feat(skills): deliver on every tier, record what agents receive, let them self-author

Three phases of the approved plan, plus a correction to what the last one
claimed.

CORRECTION: skills reached ONE tier, not all of them

The previous commit said "skills can now reach a mission agent". That was
true only for the container/ZeroClaw tier — the fall-through that queues a
topology_runs row for topology_worker, which drives the executor that was
patched. compose_turn_prompt/pinned_skills_text had exactly one production
caller, and phase_runner's three other paths (composed microVM, solo
microVM, direct session) never called it. CAPABILITY-REVIEW.md said the
broad thing too; both are corrected.

Those three tiers share one task string and have no per-turn alias, so
their skills resolve per PHASE from the mission's crew and are appended
there. The container tier deliberately still injects per turn, with the
running node's own role — appending in both places would put every crew
member's skills in every turn twice.

The behavioural tests prove phase_skills_text and compose_turn_prompt work.
They cannot prove the three launch_* calls pass the composed string, and
that substitution is a one-word edit that would silently return all three
tiers to delivering nothing with every test still green. So there is also a
source-level assertion on the call sites, following the precedent in
mission_events::the_cap_is_enforced_in_one_statement. Its negative control
names the exact tier.

PROVENANCE: what an agent received, and what it said it did

Both were unanswerable. The prompt was never stored anywhere on any tier —
re-deriving it later re-runs the skill lookup against a catalogue that has
since changed, and once agents author their own skills it certainly will
have. The reasoning rows were durably write-only: pushed live once, then
never read from the database again by anything except the GC that deletes
them.

  - prompt.composed records the exact bytes, on all four tiers
  - the session tier writes its checkpoint record and a reasoning row,
    instead of eprintln! and nothing — the same defect the solo microVM
    path was fixed for, in the last tier that still had it
  - narrative_for_mission reads both back

Found while doing it: the 400-event per-phase cap counted EVERY kind, so a
busy phase could push out its own phase.completed and its own provenance.
The cap now counts only the two unbounded kinds it was written for.
Negative control confirms the old behaviour dropped the prompt.

Retention is now a per-mission hold (0080) rather than a raised global —
with a test asserting unheld missions are still reaped, because an
exemption that applies to everything is not an exemption.

SELF-AUTHORING: agents apply their own skill drafts, no human click

By operator decision. level_up has generated complete drafts from a model
since it shipped; only a checkbox stood between propose and apply.

What replaces the gate is not another gate but four properties, each held
by a test:

  - workspace-scoped, so a hand-authored skill can never be modified
  - a draft cannot take a hand-authored skill's name. Ids are scoped and
    bindings resolve by skill_id, so it could not overwrite or shadow one
    anyway — but two procedures under one name means nobody reading a
    transcript can tell which the agent followed, and that ambiguity is
    fatal in a system where the skill is the standard being graded against
  - every revision appends a skill_versions row, so it can be reverted and
    a past run can be read against the text it was actually judged under
  - approved_by = NULL. An agent's decision is never attributed to a person
    who did not make it

Only skill_candidate applies autonomously. identity_refinement and
brain_consolidation still wait for a human: they change what an agent IS
rather than adding a procedure it can consult. State is announced at boot,
because a safety gate that changes silently is one nobody notices changed.
CLAWMATES_SKILL_SELF_AUTHORING=0 restores it.

Also: the test Postgres ran out of /dev/shm mid-suite (Docker's 64MB
default) and surfaced it during MIGRATIONS, which reads like a schema fault
and is not one. --shm-size=1g, and a pointer to the `clean` subcommand that
already existed for the 779 leaked test databases.

