//! 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 { // 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) }