Skip to content
Draft
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
70 changes: 68 additions & 2 deletions crates/skilllite-evolution/src/feedback.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Evolution feedback collection and evaluation system (EVO-1).

use crate::Result;
use rusqlite::{params, Connection};
use rusqlite::{params, Connection, OptionalExtension};
use std::fs;
use std::path::Path;

Expand Down Expand Up @@ -163,7 +163,7 @@ pub fn ensure_evolution_tables(conn: &Connection) -> Result<()> {
id INTEGER PRIMARY KEY AUTOINCREMENT,
proposal_id TEXT NOT NULL UNIQUE,
source TEXT NOT NULL,
dedupe_key TEXT NOT NULL UNIQUE,
dedupe_key TEXT NOT NULL,
scope_json TEXT NOT NULL,
risk_level TEXT NOT NULL,
roi_score REAL NOT NULL DEFAULT 0.0,
Expand All @@ -184,6 +184,7 @@ pub fn ensure_evolution_tables(conn: &Connection) -> Result<()> {
CREATE INDEX IF NOT EXISTS idx_evo_log_ts ON evolution_log(ts);
CREATE INDEX IF NOT EXISTS idx_evo_backlog_status_roi ON evolution_backlog(status, roi_score DESC);
CREATE INDEX IF NOT EXISTS idx_evo_backlog_created_at ON evolution_backlog(created_at);
CREATE INDEX IF NOT EXISTS idx_evo_backlog_dedupe_status ON evolution_backlog(dedupe_key, status);
"#,
)?;
// Backward-compatible migration: add column for existing DBs (ignored if column exists).
Expand All @@ -204,6 +205,71 @@ pub fn ensure_evolution_tables(conn: &Connection) -> Result<()> {
"CREATE INDEX IF NOT EXISTS idx_decisions_seq ON decisions(tool_sequence_key)",
[],
);
migrate_evolution_backlog_drop_dedupe_unique(conn)?;
Ok(())
}

/// Older schemas enforced `dedupe_key TEXT NOT NULL UNIQUE`, which prevents inserting a
/// fresh active proposal after an `executed` row for the same key. Rebuild without that UNIQUE.
fn migrate_evolution_backlog_drop_dedupe_unique(conn: &Connection) -> Result<()> {
let table_sql: Option<String> = conn
.query_row(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'evolution_backlog'",
[],
|row| row.get(0),
)
.optional()?;
let Some(table_sql) = table_sql else {
return Ok(());
};
// Match the historical column constraint text (new installs already omit UNIQUE).
let needs_rebuild = table_sql
.to_ascii_lowercase()
.contains("dedupe_key text not null unique");
if !needs_rebuild {
let _ = conn.execute(
"CREATE INDEX IF NOT EXISTS idx_evo_backlog_dedupe_status ON evolution_backlog(dedupe_key, status)",
[],
);
return Ok(());
}

conn.execute_batch(
r#"
CREATE TABLE evolution_backlog_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
proposal_id TEXT NOT NULL UNIQUE,
source TEXT NOT NULL,
dedupe_key TEXT NOT NULL,
scope_json TEXT NOT NULL,
risk_level TEXT NOT NULL,
roi_score REAL NOT NULL DEFAULT 0.0,
expected_gain REAL NOT NULL DEFAULT 0.0,
effort REAL NOT NULL DEFAULT 1.0,
acceptance_criteria TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL,
acceptance_status TEXT NOT NULL DEFAULT 'pending',
note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO evolution_backlog_new (
id, proposal_id, source, dedupe_key, scope_json, risk_level, roi_score,
expected_gain, effort, acceptance_criteria, status, acceptance_status,
note, created_at, updated_at
)
SELECT
id, proposal_id, source, dedupe_key, scope_json, risk_level, roi_score,
expected_gain, effort, acceptance_criteria, status, acceptance_status,
note, created_at, updated_at
FROM evolution_backlog;
DROP TABLE evolution_backlog;
ALTER TABLE evolution_backlog_new RENAME TO evolution_backlog;
CREATE INDEX IF NOT EXISTS idx_evo_backlog_status_roi ON evolution_backlog(status, roi_score DESC);
CREATE INDEX IF NOT EXISTS idx_evo_backlog_created_at ON evolution_backlog(created_at);
CREATE INDEX IF NOT EXISTS idx_evo_backlog_dedupe_status ON evolution_backlog(dedupe_key, status);
"#,
)?;
Ok(())
}