Full workspace suite green: 106 binaries, no failures.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-19 10:24:35 -07:00
co-authored by Claude Opus 5
parent e3247fee4b
commit 769e002bb3
15 changed files with 1202 additions and 21 deletions
+5
View File
@@ -341,6 +341,11 @@ async fn run() -> Result<(), String> {
// for INT-XX markers in event payloads and upserts mission_tasks // for INT-XX markers in event payloads and upserts mission_tasks
// rows so the canvas renders a live status timeline. // rows so the canvas renders a live status timeline.
cm_api::task_card_worker::spawn(pool.clone()); cm_api::task_card_worker::spawn(pool.clone());
// Agents apply their own skill drafts. Announced at boot by the spawner
// itself, because this flips an approval gate that existed since the
// feature shipped — and a safety gate whose state is invisible is one
// nobody notices has changed.
cm_api::skill_self_authoring::spawn(pool.clone());
// Load the workflow recipes now rather than lazily on first mission // Load the workflow recipes now rather than lazily on first mission
// create, so a malformed TOML shows up in the boot log instead of // create, so a malformed TOML shows up in the boot log instead of
// silently yielding a mission with no phase config. // silently yielding a mission with no phase config.
+147 -2
View File
@@ -225,6 +225,91 @@ pub async fn apply(
Ok(()) Ok(())
} }
/// Is autonomous skill authoring on?
///
/// Default ON, by operator decision. Stated at boot rather than assumed: this
/// flips a human approval gate that has existed since the feature shipped, and
/// a safety gate that changes state silently is how nobody notices it changed.
pub fn self_authoring_enabled() -> bool {
!matches!(
std::env::var("CLAWMATES_SKILL_SELF_AUTHORING")
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str(),
"0" | "off" | "false"
)
}
/// Apply a pending proposal's `skill_candidate` items with no human decision.
///
/// ONLY `skill_candidate`. The other item kinds are deliberately left to the
/// human gate: `identity_refinement` rewrites an agent's system prompt and
/// `brain_consolidation` edits its memory, and both change what the agent IS
/// rather than adding a procedure it can consult. Self-authoring a skill is
/// recoverable — the row is workspace-scoped, versioned and revertible, and
/// cannot take a hand-authored name. Rewriting an identity autonomously is not
/// the same bet, and it is not the one that was asked for.
///
/// The remaining items stay pending, so a human still sees them.
pub async fn apply_autonomous(
pool: &PgPool,
workspace_id: cm_domain::WorkspaceId,
proposal_id: Uuid,
) -> Result<Vec<String>, String> {
let proposal = cm_db::repo::level_up::get(pool, proposal_id, workspace_id.as_uuid())
.await
.map_err(|e| format!("load proposal: {e}"))?
.ok_or_else(|| "proposal not found".to_string())?;
if proposal.status != "pending" {
return Err(format!("proposal already {}", proposal.status));
}
let items = proposal
.payload
.get("suggested_items")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut applied: Vec<String> = Vec::new();
let mut candidates = 0usize;
for item in items {
let Some(item_id) = item.get("id").and_then(|v| v.as_str()) else {
continue;
};
if item.get("kind").and_then(|v| v.as_str()) != Some("skill_candidate") {
continue;
}
candidates += 1;
match apply_skill_candidate(pool, &proposal, &item).await {
Ok(()) => applied.push(item_id.to_string()),
// A refused draft is a normal outcome (a name collision with a
// hand-authored skill is the common one), not a failure of the
// sweep. Said out loud so a refusal is never mistaken for the
// agent simply not having proposed anything.
Err(e) => eprintln!(
"level_up: autonomous apply refused {item_id} for workspace {}: {e}",
workspace_id.as_uuid()
),
}
}
if candidates == 0 {
return Ok(Vec::new());
}
cm_db::repo::level_up::mark_applied_autonomously(
pool,
proposal_id,
workspace_id.as_uuid(),
&applied,
applied.len() != candidates,
)
.await
.map_err(|e| format!("mark applied: {e}"))?;
Ok(applied)
}
// ── Appliers ─────────────────────────────────────────────────── // ── Appliers ───────────────────────────────────────────────────
async fn apply_identity( async fn apply_identity(
@@ -304,20 +389,61 @@ async fn apply_skill_candidate(
.collect() .collect()
}) })
.unwrap_or_default(); .unwrap_or_default();
// A draft may never take the name of a hand-authored skill.
//
// The row itself is safe — ids are workspace-scoped, so this cannot
// overwrite a builtin, and bindings resolve by skill_id rather than name,
// so it cannot shadow one either. What it CAN do is put two different
// procedures under one name in the same agent's bundle, and then nobody
// reading a transcript can tell which one the agent followed. That
// ambiguity is the whole problem in a system where the skill is the
// standard the behaviour is graded against.
let collides: Option<Uuid> = sqlx::query_scalar(
"SELECT id FROM skills WHERE name = $1 AND workspace_id IS NULL",
)
.bind(name)
.fetch_optional(pool)
.await
.map_err(|e| format!("check builtin collision: {e}"))?;
if collides.is_some() {
return Err(format!(
"skill name {name:?} is hand-authored — an agent-authored draft \
cannot take the name of a skill it is graded against"
));
}
// Workspace-scoped custom skill. Deterministic id per // Workspace-scoped custom skill. Deterministic id per
// (workspace, name) so re-approving the same draft updates in // (workspace, name) so re-approving the same draft updates in
// place rather than duplicating. // place rather than duplicating.
let id = workspace_skill_id(proposal.workspace_id, name); let id = workspace_skill_id(proposal.workspace_id, name);
// Versioned, for the same reason builtins are: a self-authored skill that
// silently replaces its own body has no undo, and the version a run was
// judged under is the only way to read that run back honestly later.
let mut tx = pool.begin().await.map_err(|e| format!("begin: {e}"))?;
let existing: Option<(i32, String)> =
sqlx::query_as("SELECT current_version, body FROM skills WHERE id = $1")
.bind(id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| format!("read current skill: {e}"))?;
let (next_version, bump) = match &existing {
Some((v, prev)) if prev == body => (*v, false),
Some((v, _)) => (v + 1, true),
None => (1, true),
};
sqlx::query( sqlx::query(
"INSERT INTO skills "INSERT INTO skills
(id, name, title, author, description, when_to_use, tags, (id, name, title, author, description, when_to_use, tags,
source_kind, workspace_id, current_version, body) source_kind, workspace_id, current_version, body)
VALUES ($1,$2,$2,'level_up',$3,$4,$5,'promoted_from_brain',$6,1,$7) VALUES ($1,$2,$2,'level_up',$3,$4,$5,'promoted_from_brain',$6,$8,$7)
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
description = EXCLUDED.description, description = EXCLUDED.description,
when_to_use = EXCLUDED.when_to_use, when_to_use = EXCLUDED.when_to_use,
tags = EXCLUDED.tags, tags = EXCLUDED.tags,
body = EXCLUDED.body, body = EXCLUDED.body,
current_version = EXCLUDED.current_version,
updated_at = now()", updated_at = now()",
) )
.bind(id) .bind(id)
@@ -327,9 +453,28 @@ async fn apply_skill_candidate(
.bind(&tags) .bind(&tags)
.bind(proposal.workspace_id) .bind(proposal.workspace_id)
.bind(body) .bind(body)
.execute(pool) .bind(next_version)
.execute(&mut *tx)
.await .await
.map_err(|e| format!("upsert skill draft: {e}"))?; .map_err(|e| format!("upsert skill draft: {e}"))?;
if bump {
sqlx::query(
"INSERT INTO skill_versions
(skill_id, version, body_md, description, when_to_use)
VALUES ($1,$2,$3,$4,$5)
ON CONFLICT DO NOTHING",
)
.bind(id)
.bind(next_version)
.bind(body)
.bind(description)
.bind(when_to_use)
.execute(&mut *tx)
.await
.map_err(|e| format!("record skill version: {e}"))?;
}
tx.commit().await.map_err(|e| format!("commit: {e}"))?;
Ok(()) Ok(())
} }
+1
View File
@@ -52,6 +52,7 @@ pub mod runtime_preflight;
pub mod runtime_provision; pub mod runtime_provision;
pub mod security_scan; pub mod security_scan;
pub mod session_executor; pub mod session_executor;
pub mod skill_self_authoring;
pub mod skills_loader; pub mod skills_loader;
pub mod subscription; pub mod subscription;
pub mod swarm; pub mod swarm;
+72 -1
View File
@@ -23,6 +23,36 @@ pub const PHASE_COMPLETED: &str = "phase.completed";
pub const TOOL_CALL: &str = "tool.call"; pub const TOOL_CALL: &str = "tool.call";
/// A tool touched a path. `target` is the path, repo-relative where known. /// A tool touched a path. `target` is the path, repo-relative where known.
pub const FILE_TOUCH: &str = "file.touch"; pub const FILE_TOUCH: &str = "file.touch";
/// The exact prompt text an agent was given. `detail.text` is the full string,
/// `target` is the role or tier that composed it.
///
/// The durable answer to "what did this agent actually receive". Skills, the
/// task, the evaluator's feedback and the tool preamble are assembled from four
/// places across three tiers, so re-deriving the prompt after the fact means
/// re-running that assembly against data that has since changed. Recording it
/// is the only way the question stays answerable.
pub const PROMPT_COMPOSED: &str = "prompt.composed";
/// The agent's own narrative for a turn. `detail.text`.
///
/// Written by `topology_worker` and pushed live once by `live_bus`. Until the
/// reader below existed, the stored row was never read again by anything: both
/// database readers in `routes/world.rs` filter to `tool.call`/`file.touch`,
/// and the only other statement touching the table is the GC that deletes it.
pub const REASONING: &str = "reasoning";
/// Kinds the per-phase cap applies to.
///
/// The cap exists to bound the two unbounded kinds: a coding phase can call
/// thousands of tools and touch thousands of paths. The others are bounded by
/// the phase's own structure — one start, one completion, one prompt per turn —
/// and counting them against the same budget meant a busy phase could push out
/// its OWN terminal event, leaving a phase that looks like it never finished.
const CAPPED_KINDS: &[&str] = &[TOOL_CALL, FILE_TOUCH];
/// Does this kind count against, and get dropped by, `PER_PHASE_CAP`?
pub fn is_capped(kind: &str) -> bool {
CAPPED_KINDS.contains(&kind)
}
/// Most events one phase may record. /// Most events one phase may record.
/// ///
@@ -89,12 +119,17 @@ pub async fn record(pool: &PgPool, e: MissionEvent) {
} else { } else {
e.detail e.detail
}; };
// The cap is still decided INSIDE the insert (see the test below), and now
// only counts the kinds it is meant to bound.
let capped = is_capped(&e.kind);
let res = sqlx::query( let res = sqlx::query(
"INSERT INTO mission_events "INSERT INTO mission_events
(mission_id, phase_id, run_id, agent_id, kind, target, detail) (mission_id, phase_id, run_id, agent_id, kind, target, detail)
SELECT $1, $2, $3, $4, $5, $6, $7 SELECT $1, $2, $3, $4, $5, $6, $7
WHERE $2::uuid IS NULL WHERE $2::uuid IS NULL
OR (SELECT count(*) FROM mission_events WHERE phase_id = $2) < $8", OR NOT $9
OR (SELECT count(*) FROM mission_events
WHERE phase_id = $2 AND kind = ANY($10)) < $8",
) )
.bind(e.mission_id) .bind(e.mission_id)
.bind(e.phase_id) .bind(e.phase_id)
@@ -104,6 +139,8 @@ pub async fn record(pool: &PgPool, e: MissionEvent) {
.bind(&e.target) .bind(&e.target)
.bind(&detail) .bind(&detail)
.bind(PER_PHASE_CAP) .bind(PER_PHASE_CAP)
.bind(capped)
.bind(CAPPED_KINDS)
.execute(pool) .execute(pool)
.await; .await;
if let Err(err) = res { if let Err(err) = res {
@@ -112,6 +149,40 @@ pub async fn record(pool: &PgPool, e: MissionEvent) {
} }
/// Record several events under one round trip's worth of intent. /// Record several events under one round trip's worth of intent.
/// Every recorded prompt and narrative for a mission, oldest first.
///
/// The read side of `PROMPT_COMPOSED` / `REASONING`. Both kinds were write-only
/// before this: the prompt was never stored at all, and the narrative was
/// stored and then read by nothing. Together they answer "what did this agent
/// receive, and what did it say it did", which is the question
/// `docs/PROVENANCE-ASSESSMENT.md` records as unanswerable.
pub async fn narrative_for_mission(
pool: &PgPool,
mission_id: Uuid,
) -> Result<Vec<(String, Option<Uuid>, Option<String>, String)>, sqlx::Error> {
let rows: Vec<(String, Option<Uuid>, Option<String>, Value)> = sqlx::query_as(
"SELECT kind, agent_id, target, detail
FROM mission_events
WHERE mission_id = $1 AND kind = ANY($2)
ORDER BY id",
)
.bind(mission_id)
.bind(&[PROMPT_COMPOSED, REASONING][..])
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|(kind, agent, target, detail)| {
let text = detail
.get("text")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
(kind, agent, target, text)
})
.collect())
}
pub async fn record_all(pool: &PgPool, events: Vec<MissionEvent>) { pub async fn record_all(pool: &PgPool, events: Vec<MissionEvent>) {
for e in events { for e in events {
record(pool, e).await; record(pool, e).await;
+12 -3
View File
@@ -137,12 +137,21 @@ const EVENT_RETENTION_DAYS: i32 = 7;
/// deployment that has been accumulating for months would otherwise take a long /// deployment that has been accumulating for months would otherwise take a long
/// lock on its first sweep after this ships. The sweep runs on a timer, so a /// lock on its first sweep after this ships. The sweep runs on a timer, so a
/// large backlog simply drains over several passes. /// large backlog simply drains over several passes.
async fn reap_mission_events(pool: &PgPool, out: &mut Reclaimed) { pub async fn reap_mission_events(pool: &PgPool, out: &mut Reclaimed) {
let res = sqlx::query( let res = sqlx::query(
"DELETE FROM mission_events "DELETE FROM mission_events
WHERE id IN ( WHERE id IN (
SELECT id FROM mission_events SELECT e.id FROM mission_events e
WHERE created_at < now() - make_interval(days => $1) JOIN missions m ON m.id = e.mission_id
WHERE e.created_at < now() - make_interval(days => $1)
-- A mission under measurement or investigation keeps its
-- events. Without this the evidence a Skill-Use baseline or a
-- provenance question depends on expires while the question
-- is still open, and the answer degrades silently into
-- \"there are no events\" — which reads identically to
-- \"nothing happened\".
AND (m.retain_events_until IS NULL
OR m.retain_events_until < now())
LIMIT 10000 LIMIT 10000
)", )",
) )
+191 -4
View File
@@ -224,6 +224,55 @@ async fn capture_finished_coding_phases(pool: &PgPool) -> Result<(), String> {
/// "/mission/repo ... is the mission's git checkout" to a mission that had none. /// "/mission/repo ... is the mission's git checkout" to a mission that had none.
/// One agent recorded the contradiction verbatim — "No git repo — file is /// One agent recorded the contradiction verbatim — "No git repo — file is
/// written" — and wrote into a container that was then reaped unread. /// written" — and wrote into a container that was then reaped unread.
#[cfg(test)]
mod skill_delivery_wiring_tests {
/// Every tier without per-turn injection must be handed the task text that
/// CARRIES the skills.
///
/// The behavioural tests in `tests/mission_skill_delivery.rs` prove
/// `phase_skills_text` and `compose_turn_prompt` work. They cannot prove
/// the three `launch_*` calls pass the composed string rather than the bare
/// one — and that substitution is a one-word edit that would silently
/// return all three tiers to delivering no skill, with every test still
/// green. Same reasoning as `mission_events::the_cap_is_enforced_in_one_statement`.
#[test]
fn the_three_solo_tiers_are_handed_the_skill_bearing_task() {
let src = include_str!("phase_runner.rs");
// Only the dispatch body, so the helper definitions and these tests do
// not satisfy the assertion by accident.
// Built at runtime, not written as one literal: a literal anchor
// appears in THIS test's own source too, and the split then matches
// itself instead of the code under test.
let anchor = format!("let task_with_skills = match {}(", "phase_skills_text");
let body = src
.split(&anchor)
.nth(1)
.and_then(|s| s.split("\nasync fn ").next())
.expect("dispatch body");
for (call, tier) in [
("launch_composed_microvm_phase(", "composed microVM"),
("launch_microvm_phase(", "solo microVM"),
("launch_direct_session(", "direct session"),
] {
let args = body
.split(call)
.nth(1)
.and_then(|s| s.split(')').next())
.unwrap_or_else(|| panic!("{tier}: call site not found"));
assert!(
args.contains("&task_with_skills"),
"{tier} is passed the bare task — that tier has no per-turn \
injection, so its agents would receive no skill at all"
);
assert!(
!args.contains("&task,"),
"{tier} is passed `&task`, the pre-skills string"
);
}
}
}
#[cfg(test)] #[cfg(test)]
mod repo_less_text_tests { mod repo_less_text_tests {
use super::*; use super::*;
@@ -887,6 +936,29 @@ async fn launch_phase(
_ => task, _ => task,
}; };
// The container tier is deliberately NOT given this: it injects per-turn in
// `topology_exec`, with the running node's own role, and appending here too
// would put every crew member's skills in every turn twice.
let task_with_skills = match phase_skills_text(pool, mission_id).await {
Some(skills) => crate::topology_exec::compose_turn_prompt(&task, Some(&skills)),
None => task.clone(),
};
// One prompt per phase on these tiers, because the phase IS one session.
// `topology_runs.task` holds a copy for two of them, but not for the
// composed path and not with the skills appended — and a provenance record
// that exists on some tiers is one that cannot be queried uniformly.
{
let mut ev = crate::mission_events::MissionEvent::new(
mission_id,
crate::mission_events::PROMPT_COMPOSED,
);
ev.phase_id = Some(phase_id);
ev.target = Some(kind.to_string());
ev.detail = serde_json::json!({ "text": task_with_skills, "tier": "solo" });
crate::mission_events::record(pool, ev).await;
}
// Direct-session executor: run the whole phase as ONE `claude -p` session // Direct-session executor: run the whole phase as ONE `claude -p` session
// against the mission checkout, instead of driving turns through ZeroClaw. // against the mission checkout, instead of driving turns through ZeroClaw.
// //
@@ -1023,7 +1095,7 @@ async fn launch_phase(
phase_id, phase_id,
workspace_id, workspace_id,
iteration, iteration,
&task, &task_with_skills,
team, team,
purposes, purposes,
) )
@@ -1037,7 +1109,7 @@ async fn launch_phase(
kind, kind,
workspace_id, workspace_id,
iteration, iteration,
&task, &task_with_skills,
p.backend, p.backend,
chosen_node, chosen_node,
p.team_engine, p.team_engine,
@@ -1048,7 +1120,14 @@ async fn launch_phase(
} }
if crate::session_executor::direct_mode() { if crate::session_executor::direct_mode() {
return launch_direct_session(pool, mission_id, phase_id, workspace_id, iteration, &task) return launch_direct_session(
pool,
mission_id,
phase_id,
workspace_id,
iteration,
&task_with_skills,
)
.await; .await;
} }
@@ -1566,16 +1645,52 @@ async fn launch_direct_session(
summary.chars().take(200).collect::<String>() summary.chars().take(200).collect::<String>()
); );
let status = if ok { "completed" } else { "failed" }; let status = if ok { "completed" } else { "failed" };
// The agent's account, kept. Until now this tier wrote NO checkpoint
// record — the same defect the solo microVM path was fixed for, in the
// one remaining tier: `summary` went to stderr above and nowhere else,
// so the Live and Output tabs were empty for a session that did real
// work, and nothing downstream could read what the agent said it did.
//
// Shape matches the microVM path exactly, so the two existing readers
// (`/api/missions/{id}/documents` and `/api/topology-runs/{id}/events`)
// need no change.
let record = serde_json::json!({
"records": [{
"node_id": "n0",
"role": "session",
"phase": "work",
"output": summary,
"tokens": 0,
"gated": [],
}]
});
// Also as a durable event, so the narrative is queryable per mission
// rather than only by walking topology_runs JSON.
let mut ev = crate::mission_events::MissionEvent::new(
mission_id,
crate::mission_events::REASONING,
);
ev.phase_id = Some(phase_id);
ev.run_id = Some(run_id);
ev.target = Some("session".to_string());
ev.detail = serde_json::json!({ "text": summary });
crate::mission_events::record(&pool, ev).await;
// Never overwrite a cancellation. The operator asking to stop is a decision; // Never overwrite a cancellation. The operator asking to stop is a decision;
// this task reporting how the VM turned out is an observation, and it may // this task reporting how the VM turned out is an observation, and it may
// land minutes later. Without the guard a cancelled run silently reappears // land minutes later. Without the guard a cancelled run silently reappears
// as completed or failed. // as completed or failed.
if let Err(e) = sqlx::query( if let Err(e) = sqlx::query(
"UPDATE topology_runs SET status = $2, updated_at = now() "UPDATE topology_runs
SET status = $2,
checkpoint = COALESCE(checkpoint, '{}'::jsonb) || $3::jsonb,
updated_at = now()
WHERE id = $1 AND status <> 'cancelled'", WHERE id = $1 AND status <> 'cancelled'",
) )
.bind(run_id) .bind(run_id)
.bind(status) .bind(status)
.bind(&record)
.execute(&pool) .execute(&pool)
.await .await
{ {
@@ -1587,6 +1702,78 @@ async fn launch_direct_session(
Ok(()) Ok(())
} }
/// The pinned skills for a phase's crew, rendered for the task text.
///
/// The container tier gets skills per TURN (`topology_exec::pinned_skills_text`),
/// where each node carries its own claw alias and therefore its own role's
/// skills. The microVM and direct-session tiers have no per-turn alias — the
/// phase runs as one `claude -p` session — so their skills have to be resolved
/// per PHASE and appended to the task instead. Without this, those three tiers
/// deliver no skill at all, which is what they did until now.
///
/// Union across the crew, deduplicated. A solo tier does not know which crew
/// member's turn it is running, and a procedure that applies to the role doing
/// the work still applies when one agent does all of it. Erring toward the
/// union is safe here in a way it would not be on the container tier, where
/// per-role precision is available and used.
pub async fn phase_skills_text(pool: &PgPool, mission_id: Uuid) -> Option<String> {
let crew = sqlx::query(
"SELECT DISTINCT a.id
FROM team_members tm
JOIN mission_teams mt ON mt.team_id = tm.team_id
JOIN agents a ON a.id = tm.claw_id
WHERE mt.mission_id = $1 AND a.deleted_at IS NULL",
)
.bind(mission_id)
.fetch_all(pool)
.await
.unwrap_or_default();
let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
let mut out = String::new();
for row in &crew {
let agent_id: Uuid = row.get("id");
let link = cm_db::repo::agent_template_link::get(pool, agent_id)
.await
.ok()
.flatten();
let (tpl_id, slot) = link
.as_ref()
.map(|l| (Some(l.template_id), Some(l.role_slot.as_str())))
.unwrap_or((None, None));
let Ok(bindings) =
cm_db::repo::skills_catalog::effective_for_agent(pool, agent_id, tpl_id, slot).await
else {
continue;
};
for b in bindings.iter().filter(|b| b.pin_in_context) {
if !seen.insert(b.skill.name.clone()) {
continue;
}
// Bounded, and truncation is STATED. A silently clipped procedure is
// worse than an absent one: the agent follows the half it can see
// and reports success against a rule it never read.
if out.len() + b.skill.body.len() > crate::topology_exec::MAX_PINNED_SKILL_BYTES {
out.push_str(&format!(
"\n[skill \"{}\" omitted — the pinned set exceeded {} bytes]\n",
b.skill.name,
crate::topology_exec::MAX_PINNED_SKILL_BYTES
));
continue;
}
out.push_str("\n## ");
out.push_str(&b.skill.name);
out.push('\n');
out.push_str(&b.skill.body);
out.push('\n');
}
}
if seen.is_empty() {
return None;
}
Some(out)
}
fn phase_task_text( fn phase_task_text(
kind: &str, kind: &str,
title: &str, title: &str,
+93
View File
@@ -0,0 +1,93 @@
//! Applies agents' own skill drafts, with no human decision.
//!
//! `level_up` has generated complete skill drafts from a model since it
//! shipped; the only thing between a draft and the catalogue was an operator
//! ticking a checkbox in `LevelUpDrawer`. This worker removes the checkbox, by
//! operator decision.
//!
//! What is deliberately NOT removed is the record. Every write stays
//! workspace-scoped and versioned, cannot take the name of a hand-authored
//! skill, and lands with `approved_by = NULL` — so "an agent decided this" is
//! distinguishable from "a person decided this" forever after, which is the
//! property that makes the change reversible instead of merely fast.
//!
//! Only `skill_candidate` items apply here. `identity_refinement` and
//! `brain_consolidation` still wait for a human: they change what an agent IS
//! rather than adding a procedure it can consult.
use sqlx::{PgPool, Row};
use std::time::Duration;
/// How often to sweep for pending drafts.
///
/// Proposals arrive when someone runs a level-up, not continuously, so this is
/// slow on purpose — the work is bounded by how often an agent reflects, and
/// polling faster would only add load.
const SWEEP_INTERVAL: Duration = Duration::from_secs(120);
/// Start the sweep, unless self-authoring is switched off.
pub fn spawn(pool: PgPool) {
if !crate::level_up::self_authoring_enabled() {
eprintln!(
"skill_self_authoring: DISABLED (CLAWMATES_SKILL_SELF_AUTHORING) — \
agent skill drafts wait for a human in the level-up drawer"
);
return;
}
eprintln!(
"skill_self_authoring: ENABLED — agents apply their own skill drafts \
without human approval. Writes are workspace-scoped, versioned, and \
cannot take a hand-authored skill's name; each lands with no approver \
recorded. Set CLAWMATES_SKILL_SELF_AUTHORING=0 to restore the gate."
);
tokio::spawn(async move {
loop {
if let Err(e) = sweep(&pool).await {
eprintln!("skill_self_authoring: sweep failed: {e}");
}
tokio::time::sleep(SWEEP_INTERVAL).await;
}
});
}
/// Apply every pending proposal's skill candidates. Returns how many skills landed.
pub async fn sweep(pool: &PgPool) -> Result<usize, String> {
// Bounded per pass: a backlog drains over several sweeps rather than
// holding the pool for as long as it takes to apply all of it.
let rows = sqlx::query(
"SELECT id, workspace_id FROM level_up_proposals
WHERE status = 'pending'
ORDER BY created_at
LIMIT 20",
)
.fetch_all(pool)
.await
.map_err(|e| format!("select pending proposals: {e}"))?;
let mut applied = 0usize;
for row in &rows {
let id: uuid::Uuid = row.get("id");
let workspace_id: uuid::Uuid = row.get("workspace_id");
match crate::level_up::apply_autonomous(
pool,
cm_domain::WorkspaceId::from(workspace_id),
id,
)
.await
{
Ok(items) if !items.is_empty() => {
applied += items.len();
eprintln!(
"skill_self_authoring: applied {} skill draft(s) from proposal {id} \
with no human approval",
items.len()
);
}
// A proposal with no skill candidates is left pending on purpose —
// its identity/memory items still belong to the human gate.
Ok(_) => {}
Err(e) => eprintln!("skill_self_authoring: proposal {id}: {e}"),
}
}
Ok(applied)
}
+17 -1
View File
@@ -47,7 +47,7 @@ const TURN_TIMEOUT: Duration = Duration::from_secs(3600);
/// Skill bodies average ~3.5 KB and pinning is `idx < 2 || foundation`, so a /// Skill bodies average ~3.5 KB and pinning is `idx < 2 || foundation`, so a
/// role lands near 7-10 KB. The cap exists for the role that grows a long /// role lands near 7-10 KB. The cap exists for the role that grows a long
/// foundation set, and it is stated in the prompt when it fires. /// foundation set, and it is stated in the prompt when it fires.
const MAX_PINNED_SKILL_BYTES: usize = 24_000; pub(crate) const MAX_PINNED_SKILL_BYTES: usize = 24_000;
pub struct ZeroClawDriveExecutor { pub struct ZeroClawDriveExecutor {
/// Gateway base URL, e.g. `http://127.0.0.1:42617`. /// Gateway base URL, e.g. `http://127.0.0.1:42617`.
@@ -744,6 +744,22 @@ impl TurnExecutor for ZeroClawDriveExecutor {
&Self::build_prompt(&req), &Self::build_prompt(&req),
self.pinned_skills_text(&alias).await.as_deref(), self.pinned_skills_text(&alias).await.as_deref(),
); );
// Record what this agent is ACTUALLY about to receive, before driving.
// Re-deriving it later would re-run the skill lookup against a
// catalogue that may have changed — and once agents author their own
// skills, it certainly will have.
if let Some(tap) = self.tap.as_ref() {
let mut ev = crate::mission_events::MissionEvent::new(
tap.mission_id,
crate::mission_events::PROMPT_COMPOSED,
);
ev.phase_id = tap.phase_id;
ev.run_id = tap.run_id;
ev.agent_id = crate::runtime_provision::claw_from_alias(&alias);
ev.target = Some(req.role.clone());
ev.detail = serde_json::json!({ "text": prompt, "tier": "container" });
crate::mission_events::record(&tap.pool, ev).await;
}
self.drive(&alias, &prompt).await self.drive(&alias, &prompt).await
} }
} }
@@ -211,3 +211,109 @@ fn the_composed_prompt_carries_the_skill_and_omits_the_heading_when_empty() {
); );
} }
} }
// ── The other three tiers ───────────────────────────────────────────
//
// `topology_exec` injects per TURN and covers only the container tier. The
// composed-microVM, solo-microVM and direct-session paths in `phase_runner`
// share one task string built by `phase_task_text`, and until now that string
// carried no skill at all — so a mission on any of those tiers ran with the
// catalogue unreachable, exactly as the container tier did before e4942ce.
/// Bind a claw to a mission's crew so `phase_skills_text` can find it.
async fn seed_mission_with_crew(pool: &sqlx::PgPool, ws: WorkspaceId, agent: AgentId) -> Uuid {
let mission = Uuid::now_v7();
sqlx::query(
"INSERT INTO missions (id, workspace_id, title, template_kind, status)
VALUES ($1, $2, 'skill delivery', 'research_only', 'running')",
)
.bind(mission)
.bind(ws.as_uuid())
.execute(pool)
.await
.unwrap();
let team = Uuid::now_v7();
sqlx::query(
"INSERT INTO teams (id, workspace_id, name, kind, lifecycle, graph)
VALUES ($1, $2, 'crew', 'pipeline', 'permanent', '{}'::jsonb)",
)
.bind(team)
.bind(ws.as_uuid())
.execute(pool)
.await
.unwrap();
sqlx::query("INSERT INTO team_members (team_id, claw_id, node_id, role) VALUES ($1, $2, 'researcher', 'researcher')")
.bind(team)
.bind(agent.as_uuid())
.execute(pool)
.await
.unwrap();
sqlx::query("INSERT INTO mission_teams (mission_id, team_id, purpose) VALUES ($1, $2, 'mission')")
.bind(mission)
.bind(team)
.execute(pool)
.await
.unwrap();
mission
}
#[tokio::test]
async fn the_microvm_and_session_tiers_get_the_skill_in_their_task_text() {
let pool = cm_testkit::test_pool().await;
const MARKER: &str = "Never review a paper from its title alone.";
let (alias, ws) = seed_claw_with_pinned_skill(&pool, MARKER).await;
let agent = AgentId::from(cm_api::runtime_provision::claw_from_alias(&alias).unwrap());
let mission = seed_mission_with_crew(&pool, ws, agent).await;
let skills = cm_api::phase_runner::phase_skills_text(&pool, mission)
.await
.expect("a mission whose crew holds a pinned skill must produce skill text");
assert!(
skills.contains(MARKER),
"the pinned BODY must reach the phase task — these tiers run one \
`claude -p` session with no per-turn injection, so this string is the \
agent's only route to the procedure. Got:\n{skills}"
);
// All three tiers share this composition, so testing it once covers them.
let composed = cm_api::topology_exec::compose_turn_prompt("Task: read the papers", Some(&skills));
assert!(composed.contains(MARKER));
assert!(composed.contains("Task: read the papers"));
}
#[tokio::test]
async fn a_mission_whose_crew_has_no_skills_adds_nothing() {
let pool = cm_testkit::test_pool().await;
let ws = Workspace {
id: WorkspaceId::new(),
name: "Bare Crew".into(),
plan: "team".into(),
};
cm_db::repo::workspaces::insert(&pool, &ws).await.unwrap();
let user = seed_user(&pool, ws.id).await;
let agent = Agent {
id: AgentId::new(),
workspace_id: ws.id,
name: "Bare".into(),
job_title: "researcher".into(),
system_prompt: String::new(),
avatar: String::new(),
accent: String::new(),
wallpaper: String::new(),
managed_by: user,
status: AgentStatus::Online,
};
cm_db::repo::agents::insert(&pool, &agent, &cm_domain::AccessPolicy::default())
.await
.unwrap();
let mission = seed_mission_with_crew(&pool, ws.id, agent.id).await;
assert!(
cm_api::phase_runner::phase_skills_text(&pool, mission)
.await
.is_none(),
"a crew with no pinned skills must add no section — the empty-heading \
rule has to hold on this path too"
);
}
+174
View File
@@ -0,0 +1,174 @@
//! What an agent received, and what it said it did, must survive the phase.
//!
//! `docs/PROVENANCE-ASSESSMENT.md` records "why did the agent say X?" as
//! unanswerable. The first two things it needs are the prompt and the
//! narrative. Before this, the prompt was never stored at all, and the
//! narrative was stored and then read by nothing — both database readers in
//! `routes/world.rs` filter to `tool.call`/`file.touch`, and the only other
//! statement touching the table is the GC that deletes it.
use cm_api::mission_events::{self, MissionEvent, PER_PHASE_CAP, PROMPT_COMPOSED, REASONING, TOOL_CALL};
use cm_domain::{Workspace, WorkspaceId};
use uuid::Uuid;
async fn seed_phase(pool: &sqlx::PgPool) -> (Uuid, Uuid) {
let ws = Workspace {
id: WorkspaceId::new(),
name: "Provenance Test".into(),
plan: "team".into(),
};
cm_db::repo::workspaces::insert(pool, &ws).await.unwrap();
let mission = Uuid::now_v7();
sqlx::query(
"INSERT INTO missions (id, workspace_id, title, template_kind, status)
VALUES ($1, $2, 'provenance', 'research_only', 'running')",
)
.bind(mission)
.bind(ws.id.as_uuid())
.execute(pool)
.await
.unwrap();
let phase = Uuid::now_v7();
sqlx::query(
"INSERT INTO mission_phases (id, mission_id, kind, order_idx, status)
VALUES ($1, $2, 'research', 0, 'running')",
)
.bind(phase)
.bind(mission)
.execute(pool)
.await
.unwrap();
(mission, phase)
}
#[tokio::test]
async fn the_prompt_and_the_narrative_are_both_readable_after_the_fact() {
let pool = cm_testkit::test_pool().await;
let (mission, phase) = seed_phase(&pool).await;
let mut prompt = MissionEvent::new(mission, PROMPT_COMPOSED);
prompt.phase_id = Some(phase);
prompt.target = Some("researcher".into());
prompt.detail = serde_json::json!({ "text": "Task: read the papers\n## arxiv-daily\nDo not re-search." });
mission_events::record(&pool, prompt).await;
let mut said = MissionEvent::new(mission, REASONING);
said.phase_id = Some(phase);
said.detail = serde_json::json!({ "text": "I read the manifest and wrote analysis.md." });
mission_events::record(&pool, said).await;
let narrative = mission_events::narrative_for_mission(&pool, mission)
.await
.unwrap();
let prompt_text = narrative
.iter()
.find(|(kind, ..)| kind == PROMPT_COMPOSED)
.map(|(.., text)| text.clone())
.expect("the prompt must be recoverable — re-deriving it later re-runs \
the skill lookup against a catalogue that will have changed");
assert!(prompt_text.contains("Do not re-search."));
assert!(
prompt_text.contains("Task: read the papers"),
"the whole composed prompt, not just the skills half"
);
assert!(
narrative
.iter()
.any(|(kind, .., text)| kind == REASONING && text.contains("analysis.md")),
"the agent's own account must come back out of the database"
);
}
#[tokio::test]
async fn a_busy_phase_cannot_push_out_its_own_provenance() {
let pool = cm_testkit::test_pool().await;
let (mission, phase) = seed_phase(&pool).await;
// Fill the phase past the cap with the kind the cap exists to bound.
for i in 0..(PER_PHASE_CAP + 20) {
let mut ev = MissionEvent::new(mission, TOOL_CALL);
ev.phase_id = Some(phase);
ev.target = Some(format!("tool_{i}"));
mission_events::record(&pool, ev).await;
}
let tool_rows: i64 = sqlx::query_scalar(
"SELECT count(*) FROM mission_events WHERE phase_id = $1 AND kind = $2",
)
.bind(phase)
.bind(TOOL_CALL)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
tool_rows, PER_PHASE_CAP,
"the cap must still bound the kind it was written for"
);
// The prompt arrives AFTER the flood, which is the real ordering: a coding
// phase calls its tools and then the next turn is composed.
let mut prompt = MissionEvent::new(mission, PROMPT_COMPOSED);
prompt.phase_id = Some(phase);
prompt.detail = serde_json::json!({ "text": "the next turn's prompt" });
mission_events::record(&pool, prompt).await;
let narrative = mission_events::narrative_for_mission(&pool, mission)
.await
.unwrap();
assert!(
narrative.iter().any(|(.., text)| text == "the next turn's prompt"),
"a phase that called {} tools dropped its own prompt — the cap counted \
provenance against a budget meant for the two unbounded kinds, so the \
busier the phase, the less of it is explainable",
PER_PHASE_CAP + 20
);
}
#[tokio::test]
async fn a_mission_under_measurement_keeps_its_events_past_the_window() {
let pool = cm_testkit::test_pool().await;
let (kept, kept_phase) = seed_phase(&pool).await;
let (reaped, reaped_phase) = seed_phase(&pool).await;
// Only one of them is held.
sqlx::query("UPDATE missions SET retain_events_until = now() + interval '30 days' WHERE id = $1")
.bind(kept)
.execute(&pool)
.await
.unwrap();
for (mission, phase) in [(kept, kept_phase), (reaped, reaped_phase)] {
let mut ev = MissionEvent::new(mission, PROMPT_COMPOSED);
ev.phase_id = Some(phase);
ev.detail = serde_json::json!({ "text": "the prompt" });
mission_events::record(&pool, ev).await;
}
// Age both beyond the global window.
sqlx::query("UPDATE mission_events SET created_at = now() - interval '90 days'")
.execute(&pool)
.await
.unwrap();
let mut out = cm_api::mission_gc::Reclaimed::default();
cm_api::mission_gc::reap_mission_events(&pool, &mut out).await;
assert!(
!mission_events::narrative_for_mission(&pool, kept)
.await
.unwrap()
.is_empty(),
"a mission held for measurement lost its events — the evidence expires \
while the question is still open, and 'no events' reads exactly like \
'nothing happened'"
);
assert!(
mission_events::narrative_for_mission(&pool, reaped)
.await
.unwrap()
.is_empty(),
"an unheld mission must still be reaped — an exemption that applies to \
everything is not an exemption, it is a raised global bound"
);
}
+255
View File
@@ -0,0 +1,255 @@
//! Agents author their own skills, with no human in the loop.
//!
//! The operator's decision. The machinery already existed — `level_up` has
//! generated full skill drafts from a model since it shipped — and the only
//! thing between propose and apply was an operator ticking a checkbox.
//!
//! What replaces that checkbox is not another gate but three properties, and
//! these tests are what hold them: the write is workspace-scoped and can never
//! take a hand-authored skill's name, every change appends a version so it can
//! be read back and reverted, and a proposal applied with no human carries no
//! human's name in its approval trail.
use cm_domain::{Agent, AgentId, AgentStatus, Role, User, UserId, Workspace, WorkspaceId};
use serde_json::json;
use uuid::Uuid;
/// A workspace with one agent. `level_up_target_one` requires a proposal to
/// name exactly one of agent_id / team_id, so the agent is not optional here.
async fn seed_workspace(pool: &sqlx::PgPool) -> (WorkspaceId, AgentId) {
let ws = Workspace {
id: WorkspaceId::new(),
name: "Self Authoring".into(),
plan: "team".into(),
};
cm_db::repo::workspaces::insert(pool, &ws).await.unwrap();
let user = User {
id: UserId::new(),
workspace_id: ws.id,
email: format!("owner-{}@example.com", Uuid::now_v7().simple()),
role: Role::Owner,
display_name: "Owner".into(),
created_at: time::OffsetDateTime::UNIX_EPOCH,
};
cm_db::repo::users::insert(pool, &user).await.unwrap();
let agent = Agent {
id: AgentId::new(),
workspace_id: ws.id,
name: "Scribe".into(),
job_title: "researcher".into(),
system_prompt: String::new(),
avatar: String::new(),
accent: String::new(),
wallpaper: String::new(),
managed_by: user.id,
status: AgentStatus::Online,
};
cm_db::repo::agents::insert(pool, &agent, &cm_domain::AccessPolicy::default())
.await
.unwrap();
(ws.id, agent.id)
}
/// A pending proposal carrying one `skill_candidate` draft.
async fn seed_proposal(
pool: &sqlx::PgPool,
ws: WorkspaceId,
agent: AgentId,
name: &str,
body: &str,
) -> Uuid {
let payload = json!({
"suggested_items": [{
"id": "item-1",
"kind": "skill_candidate",
"draft": {
"name": name,
"description": "a procedure the agent wrote for itself",
"when_to_use": "when the situation arises",
"body": body,
"tags": ["self-authored"],
}
}]
});
cm_db::repo::level_up::insert(
pool,
cm_db::repo::level_up::NewProposal {
workspace_id: ws.as_uuid(),
agent_id: Some(agent.as_uuid()),
team_id: None,
payload: &payload,
model: Some("glm:glm-4.7"),
created_by: None,
},
)
.await
.unwrap()
}
#[tokio::test]
async fn an_agent_applies_its_own_skill_with_no_human_and_it_is_versioned() {
let pool = cm_testkit::test_pool().await;
let (ws, agent) = seed_workspace(&pool).await;
let p1 = seed_proposal(&pool, ws, agent, "vault-note-shape", "First: write the date line.").await;
let applied = cm_api::level_up::apply_autonomous(&pool, ws, p1)
.await
.expect("autonomous apply must succeed");
assert_eq!(applied, vec!["item-1".to_string()]);
let (id, source_kind, workspace, version): (Uuid, String, Option<Uuid>, i32) = sqlx::query_as(
"SELECT id, source_kind, workspace_id, current_version FROM skills WHERE name = $1",
)
.bind("vault-note-shape")
.fetch_one(&pool)
.await
.expect("the skill must exist with no human approval");
assert_eq!(source_kind, "promoted_from_brain", "self-authored skills must stay distinguishable from builtins in one query");
assert_eq!(workspace, Some(ws.as_uuid()), "must be workspace-scoped, never global");
assert_eq!(version, 1);
// The approval trail must not name a human who did not approve.
let approved_by: Option<Uuid> =
sqlx::query_scalar("SELECT approved_by FROM level_up_proposals WHERE id = $1")
.bind(p1)
.fetch_one(&pool)
.await
.unwrap();
assert!(
approved_by.is_none(),
"an autonomously applied proposal must record NO approver — putting a \
user id here would attribute a decision to someone who never made it"
);
// A revision bumps the version and keeps the old body readable.
let p2 = seed_proposal(&pool, ws, agent, "vault-note-shape", "First: write the date line. Then the source.").await;
cm_api::level_up::apply_autonomous(&pool, ws, p2).await.unwrap();
let versions: Vec<(i32, String)> =
sqlx::query_as("SELECT version, body_md FROM skill_versions WHERE skill_id = $1 ORDER BY version")
.bind(id)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(
versions.len(),
2,
"each self-authored revision must append a version — without history \
there is no revert, and no way to read back which text a past run was \
actually judged under"
);
assert_eq!(versions[0].1, "First: write the date line.");
assert!(versions[1].1.contains("Then the source."));
}
#[tokio::test]
async fn a_draft_cannot_take_a_hand_authored_skills_name() {
let pool = cm_testkit::test_pool().await;
let (ws, agent) = seed_workspace(&pool).await;
// A builtin, as `skills_loader` writes them: global, workspace_id NULL.
let builtin = Uuid::now_v7();
sqlx::query(
"INSERT INTO skills
(id, name, title, author, description, when_to_use, tags,
source_kind, workspace_id, current_version, body)
VALUES ($1,'arxiv-daily','arxiv-daily','system','the real one','always',
'{}','builtin',NULL,1,'Do NOT re-search arXiv.')",
)
.bind(builtin)
.execute(&pool)
.await
.unwrap();
let p = seed_proposal(&pool, ws, agent, "arxiv-daily", "Actually, re-searching arXiv is fine.").await;
let applied = cm_api::level_up::apply_autonomous(&pool, ws, p).await.unwrap();
assert!(
applied.is_empty(),
"a draft taking a hand-authored name must be refused: two procedures \
under one name means nobody reading a transcript can tell which the \
agent followed — and this one inverts the rule it shadows"
);
let body: String = sqlx::query_scalar("SELECT body FROM skills WHERE id = $1")
.bind(builtin)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
body, "Do NOT re-search arXiv.",
"the hand-authored skill must be untouched"
);
let n: i64 = sqlx::query_scalar("SELECT count(*) FROM skills WHERE name = 'arxiv-daily'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(n, 1, "no second row may exist under that name");
}
#[tokio::test]
async fn autonomous_apply_leaves_identity_and_memory_items_for_a_human() {
let pool = cm_testkit::test_pool().await;
let (ws, agent) = seed_workspace(&pool).await;
let payload = json!({
"suggested_items": [
{ "id": "skill-1", "kind": "skill_candidate",
"draft": { "name": "commit-message-shape", "description": "d",
"body": "Say what changed and why.", "tags": [] } },
{ "id": "identity-1", "kind": "identity_refinement",
"new_system_prompt": "You are now a different agent." }
]
});
let p = cm_db::repo::level_up::insert(
&pool,
cm_db::repo::level_up::NewProposal {
workspace_id: ws.as_uuid(),
agent_id: Some(agent.as_uuid()),
team_id: None,
payload: &payload,
model: Some("glm:glm-4.7"),
created_by: None,
},
)
.await
.unwrap();
let applied = cm_api::level_up::apply_autonomous(&pool, ws, p).await.unwrap();
assert_eq!(
applied,
vec!["skill-1".to_string()],
"only skill_candidate items apply autonomously — an identity rewrite \
changes what the agent IS rather than adding a procedure it can \
consult, and that is a different bet than the one that was taken"
);
}
#[tokio::test]
async fn the_sweep_applies_pending_drafts_and_leaves_nothing_pending_twice() {
let pool = cm_testkit::test_pool().await;
let (ws, agent) = seed_workspace(&pool).await;
seed_proposal(&pool, ws, agent, "swept-skill", "Say what changed and why.").await;
let n = cm_api::skill_self_authoring::sweep(&pool).await.unwrap();
assert_eq!(n, 1, "the sweep must apply the pending draft with no human");
let body: String = sqlx::query_scalar("SELECT body FROM skills WHERE name = 'swept-skill'")
.fetch_one(&pool)
.await
.expect("the swept draft must be in the catalogue");
assert_eq!(body, "Say what changed and why.");
// Idempotent: the proposal is no longer pending, so a second pass is a
// no-op rather than a duplicate apply or a version bump for no change.
let again = cm_api::skill_self_authoring::sweep(&pool).await.unwrap();
assert_eq!(again, 0, "a swept proposal must not be applied twice");
let versions: i64 =
sqlx::query_scalar("SELECT count(*) FROM skill_versions sv JOIN skills s ON s.id = sv.skill_id WHERE s.name = 'swept-skill'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(versions, 1, "an unchanged body must not append a version");
}
+29
View File
@@ -122,6 +122,35 @@ pub async fn mark_applied(
Ok(()) Ok(())
} }
/// Mark a proposal applied with NO approving user.
///
/// `approved_by` stays NULL, which is the truthful record when a proposal was
/// applied autonomously. Reusing `mark_applied` with some stand-in user id
/// would put a human's name on a decision no human made — and the approval
/// trail is one of the few things in this system that has to be exactly true.
pub async fn mark_applied_autonomously(
pool: &PgPool,
id: Uuid,
workspace_id: Uuid,
applied_items: &[String],
partial: bool,
) -> Result<(), DbError> {
let status = if partial { "partial" } else { "applied" };
sqlx::query(
"UPDATE level_up_proposals
SET status = $3, applied_items = $4, approved_by = NULL,
applied_at = now()
WHERE id = $1 AND workspace_id = $2 AND status = 'pending'",
)
.bind(id)
.bind(workspace_id)
.bind(status)
.bind(applied_items)
.execute(pool)
.await?;
Ok(())
}
pub async fn mark_rejected( pub async fn mark_rejected(
pool: &PgPool, pool: &PgPool,
id: Uuid, id: Uuid,
+65 -5
View File
@@ -20,6 +20,49 @@ stray `TODO` in production paths. **Every defect found in this review was a
wiring defect** — code that was correct, reachable by nothing, and reported as wiring defect** — code that was correct, reachable by nothing, and reported as
working. working.
## Where skills come from
Asked directly, and worth recording because it was not answerable from any
document before now.
- **All 53 catalogue skills are hand-authored markdown** committed to
`skills/**/*.md`, ingested at boot by `skills_loader` with
`source_kind='builtin'`, `workspace_id = NULL`. None are generated at runtime
and none are pulled from anywhere.
- **An LLM-authoring path exists and is fully wired**: `level_up.rs:435` calls a
model (default `glm:glm-4.7`) whose prompt asks for `skill_candidate` items
carrying a complete `draft.body`. Nothing is written to `skills` at propose
time — the payload sits in `level_up_proposals` as `pending`.
- **Application WAS gated on a human click**, and as of 2026-08-19 is not.
`apply()` still honours `approved_item_ids` for the manual path, but
`skill_self_authoring` now sweeps pending proposals every two minutes and
applies their `skill_candidate` items autonomously, by operator decision.
What replaces the gate is not another gate but four properties, each held by
a test in `tests/skill_self_authoring.rs`:
- the write is **workspace-scoped** (`workspace_id` set, never NULL), so it
can never modify a hand-authored skill;
- a draft **cannot take a hand-authored skill's name** — ids are scoped and
bindings resolve by `skill_id`, so it could not overwrite or shadow one
anyway, but two procedures under one name means nobody reading a transcript
can tell which the agent followed;
- every revision **appends a `skill_versions` row**, so a self-authored skill
can be reverted and a past run can be read back against the text it was
actually judged under;
- the proposal lands with **`approved_by = NULL`** — an agent's decision is
never attributed to a person who did not make it.
`identity_refinement` and `brain_consolidation` still wait for a human: they
change what an agent *is* rather than adding a procedure it can consult.
Disable with `CLAWMATES_SKILL_SELF_AUTHORING=0`; the state is announced at
boot either way.
- **No external registry feeds the catalogue.** ClawBrainHub trades `.brain`
files and never touches the `skills` table. `cm_db::repo::skills::create` —
the only path that would produce `source_kind='hand_authored'` — has no
production caller.
So the only way a skill enters the catalogue today is a committed markdown file
or an operator approving an agent's draft.
## The theme ## The theme
Everything below is one shape, the project's own "silent-success" class: a Everything below is one shape, the project's own "silent-success" class: a
@@ -69,10 +112,24 @@ used either, which `mission_orchestrator` documents.
So every skill authored for a mission role was unreachable prose, and the So every skill authored for a mission role was unreachable prose, and the
Skill-Use measurement this review planned could only ever have returned zero. Skill-Use measurement this review planned could only ever have returned zero.
Fixed: `provision_claw` now provisions the template's bundles (door always Fixed **on the container/ZeroClaw tier only**: `provision_claw` now provisions
added); the pinned skills are injected as **bodies** into the mission prompt — the template's bundles (door always added), and the pinned skills are injected
not an index, because there is no `skills.read` tool on this path and an index as **bodies** into the turn prompt — not an index, because there is no
would advertise a capability that does not exist. `skills.read` tool on this path and an index would advertise a capability that
does not exist.
The correction matters, because the first version of this document said
"mission agents" without qualifying the tier. `compose_turn_prompt` /
`pinned_skills_text` have exactly one production caller
(`topology_exec.rs:745`), which `topology_worker` drives. `phase_runner`'s three
other paths do not call it:
| Path | Entry point | Skills reach the agent? |
|---|---|---|
| Container / ZeroClaw (the default) | `topology_worker` → `ZeroClawDriveExecutor` | **yes** |
| Composed microVM | `phase_runner::launch_composed_microvm_phase` | not yet |
| Solo microVM | `microvm_executor::run_phase_in_vm` | not yet |
| Direct session | `phase_runner::launch_direct_session` | not yet |
### 3. `upsert_task` raised 42P10 on every call ### 3. `upsert_task` raised 42P10 on every call
@@ -137,7 +194,10 @@ work and the condition.
finds excessive retrieval actively harms agent decisions. Measure against a finds excessive retrieval actively harms agent decisions. Measure against a
baseline before migrating. baseline before migrating.
- **Thin test coverage**: `cm-secrets` (4), `cm-billing` (4), `cm-safety` (7), - **Thin test coverage**: `cm-secrets` (4), `cm-billing` (4), `cm-safety` (7),
`cm-telemetry` (1); 6 of `cm-brain`'s 9 tests are `#[ignore]`d. `cm-telemetry` (1 — and that one is a genuinely good test: it stands up a
real OTLP/HTTP receiver and decodes protobuf with the official proto types,
so it exercises the actual wire contract rather than a mock. The crate needs
more tests, not a different one); 6 of `cm-brain`'s 9 tests are `#[ignore]`d.
- **`ZEROCLAW_GATEWAY_URL` / `_TOKEN`** have no default and fail at *first use*, - **`ZEROCLAW_GATEWAY_URL` / `_TOKEN`** have no default and fail at *first use*,
not boot — a deployment looks healthy until someone clicks run. not boot — a deployment looks healthy until someone clicks run.
- **`gitea_forge` resolves to nothing.** Six templates name it; the runtime - **`gitea_forge` resolves to nothing.** Six templates name it; the runtime
@@ -0,0 +1,20 @@
-- Keep a mission's events past the global retention window.
--
-- `mission_gc` deletes mission_events older than EVENT_RETENTION_DAYS (7). That
-- bound is right for volume — a single busy coding phase adds hundreds of rows
-- — and wrong for anything that needs to be re-read later: a Skill-Use
-- measurement, a provenance question, an incident review. All three ask about a
-- specific mission, so the exemption is per-mission rather than a raised global.
--
-- NULL (the default) means the mission obeys the global window, so this changes
-- nothing for existing rows.
ALTER TABLE missions
ADD COLUMN IF NOT EXISTS retain_events_until TIMESTAMPTZ;
COMMENT ON COLUMN missions.retain_events_until IS
'While in the future, mission_gc will not reap this mission''s events. Set when a mission is under measurement or investigation.';
-- The sweep joins on this, and the vast majority of rows are NULL.
CREATE INDEX IF NOT EXISTS missions_retain_events_idx
ON missions (retain_events_until)
WHERE retain_events_until IS NOT NULL;
+10
View File
@@ -23,7 +23,17 @@ case "${1:-up}" in
exit 0 exit 0
fi fi
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
# --shm-size: Docker defaults /dev/shm to 64MB. Postgres allocates parallel
# query segments there, and the suite runs many tests at once against many
# databases, so the default is exhausted mid-run — surfacing as
# `could not resize shared memory segment ... No space left on device`
# during MIGRATIONS, which reads like a schema fault and is not one.
#
# Space exhaustion has a second cause with the same symptom: cm-testkit
# creates a database per test and drops none, so they accumulate across
# runs (779 of them, once). `$0 clean` sweeps those.
docker run -d --name "$CONTAINER" --restart unless-stopped \ docker run -d --name "$CONTAINER" --restart unless-stopped \
--shm-size=1g \
-e POSTGRES_PASSWORD=postgres \ -e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=postgres \ -e POSTGRES_DB=postgres \
-p "${PORT}:5432" \ -p "${PORT}:5432" \