Expand Down
195 changes: 195 additions & 0 deletions crates/skilllite-evolution/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,201 @@ mod lib_tests {
let _ = std::fs::remove_dir_all(&root);
}

#[test]
fn enqueue_user_capability_evolution_requeues_after_executed() {
let root =
std::env::temp_dir().join(format!("skilllite-evo-test-{}", uuid::Uuid::new_v4()));
let conn = feedback::open_evolution_db(&root).expect("open db");
let first_id = enqueue_user_capability_evolution(
&conn,
"weather",
"failure",
"first authorization",
)
.expect("first enqueue");
conn.execute(
"UPDATE evolution_backlog
SET status = 'executed', acceptance_status = 'met', updated_at = datetime('now')
WHERE proposal_id = ?1",
rusqlite::params![first_id],
)
.expect("mark executed");

let second_id = enqueue_user_capability_evolution(
&conn,
"weather",
"failure",
"re-authorize after executed",
)
.expect("second enqueue");

assert_ne!(
second_id, first_id,
"re-queue after executed must mint a new active proposal identity"
);
let (status, active_count, total_count): (String, i64, i64) = conn
.query_row(
"SELECT
(SELECT status FROM evolution_backlog WHERE proposal_id = ?1),
(SELECT COUNT(*) FROM evolution_backlog
WHERE dedupe_key = 'user_capability:weather:failure' AND status != 'executed'),
(SELECT COUNT(*) FROM evolution_backlog
WHERE dedupe_key = 'user_capability:weather:failure')",
rusqlite::params![second_id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.expect("read re-queued state");
assert_eq!(status, "queued");
assert_eq!(active_count, 1);
assert_eq!(total_count, 2);
let _ = std::fs::remove_dir_all(&root);
}

#[test]
fn migrate_evolution_backlog_allows_requeue_with_legacy_unique_dedupe() {
let root =
std::env::temp_dir().join(format!("skilllite-evo-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&root).expect("mkdir");
let db_path = root.join("feedback.sqlite");
{
let conn = Connection::open(&db_path).expect("open legacy db");
conn.execute_batch(
r#"
CREATE TABLE evolution_backlog (
id INTEGER PRIMARY KEY AUTOINCREMENT,
proposal_id TEXT NOT NULL UNIQUE,
source TEXT NOT NULL,
dedupe_key TEXT NOT NULL UNIQUE,
scope_json TEXT NOT NULL,
risk_level TEXT NOT NULL,
roi_score REAL NOT NULL DEFAULT 0.0,
expected_gain REAL NOT NULL DEFAULT 0.0,
effort REAL NOT NULL DEFAULT 1.0,
acceptance_criteria TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL,
acceptance_status TEXT NOT NULL DEFAULT 'pending',
note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO evolution_backlog
(proposal_id, source, dedupe_key, scope_json, risk_level, roi_score,
expected_gain, effort, acceptance_criteria, status, acceptance_status, note)
VALUES
('proposal_legacy_executed', 'passive', 'user_capability:weather:failure',
'{}', 'medium', 0.4, 0.75, 1.8, '[]', 'executed', 'met', 'legacy');
"#,
)
.expect("seed legacy unique schema");
}

let conn = feedback::open_evolution_db(&root).expect("migrate on open");
let table_sql: String = conn
.query_row(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'evolution_backlog'",
[],
|row| row.get(0),
)
.expect("read schema");
assert!(
!table_sql
.to_ascii_lowercase()
.contains("dedupe_key text not null unique"),
"migration must drop global UNIQUE on dedupe_key: {table_sql}"
);

let requeued_id = enqueue_user_capability_evolution(
&conn,
"weather",
"failure",
"after legacy migrate",
)
.expect("enqueue after migrate");
let active: i64 = conn
.query_row(
"SELECT COUNT(*) FROM evolution_backlog
WHERE proposal_id = ?1 AND status = 'queued'",
rusqlite::params![requeued_id],
|row| row.get(0),
)
.expect("count active");
assert_eq!(active, 1);
let _ = std::fs::remove_dir_all(&root);
}

#[test]
fn coordinate_attaches_status_to_persisted_id_after_requeue() {
let _guard = EVO_LOCK.lock().expect("evo lock");
let root =
std::env::temp_dir().join(format!("skilllite-evo-test-{}", uuid::Uuid::new_v4()));
let conn = feedback::open_evolution_db(&root).expect("open db");
let first = build_proposal(
ProposalSource::Passive,
EvolutionScope {
skills: true,
skill_action: SkillAction::Generate,
..Default::default()
},
ProposalRiskLevel::Low,
0.6,
1.0,
vec!["criteria".into()],
);
let dedupe_key = first.dedupe_key.clone();
conn.execute(
"INSERT INTO evolution_backlog
(proposal_id, source, dedupe_key, scope_json, risk_level, roi_score, expected_gain, effort, acceptance_criteria, status, acceptance_status, note)
VALUES (?1, 'passive', ?2, '{}', 'low', 0.6, 0.6, 1.0, '[\"criteria\"]', 'executed', 'met', 'done')",
rusqlite::params![first.proposal_id, dedupe_key],
)
.expect("seed executed history");

let next = build_proposal(
ProposalSource::Passive,
EvolutionScope {
skills: true,
skill_action: SkillAction::Generate,
..Default::default()
},
ProposalRiskLevel::Low,
0.7,
1.0,
vec!["criteria".into()],
);
assert_eq!(next.dedupe_key, dedupe_key);
let minted_id = next.proposal_id.clone();
let decision = coordinate_proposals_with_config(
&conn,
vec![next],
true,
EvolutionCoordinatorConfig {
policy_runtime_enabled: false,
auto_execute_low_risk: true,
deny_critical: true,
risk_budget: EvolutionRiskBudget {
low_per_day: 5,
medium_per_day: 5,
high_per_day: 5,
critical_per_day: 1,
},
},
)
.expect("coordinate");
let CoordinatorDecision::Execute(selected) = decision else {
panic!("expected Execute after re-queue");
};
assert_eq!(selected.proposal_id, minted_id);
let status: String = conn
.query_row(
"SELECT status FROM evolution_backlog WHERE proposal_id = ?1",
rusqlite::params![minted_id],
|row| row.get(0),
)
.expect("persisted status");
assert_eq!(status, "executing");
let _ = std::fs::remove_dir_all(&root);
}

fn seed_backlog_row(conn: &Connection, proposal_id: &str, updated_at: &str) {
conn.execute(
"INSERT INTO evolution_backlog
Expand Down
49 changes: 29 additions & 20 deletions crates/skilllite-evolution/src/scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -478,25 +478,8 @@ fn upsert_backlog_proposal(
) -> Result<()> {
let scope_json = serde_json::to_string(&proposal.scope)?;
let acceptance_criteria = serde_json::to_string(&proposal.acceptance_criteria)?;
conn.execute(
"INSERT OR IGNORE INTO evolution_backlog
(proposal_id, source, dedupe_key, scope_json, risk_level, roi_score, expected_gain, effort, acceptance_criteria, status, note)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
params![
proposal.proposal_id,
proposal.source.as_str(),
proposal.dedupe_key,
scope_json,
proposal.risk_level.as_str(),
proposal.roi_score as f64,
proposal.expected_gain as f64,
proposal.effort as f64,
acceptance_criteria,
status,
note,
],
)?;
conn.execute(
// Soft-dedupe among active rows only. Executed history must not block a fresh insert.
let updated = conn.execute(
"UPDATE evolution_backlog
SET roi_score = ?1,
expected_gain = ?2,
Expand All @@ -512,6 +495,26 @@ fn upsert_backlog_proposal(
proposal.dedupe_key,
],
)?;
if updated == 0 {
conn.execute(
"INSERT INTO evolution_backlog
(proposal_id, source, dedupe_key, scope_json, risk_level, roi_score, expected_gain, effort, acceptance_criteria, status, note)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
params![
proposal.proposal_id,
proposal.source.as_str(),
proposal.dedupe_key,
scope_json,
proposal.risk_level.as_str(),
proposal.roi_score as f64,
proposal.expected_gain as f64,
proposal.effort as f64,
acceptance_criteria,
status,
note,
],
)?;
}
Ok(())
}

Expand Down Expand Up @@ -969,9 +972,15 @@ pub(crate) fn coordinate_proposals_with_config(
upsert_backlog_proposal(conn, proposal, "queued", "Proposal collected")?;
}
proposals.sort_by(|a, b| b.roi_score.total_cmp(&a.roi_score));
let Some(selected) = proposals.into_iter().next() else {
let Some(mut selected) = proposals.into_iter().next() else {
return Ok(CoordinatorDecision::NoCandidate);
};
// Soft-dedupe may keep an older proposal_id on the active row; attach status to that id.
if let Some(persisted_id) =
latest_non_executed_proposal_id_by_dedupe(conn, &selected.dedupe_key)?
{
selected.proposal_id = persisted_id;
}
if force {
set_backlog_status(
conn,
Expand Down
Loading
Loading