mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
refactor(subconscious): replace task system with agent-per-tick model (#3079)
Co-authored-by: Steven Enamakel's Droid <enamakel.agent@tinyhumans.ai> Co-authored-by: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Co-authored-by: sanil-23 <sanil@tinyhumans.ai> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Steven Enamakel's Droid
oxoxDev
sanil-23
Claude Opus 4.8
parent
3136ef5483
commit
97ff4f53d3
@@ -1,8 +1,57 @@
|
||||
//! Heartbeat and cron configuration.
|
||||
//! Heartbeat, cron, and subconscious mode configuration.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Subconscious operating mode — controls tool access and tick frequency.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SubconsciousMode {
|
||||
/// Disabled — the subconscious loop does not run.
|
||||
#[default]
|
||||
Off,
|
||||
/// Read-only observation every 30 minutes. Memory recall and file
|
||||
/// reading only — no writes, no sub-agent spawning.
|
||||
Simple,
|
||||
/// Full tool access every 5 minutes. Can write, spawn sub-agents,
|
||||
/// and delegate tasks to the orchestrator.
|
||||
Aggressive,
|
||||
}
|
||||
|
||||
impl SubconsciousMode {
|
||||
pub fn is_enabled(self) -> bool {
|
||||
!matches!(self, Self::Off)
|
||||
}
|
||||
|
||||
pub fn default_interval_minutes(self) -> u32 {
|
||||
match self {
|
||||
Self::Off => 5,
|
||||
Self::Simple => 30,
|
||||
Self::Aggressive => 5,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_read_only(self) -> bool {
|
||||
matches!(self, Self::Simple)
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Off => "off",
|
||||
Self::Simple => "simple",
|
||||
Self::Aggressive => "aggressive",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str_lossy(s: &str) -> Self {
|
||||
match s {
|
||||
"simple" => Self::Simple,
|
||||
"aggressive" => Self::Aggressive,
|
||||
_ => Self::Off,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Heartbeat configuration — periodic background loop that evaluates
|
||||
/// HEARTBEAT.md tasks and proactive notification sources.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
@@ -47,6 +96,11 @@ pub struct HeartbeatConfig {
|
||||
/// Maximum lookahead window for reminder notifications.
|
||||
#[serde(default = "default_reminder_lookahead_minutes")]
|
||||
pub reminder_lookahead_minutes: u32,
|
||||
/// Subconscious operating mode. Controls tool access and tick frequency.
|
||||
/// Off (default) = disabled. Simple = read-only every 30 min.
|
||||
/// Aggressive = full access every 5 min.
|
||||
#[serde(default)]
|
||||
pub subconscious_mode: SubconsciousMode,
|
||||
}
|
||||
|
||||
fn default_context_budget() -> u32 {
|
||||
@@ -93,6 +147,25 @@ impl Default for HeartbeatConfig {
|
||||
meeting_lookahead_minutes: default_meeting_lookahead_minutes(),
|
||||
max_calendar_connections_per_tick: default_max_calendar_connections_per_tick(),
|
||||
reminder_lookahead_minutes: default_reminder_lookahead_minutes(),
|
||||
subconscious_mode: SubconsciousMode::Off,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HeartbeatConfig {
|
||||
/// Resolve the effective subconscious mode, handling backward
|
||||
/// compatibility for configs that pre-date the `subconscious_mode`
|
||||
/// field. If `subconscious_mode` is explicitly set (not Off-by-default),
|
||||
/// use it. Otherwise, if the legacy `enabled && inference_enabled`
|
||||
/// flags are true, treat as `Simple`.
|
||||
pub fn effective_subconscious_mode(&self) -> SubconsciousMode {
|
||||
if self.subconscious_mode != SubconsciousMode::Off {
|
||||
return self.subconscious_mode;
|
||||
}
|
||||
if self.enabled && self.inference_enabled {
|
||||
SubconsciousMode::Simple
|
||||
} else {
|
||||
SubconsciousMode::Off
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,6 +185,50 @@ mod tests {
|
||||
assert!(!config.external_delivery_enabled);
|
||||
assert_eq!(config.interval_minutes, 5);
|
||||
assert_eq!(config.max_calendar_connections_per_tick, 2);
|
||||
assert_eq!(config.subconscious_mode, SubconsciousMode::Off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subconscious_mode_serde_round_trip() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&SubconsciousMode::Simple).unwrap(),
|
||||
r#""simple""#
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<SubconsciousMode>(r#""aggressive""#).unwrap(),
|
||||
SubconsciousMode::Aggressive
|
||||
);
|
||||
assert_eq!(SubconsciousMode::default(), SubconsciousMode::Off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subconscious_mode_helpers() {
|
||||
assert!(!SubconsciousMode::Off.is_enabled());
|
||||
assert!(SubconsciousMode::Simple.is_enabled());
|
||||
assert!(SubconsciousMode::Aggressive.is_enabled());
|
||||
assert!(SubconsciousMode::Simple.is_read_only());
|
||||
assert!(!SubconsciousMode::Aggressive.is_read_only());
|
||||
assert_eq!(SubconsciousMode::Simple.default_interval_minutes(), 30);
|
||||
assert_eq!(SubconsciousMode::Aggressive.default_interval_minutes(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_mode_backward_compat() {
|
||||
let mut config = HeartbeatConfig::default();
|
||||
assert_eq!(config.effective_subconscious_mode(), SubconsciousMode::Off);
|
||||
|
||||
config.enabled = true;
|
||||
config.inference_enabled = true;
|
||||
assert_eq!(
|
||||
config.effective_subconscious_mode(),
|
||||
SubconsciousMode::Simple
|
||||
);
|
||||
|
||||
config.subconscious_mode = SubconsciousMode::Aggressive;
|
||||
assert_eq!(
|
||||
config.effective_subconscious_mode(),
|
||||
SubconsciousMode::Aggressive
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -61,7 +61,7 @@ pub use channels::{
|
||||
pub use context::ContextConfig;
|
||||
pub use dashboard::{DashboardConfig, DiagramViewerConfig, EventStreamConfig, ModelHealthConfig};
|
||||
pub use dictation::{DictationActivationMode, DictationConfig};
|
||||
pub use heartbeat_cron::{CronConfig, HeartbeatConfig};
|
||||
pub use heartbeat_cron::{CronConfig, HeartbeatConfig, SubconsciousMode};
|
||||
pub use identity_cost::{CostConfig, ModelPricing};
|
||||
pub use learning::{LearningConfig, ReflectionSource};
|
||||
pub use local_ai::{LocalAiConfig, LocalAiUsage};
|
||||
|
||||
@@ -108,8 +108,8 @@ impl HeartbeatEngine {
|
||||
match engine.tick().await {
|
||||
Ok(result) => {
|
||||
info!(
|
||||
"[heartbeat] tick: executed={} escalated={} duration={}ms",
|
||||
result.executed, result.escalated, result.duration_ms
|
||||
"[heartbeat] tick: thoughts={} thread={:?} duration={}ms",
|
||||
result.thoughts_count, result.thread_id, result.duration_ms
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -20,6 +20,7 @@ pub struct HeartbeatSettingsPatch {
|
||||
pub meeting_lookahead_minutes: Option<u32>,
|
||||
pub max_calendar_connections_per_tick: Option<u32>,
|
||||
pub reminder_lookahead_minutes: Option<u32>,
|
||||
pub subconscious_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -34,6 +35,7 @@ pub struct HeartbeatSettingsView {
|
||||
pub meeting_lookahead_minutes: u32,
|
||||
pub max_calendar_connections_per_tick: u32,
|
||||
pub reminder_lookahead_minutes: u32,
|
||||
pub subconscious_mode: String,
|
||||
}
|
||||
|
||||
pub async fn settings_get() -> Result<RpcOutcome<serde_json::Value>, String> {
|
||||
@@ -90,23 +92,37 @@ pub async fn settings_set(
|
||||
if let Some(reminder_lookahead_minutes) = patch.reminder_lookahead_minutes {
|
||||
config.heartbeat.reminder_lookahead_minutes = reminder_lookahead_minutes.max(1);
|
||||
}
|
||||
if let Some(ref mode_str) = patch.subconscious_mode {
|
||||
use crate::openhuman::config::schema::SubconsciousMode;
|
||||
let mode = SubconsciousMode::from_str_lossy(mode_str);
|
||||
config.heartbeat.subconscious_mode = mode;
|
||||
config.heartbeat.enabled = mode.is_enabled() || config.heartbeat.enabled;
|
||||
config.heartbeat.inference_enabled = mode.is_enabled();
|
||||
config.heartbeat.interval_minutes = mode.default_interval_minutes();
|
||||
}
|
||||
|
||||
config.save().await.map_err(|e| {
|
||||
warn!("[heartbeat][rpc] settings_set: config.save failed: {e}");
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
if config.heartbeat.enabled {
|
||||
debug!("[heartbeat][rpc] settings_set: enabling — running bootstrap_after_login()");
|
||||
if let Err(error) = crate::openhuman::subconscious::global::bootstrap_after_login().await {
|
||||
warn!("[heartbeat][rpc] settings_set: heartbeat bootstrap failed: {error}");
|
||||
return Err(format!(
|
||||
"heartbeat settings saved, but failed to start heartbeat loop: {error}"
|
||||
));
|
||||
}
|
||||
} else {
|
||||
debug!("[heartbeat][rpc] settings_set: disabling — stopping heartbeat loop");
|
||||
// Mode change requires a full engine restart so the new mode's interval
|
||||
// and tool restrictions take effect. stop + bootstrap is idempotent.
|
||||
if patch.subconscious_mode.is_some() || patch.enabled.is_some() {
|
||||
crate::openhuman::subconscious::global::stop_heartbeat_loop().await;
|
||||
if config.heartbeat.effective_subconscious_mode().is_enabled() {
|
||||
debug!("[heartbeat][rpc] settings_set: (re)starting for mode change");
|
||||
if let Err(error) =
|
||||
crate::openhuman::subconscious::global::bootstrap_after_login().await
|
||||
{
|
||||
warn!("[heartbeat][rpc] settings_set: heartbeat bootstrap failed: {error}");
|
||||
return Err(format!(
|
||||
"heartbeat settings saved, but failed to start heartbeat loop: {error}"
|
||||
));
|
||||
}
|
||||
} else {
|
||||
debug!("[heartbeat][rpc] settings_set: subconscious off — loop stopped");
|
||||
}
|
||||
}
|
||||
|
||||
debug!("[heartbeat][rpc] settings_set: exit ok");
|
||||
@@ -146,5 +162,10 @@ fn view(config: &Config) -> HeartbeatSettingsView {
|
||||
meeting_lookahead_minutes: config.heartbeat.meeting_lookahead_minutes,
|
||||
max_calendar_connections_per_tick: config.heartbeat.max_calendar_connections_per_tick,
|
||||
reminder_lookahead_minutes: config.heartbeat.reminder_lookahead_minutes,
|
||||
subconscious_mode: config
|
||||
.heartbeat
|
||||
.effective_subconscious_mode()
|
||||
.as_str()
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,10 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
"reminder_lookahead_minutes",
|
||||
"Max lookahead window (minutes) for reminder notifications.",
|
||||
),
|
||||
optional_string(
|
||||
"subconscious_mode",
|
||||
"Subconscious operating mode: off, simple, or aggressive.",
|
||||
),
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "settings",
|
||||
@@ -161,3 +165,12 @@ fn optional_u64(name: &'static str, comment: &'static str) -> FieldSchema {
|
||||
required: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_string(name: &'static str, comment: &'static str) -> FieldSchema {
|
||||
FieldSchema {
|
||||
name,
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment,
|
||||
required: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,209 +1,3 @@
|
||||
//! Decision log for tracking what the subconscious has already surfaced.
|
||||
//! Prevents re-escalating the same state changes across ticks.
|
||||
|
||||
use super::types::TickDecision;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// TTL for decision records before auto-expiry (24 hours).
|
||||
const RECORD_TTL_SECS: f64 = 24.0 * 60.0 * 60.0;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DecisionRecord {
|
||||
pub tick_at: f64,
|
||||
pub decision: TickDecision,
|
||||
pub source_doc_ids: Vec<String>,
|
||||
pub reason: String,
|
||||
pub acknowledged: bool,
|
||||
pub expires_at: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct DecisionLog {
|
||||
records: Vec<DecisionRecord>,
|
||||
}
|
||||
|
||||
impl DecisionLog {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
records: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn was_already_surfaced(&self, doc_ids: &[String]) -> bool {
|
||||
let now = now_secs();
|
||||
self.records.iter().any(|r| {
|
||||
!r.acknowledged
|
||||
&& r.expires_at > now
|
||||
&& r.decision != TickDecision::Noop
|
||||
&& r.source_doc_ids.iter().any(|id| doc_ids.contains(id))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn filter_unsurfaced(&self, doc_ids: &[String]) -> Vec<String> {
|
||||
let surfaced: HashSet<&str> = self
|
||||
.records
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
!r.acknowledged && r.expires_at > now_secs() && r.decision != TickDecision::Noop
|
||||
})
|
||||
.flat_map(|r| r.source_doc_ids.iter().map(|s| s.as_str()))
|
||||
.collect();
|
||||
|
||||
doc_ids
|
||||
.iter()
|
||||
.filter(|id| !surfaced.contains(id.as_str()))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn record(
|
||||
&mut self,
|
||||
tick_at: f64,
|
||||
decision: TickDecision,
|
||||
reason: &str,
|
||||
source_doc_ids: Vec<String>,
|
||||
) {
|
||||
self.records.push(DecisionRecord {
|
||||
tick_at,
|
||||
decision,
|
||||
source_doc_ids,
|
||||
reason: reason.to_string(),
|
||||
acknowledged: false,
|
||||
expires_at: tick_at + RECORD_TTL_SECS,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn mark_acknowledged(&mut self, doc_ids: &[String]) {
|
||||
for record in &mut self.records {
|
||||
if record.source_doc_ids.iter().any(|id| doc_ids.contains(id)) {
|
||||
record.acknowledged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prune_expired(&mut self) {
|
||||
let now = now_secs();
|
||||
self.records.retain(|r| r.expires_at > now);
|
||||
}
|
||||
|
||||
pub fn active_count(&self) -> usize {
|
||||
let now = now_secs();
|
||||
self.records.iter().filter(|r| r.expires_at > now).count()
|
||||
}
|
||||
|
||||
pub fn records(&self) -> &[DecisionRecord] {
|
||||
&self.records
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> Result<String, String> {
|
||||
serde_json::to_string(self).map_err(|e| format!("serialize decision log: {e}"))
|
||||
}
|
||||
|
||||
pub fn from_json(json: &str) -> Result<Self, String> {
|
||||
serde_json::from_str(json).map_err(|e| format!("deserialize decision log: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn now_secs() -> f64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs_f64())
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn now() -> f64 {
|
||||
now_secs()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_log_surfaces_nothing() {
|
||||
let log = DecisionLog::new();
|
||||
assert!(!log.was_already_surfaced(&["doc-1".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_escalation_is_surfaced() {
|
||||
let mut log = DecisionLog::new();
|
||||
log.record(
|
||||
now(),
|
||||
TickDecision::Escalate,
|
||||
"deadline",
|
||||
vec!["doc-1".into()],
|
||||
);
|
||||
assert!(log.was_already_surfaced(&["doc-1".into()]));
|
||||
assert!(!log.was_already_surfaced(&["doc-2".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noop_decisions_are_not_surfaced() {
|
||||
let mut log = DecisionLog::new();
|
||||
log.record(now(), TickDecision::Noop, "nothing", vec!["doc-1".into()]);
|
||||
assert!(!log.was_already_surfaced(&["doc-1".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acknowledged_records_are_not_surfaced() {
|
||||
let mut log = DecisionLog::new();
|
||||
log.record(
|
||||
now(),
|
||||
TickDecision::Escalate,
|
||||
"deadline",
|
||||
vec!["doc-1".into()],
|
||||
);
|
||||
log.mark_acknowledged(&["doc-1".into()]);
|
||||
assert!(!log.was_already_surfaced(&["doc-1".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_records_are_not_surfaced() {
|
||||
let mut log = DecisionLog::new();
|
||||
let old_time = now() - RECORD_TTL_SECS - 1.0;
|
||||
log.record(
|
||||
old_time,
|
||||
TickDecision::Escalate,
|
||||
"old",
|
||||
vec!["doc-1".into()],
|
||||
);
|
||||
assert!(!log.was_already_surfaced(&["doc-1".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_removes_expired() {
|
||||
let mut log = DecisionLog::new();
|
||||
let old_time = now() - RECORD_TTL_SECS - 1.0;
|
||||
log.record(
|
||||
old_time,
|
||||
TickDecision::Escalate,
|
||||
"old",
|
||||
vec!["doc-1".into()],
|
||||
);
|
||||
log.record(now(), TickDecision::Act, "new", vec!["doc-2".into()]);
|
||||
assert_eq!(log.records().len(), 2);
|
||||
log.prune_expired();
|
||||
assert_eq!(log.records().len(), 1);
|
||||
assert_eq!(log.records()[0].source_doc_ids, vec!["doc-2".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_unsurfaced_returns_new_docs() {
|
||||
let mut log = DecisionLog::new();
|
||||
log.record(now(), TickDecision::Escalate, "seen", vec!["doc-1".into()]);
|
||||
let unsurfaced = log.filter_unsurfaced(&["doc-1".into(), "doc-2".into(), "doc-3".into()]);
|
||||
assert_eq!(unsurfaced, vec!["doc-2".to_string(), "doc-3".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_json() {
|
||||
let mut log = DecisionLog::new();
|
||||
log.record(now(), TickDecision::Escalate, "test", vec!["doc-1".into()]);
|
||||
let json = log.to_json().unwrap();
|
||||
let restored = DecisionLog::from_json(&json).unwrap();
|
||||
assert_eq!(restored.records().len(), 1);
|
||||
assert_eq!(restored.records()[0].reason, "test");
|
||||
}
|
||||
}
|
||||
//! Legacy decision log — retained as an empty module for backward
|
||||
//! compatibility. The task-based decision tracking was removed when
|
||||
//! the subconscious switched to an agent-per-tick model.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,243 +1,59 @@
|
||||
use super::*;
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
old: Option<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn set_to_path(key: &'static str, path: &std::path::Path) -> Self {
|
||||
let old = std::env::var_os(key);
|
||||
std::env::set_var(key, path);
|
||||
Self { key, old }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
match &self.old {
|
||||
Some(value) => std::env::set_var(self.key, value),
|
||||
None => std::env::remove_var(self.key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn test_tasks() -> Vec<SubconsciousTask> {
|
||||
vec![
|
||||
SubconsciousTask {
|
||||
id: "t1".into(),
|
||||
title: "Check email".into(),
|
||||
source: TaskSource::User,
|
||||
recurrence: TaskRecurrence::Cron("0 8 * * *".into()),
|
||||
enabled: true,
|
||||
last_run_at: None,
|
||||
next_run_at: None,
|
||||
completed: false,
|
||||
created_at: 0.0,
|
||||
},
|
||||
SubconsciousTask {
|
||||
id: "t2".into(),
|
||||
title: "Monitor skills".into(),
|
||||
source: TaskSource::System,
|
||||
recurrence: TaskRecurrence::Pending,
|
||||
enabled: true,
|
||||
last_run_at: None,
|
||||
next_run_at: None,
|
||||
completed: false,
|
||||
created_at: 0.0,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tick_skips_unavailable_provider_without_activity_log_spam() {
|
||||
let _env_lock = crate::openhuman::config::TEST_ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let _workspace = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", tmp.path());
|
||||
let config = Config::load_or_init().await.expect("load test config");
|
||||
let engine = SubconsciousEngine::from_heartbeat_config(
|
||||
&config.heartbeat,
|
||||
config.workspace_dir.clone(),
|
||||
None,
|
||||
);
|
||||
|
||||
let result = engine.tick().await.expect("tick should skip cleanly");
|
||||
|
||||
assert!(result.evaluations.is_empty());
|
||||
let logs = store::with_connection(&config.workspace_dir, |conn| {
|
||||
store::list_log_entries(conn, None, 20)
|
||||
})
|
||||
.expect("list logs");
|
||||
assert!(
|
||||
logs.is_empty(),
|
||||
"provider skip must not append per-task failure log entries"
|
||||
);
|
||||
|
||||
let status = engine.status().await;
|
||||
assert_eq!(status.consecutive_failures, 1);
|
||||
assert!(!status.provider_available);
|
||||
assert!(status
|
||||
.provider_unavailable_reason
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("Sign in"));
|
||||
|
||||
let _second = engine.tick().await.expect("repeat skip should be clean");
|
||||
let logs = store::with_connection(&config.workspace_dir, |conn| {
|
||||
store::list_log_entries(conn, None, 20)
|
||||
})
|
||||
.expect("list logs after repeat");
|
||||
assert!(logs.is_empty(), "repeat skips must not spam activity log");
|
||||
}
|
||||
use crate::openhuman::subconscious::reflection::ReflectionKind;
|
||||
|
||||
#[test]
|
||||
fn local_subconscious_provider_is_available() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let mut config = Config::default();
|
||||
config.config_path = tmp.path().join("config.toml");
|
||||
config.workspace_dir = tmp.path().join("workspace");
|
||||
config.subconscious_provider = Some("ollama:qwen2.5:0.5b".into());
|
||||
|
||||
assert!(subconscious_provider_unavailable_reason(&config).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_subconscious_route_preserves_ollama_model() {
|
||||
let mut config = Config::default();
|
||||
config.subconscious_provider = Some("ollama:qwen2.5:0.5b".into());
|
||||
|
||||
assert_eq!(
|
||||
resolve_subconscious_route(&config),
|
||||
SubconsciousProviderRoute::LocalOllama {
|
||||
model: "qwen2.5:0.5b".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_subconscious_provider_does_not_require_legacy_endpoint() {
|
||||
let mut config = Config::default();
|
||||
config.subconscious_provider = Some("ollama:qwen2.5:0.5b".into());
|
||||
config.memory_tree.llm_summariser_endpoint = None;
|
||||
|
||||
assert!(subconscious_provider_unavailable_reason(&config).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openhuman_subconscious_alias_uses_cloud_route() {
|
||||
let mut config = Config::default();
|
||||
config.subconscious_provider = Some("openhuman:summarization".into());
|
||||
|
||||
assert_eq!(
|
||||
resolve_subconscious_route(&config),
|
||||
SubconsciousProviderRoute::OpenHumanCloud
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_subconscious_provider_uses_other_route() {
|
||||
let mut config = Config::default();
|
||||
config.subconscious_provider = Some("custom-provider".into());
|
||||
|
||||
assert_eq!(
|
||||
resolve_subconscious_route(&config),
|
||||
SubconsciousProviderRoute::Other("custom-provider".into())
|
||||
);
|
||||
assert!(subconscious_provider_unavailable_reason(&config).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_evaluation_response() {
|
||||
let json = r#"{"evaluations": [
|
||||
{"task_id": "t1", "decision": "act", "reason": "3 new urgent emails"},
|
||||
{"task_id": "t2", "decision": "noop", "reason": "All skills healthy"}
|
||||
fn parse_thoughts_from_envelope() {
|
||||
let json = r#"{"thoughts": [
|
||||
{"kind": "hotness_spike", "body": "Phoenix surged", "source_refs": ["entity:phoenix"]},
|
||||
{"kind": "risk", "body": "Deadline approaching"}
|
||||
]}"#;
|
||||
let (evals, drafts) = parse_response(json, &test_tasks());
|
||||
assert_eq!(evals.len(), 2);
|
||||
assert_eq!(evals[0].decision, TickDecision::Act);
|
||||
assert_eq!(evals[1].decision, TickDecision::Noop);
|
||||
assert!(drafts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_evaluation_bare_array() {
|
||||
let json = r#"[
|
||||
{"task_id": "t1", "decision": "escalate", "reason": "Deadline conflict"}
|
||||
]"#;
|
||||
let (evals, drafts) = parse_response(json, &test_tasks());
|
||||
assert_eq!(evals.len(), 1);
|
||||
assert_eq!(evals[0].decision, TickDecision::Escalate);
|
||||
assert!(drafts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_evaluation_in_markdown() {
|
||||
let json = "```json\n{\"evaluations\": [{\"task_id\": \"t1\", \"decision\": \"act\", \"reason\": \"Found items\"}]}\n```";
|
||||
let (evals, _) = parse_response(json, &test_tasks());
|
||||
assert_eq!(evals.len(), 1);
|
||||
assert_eq!(evals[0].decision, TickDecision::Act);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_evaluation_garbage_falls_back_to_noop() {
|
||||
let (evals, drafts) = parse_response("Not JSON at all", &test_tasks());
|
||||
assert_eq!(evals.len(), 2);
|
||||
assert!(evals.iter().all(|e| e.decision == TickDecision::Noop));
|
||||
assert!(drafts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_response_extracts_reflections() {
|
||||
let json = r#"{
|
||||
"evaluations": [{"task_id": "t1", "decision": "noop", "reason": "nothing"}],
|
||||
"reflections": [
|
||||
{
|
||||
"kind": "hotness_spike",
|
||||
"body": "Phoenix surge",
|
||||
"disposition": "notify",
|
||||
"proposed_action": "Pull mentions",
|
||||
"source_refs": ["entity:phoenix"]
|
||||
},
|
||||
{
|
||||
"kind": "daily_digest",
|
||||
"body": "New digest",
|
||||
"disposition": "observe"
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
let (evals, drafts) = parse_response(json, &test_tasks());
|
||||
assert_eq!(evals.len(), 1);
|
||||
let drafts = parse_thoughts(json);
|
||||
assert_eq!(drafts.len(), 2);
|
||||
assert_eq!(drafts[0].body, "Phoenix surge");
|
||||
assert_eq!(drafts[1].body, "New digest");
|
||||
assert_eq!(drafts[0].kind, ReflectionKind::HotnessSpike);
|
||||
assert_eq!(drafts[1].kind, ReflectionKind::Risk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_response_handles_only_reflections() {
|
||||
// LLM emitted reflections but no per-task evaluations.
|
||||
let json = r#"{
|
||||
"evaluations": [],
|
||||
"reflections": [
|
||||
{"kind": "risk", "body": "Concerning pattern", "disposition": "notify"}
|
||||
]
|
||||
}"#;
|
||||
let (evals, drafts) = parse_response(json, &test_tasks());
|
||||
// Tasks default to Noop so the existing tick loop still updates log entries.
|
||||
assert_eq!(evals.len(), 2);
|
||||
assert!(evals.iter().all(|e| e.decision == TickDecision::Noop));
|
||||
fn parse_thoughts_from_reflections_key() {
|
||||
let json = r#"{"reflections": [
|
||||
{"kind": "opportunity", "body": "New connection available"}
|
||||
]}"#;
|
||||
let drafts = parse_thoughts(json);
|
||||
assert_eq!(drafts.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_json_object() {
|
||||
assert_eq!(extract_json(r#"{"key": "val"}"#), r#"{"key": "val"}"#);
|
||||
fn parse_thoughts_from_bare_array() {
|
||||
let json = r#"[{"kind": "daily_digest", "body": "Summary of the day"}]"#;
|
||||
let drafts = parse_thoughts(json);
|
||||
assert_eq!(drafts.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_json_from_text() {
|
||||
let input = "Here's the result: {\"evaluations\": []} done.";
|
||||
assert!(extract_json(input).starts_with('{'));
|
||||
assert!(extract_json(input).ends_with('}'));
|
||||
fn parse_thoughts_returns_empty_on_garbage() {
|
||||
let drafts = parse_thoughts("not json at all");
|
||||
assert!(drafts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_thoughts_handles_markdown_wrapper() {
|
||||
let json = "```json\n{\"thoughts\": [{\"kind\": \"risk\", \"body\": \"test\"}]}\n```";
|
||||
let drafts = parse_thoughts(json);
|
||||
assert_eq!(drafts.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_json_finds_object() {
|
||||
let text = "Here's the JSON: {\"a\": 1} done.";
|
||||
let extracted = extract_json(text);
|
||||
assert!(extracted.starts_with('{'));
|
||||
assert!(extracted.ends_with('}'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_json_finds_array() {
|
||||
let text = "Result: [1, 2, 3] end.";
|
||||
let extracted = extract_json(text);
|
||||
assert!(extracted.starts_with('['));
|
||||
assert!(extracted.ends_with(']'));
|
||||
}
|
||||
|
||||
@@ -1,542 +1,4 @@
|
||||
//! Task execution — dispatches tasks to either the local Ollama model (text-only)
|
||||
//! or the full agentic loop (tool-required).
|
||||
//!
|
||||
//! When agentic-v1 is used for a task that didn't have explicit write intent,
|
||||
//! it runs in analysis-only mode. If it recommends a write action, execution
|
||||
//! is paused and an `UnapprovedWrite` result is returned so the engine can
|
||||
//! create an escalation for user approval.
|
||||
|
||||
use super::prompt;
|
||||
use super::types::{ExecutionResult, SubconsciousTask};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
#[cfg(test)]
|
||||
mod test_mocks {
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
const MODE_REAL: u8 = 0;
|
||||
const MODE_LOCAL_FAIL: u8 = 1;
|
||||
const MODE_AGENT_FAIL: u8 = 2;
|
||||
|
||||
static MODE: AtomicU8 = AtomicU8::new(MODE_REAL);
|
||||
|
||||
pub fn mock_local() {
|
||||
MODE.store(MODE_LOCAL_FAIL, Ordering::Release);
|
||||
}
|
||||
pub fn mock_agent() {
|
||||
MODE.store(MODE_AGENT_FAIL, Ordering::Release);
|
||||
}
|
||||
pub fn reset() {
|
||||
MODE.store(MODE_REAL, Ordering::Release);
|
||||
}
|
||||
pub fn is_local_mocked() -> bool {
|
||||
MODE.load(Ordering::Acquire) == MODE_LOCAL_FAIL
|
||||
}
|
||||
pub fn is_agent_mocked() -> bool {
|
||||
MODE.load(Ordering::Acquire) == MODE_AGENT_FAIL
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of executing a task — either completed or needs user approval.
|
||||
#[derive(Debug)]
|
||||
pub enum ExecutionOutcome {
|
||||
/// Task completed (either read-only analysis or approved write).
|
||||
Completed(ExecutionResult),
|
||||
/// agentic-v1 recommends a write action on a read-only task.
|
||||
/// Contains the recommended action description for the escalation.
|
||||
UnapprovedWrite {
|
||||
recommendation: String,
|
||||
duration_ms: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Execute a task. Routes to local model or agentic loop based on whether
|
||||
/// the task needs external tools.
|
||||
pub async fn execute_task(
|
||||
task: &SubconsciousTask,
|
||||
situation_report: &str,
|
||||
identity_context: &str,
|
||||
) -> Result<ExecutionOutcome, String> {
|
||||
let started = std::time::Instant::now();
|
||||
let task_has_write_intent = needs_tools(&task.title);
|
||||
let mut config = crate::openhuman::config::Config::load_or_init()
|
||||
.await
|
||||
.map_err(|e| format!("config load: {e}"))?;
|
||||
|
||||
let result = if task_has_write_intent {
|
||||
// Task explicitly asks for a write action — run with full permissions.
|
||||
info!(
|
||||
"[subconscious:executor] write task: id={} — agentic loop, full permissions",
|
||||
task.id
|
||||
);
|
||||
execute_with_agent_full(&mut config, task, situation_report, identity_context)
|
||||
.await
|
||||
.map(|output| {
|
||||
ExecutionOutcome::Completed(ExecutionResult {
|
||||
output,
|
||||
used_tools: true,
|
||||
duration_ms: started.elapsed().as_millis() as u64,
|
||||
})
|
||||
})
|
||||
} else if needs_agent(&task.title) {
|
||||
// Read-only task but needs deeper reasoning — run analysis-only.
|
||||
info!(
|
||||
"[subconscious:executor] read-only task escalated: id={} — agentic loop, analysis only",
|
||||
task.id
|
||||
);
|
||||
let output =
|
||||
execute_with_agent_analysis(&mut config, task, situation_report, identity_context)
|
||||
.await?;
|
||||
let duration_ms = started.elapsed().as_millis() as u64;
|
||||
|
||||
if let Some(recommendation) = extract_recommended_action(&output) {
|
||||
// agentic-v1 wants to take a write action the user didn't ask for.
|
||||
Ok(ExecutionOutcome::UnapprovedWrite {
|
||||
recommendation,
|
||||
duration_ms,
|
||||
})
|
||||
} else {
|
||||
Ok(ExecutionOutcome::Completed(ExecutionResult {
|
||||
output,
|
||||
used_tools: false,
|
||||
duration_ms,
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
// Simple text-only task. Use local model if configured for subconscious
|
||||
// tasks, otherwise fall back to the cloud agentic analysis path.
|
||||
if config.workload_uses_local("subconscious") {
|
||||
debug!(
|
||||
"[subconscious:executor] text task: id={} — using local model",
|
||||
task.id
|
||||
);
|
||||
execute_with_local_model(&config, task, situation_report, identity_context)
|
||||
.await
|
||||
.map(|output| {
|
||||
ExecutionOutcome::Completed(ExecutionResult {
|
||||
output,
|
||||
used_tools: false,
|
||||
duration_ms: started.elapsed().as_millis() as u64,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
info!(
|
||||
"[subconscious:executor] text task: id={} — local AI disabled, using cloud fallback",
|
||||
task.id
|
||||
);
|
||||
let output =
|
||||
execute_with_agent_analysis(&mut config, task, situation_report, identity_context)
|
||||
.await
|
||||
.map_err(|e| format!("cloud fallback agent execution: {e}"))?;
|
||||
let duration_ms = started.elapsed().as_millis() as u64;
|
||||
debug!(
|
||||
"[subconscious:executor] text task cloud fallback complete: id={} — duration_ms={}",
|
||||
task.id, duration_ms
|
||||
);
|
||||
|
||||
// Suppress UnapprovedWrite: passive tasks that didn't trigger
|
||||
// needs_agent should never escalate even if the cloud model's
|
||||
// output contains RECOMMENDED ACTION. The write-intent gate is
|
||||
// needs_tools for active tasks and needs_agent for read-only
|
||||
// escalations; the cloud fallback is a passthrough for simple
|
||||
// text tasks and must not silently change the contract.
|
||||
Ok(ExecutionOutcome::Completed(ExecutionResult {
|
||||
output,
|
||||
used_tools: false,
|
||||
duration_ms,
|
||||
}))
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(ref e) = result {
|
||||
warn!("[subconscious:executor] task id={} failed: {e}", task.id);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Execute an approved write action — called after user approves an escalation
|
||||
/// that originated from `UnapprovedWrite`.
|
||||
///
|
||||
/// Independent `Config::load_or_init()`: the task was originally routed under
|
||||
/// config_A in `execute_task`; now executes under config_B after user approval.
|
||||
/// If `use_local_for_subconscious()` toggled between the two calls, the approval
|
||||
/// was made under different assumptions. Risk is negligible in practice (config
|
||||
/// changes require a restart to take effect on most fields), but callers should
|
||||
/// be aware of this TOCTOU window.
|
||||
pub async fn execute_approved_write(
|
||||
task: &SubconsciousTask,
|
||||
situation_report: &str,
|
||||
identity_context: &str,
|
||||
) -> Result<ExecutionResult, String> {
|
||||
let started = std::time::Instant::now();
|
||||
let mut config = crate::openhuman::config::Config::load_or_init()
|
||||
.await
|
||||
.map_err(|e| format!("config load: {e}"))?;
|
||||
let output =
|
||||
execute_with_agent_full(&mut config, task, situation_report, identity_context).await?;
|
||||
Ok(ExecutionResult {
|
||||
output,
|
||||
used_tools: true,
|
||||
duration_ms: started.elapsed().as_millis() as u64,
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute a text-only task using the local Ollama model.
|
||||
///
|
||||
/// The caller MUST have already checked `config.local_ai.use_local_for_subconscious()`
|
||||
/// before calling this function.
|
||||
async fn execute_with_local_model(
|
||||
config: &crate::openhuman::config::Config,
|
||||
task: &SubconsciousTask,
|
||||
situation_report: &str,
|
||||
identity_context: &str,
|
||||
) -> Result<String, String> {
|
||||
#[cfg(test)]
|
||||
if test_mocks::is_local_mocked() {
|
||||
return Err("local model: mocked failure (test)".into());
|
||||
}
|
||||
let prompt_text = prompt::build_text_execution_prompt(task, situation_report, identity_context);
|
||||
|
||||
let messages = vec![
|
||||
crate::openhuman::inference::provider::traits::ChatMessage::system(prompt_text),
|
||||
crate::openhuman::inference::provider::traits::ChatMessage::user("Execute the task now."),
|
||||
];
|
||||
let model_id = crate::openhuman::inference::model_ids::effective_chat_model_id(config);
|
||||
let provider_string =
|
||||
match crate::openhuman::inference::local::provider::provider_from_config(config) {
|
||||
crate::openhuman::inference::local::provider::LocalAiProvider::Ollama => {
|
||||
format!("ollama:{model_id}")
|
||||
}
|
||||
crate::openhuman::inference::local::provider::LocalAiProvider::LmStudio => {
|
||||
format!("lmstudio:{model_id}")
|
||||
}
|
||||
};
|
||||
let (provider, model) =
|
||||
crate::openhuman::inference::provider::factory::create_local_chat_provider_from_string(
|
||||
&provider_string,
|
||||
config,
|
||||
)
|
||||
.map_err(|e| format!("local model: {e}"))?;
|
||||
|
||||
provider
|
||||
.chat_with_history(&messages, &model, config.default_temperature)
|
||||
.await
|
||||
.map_err(|e| format!("local model: {e}"))
|
||||
}
|
||||
|
||||
/// Execute with agentic-v1 at full permissions (write-intent tasks or approved writes).
|
||||
///
|
||||
/// Retries up to 3 times with exponential backoff (2s, 4s, 8s) on 429 rate-limit
|
||||
/// errors from the agentic-v1 cloud model.
|
||||
async fn execute_with_agent_full(
|
||||
config: &mut crate::openhuman::config::Config,
|
||||
task: &SubconsciousTask,
|
||||
situation_report: &str,
|
||||
identity_context: &str,
|
||||
) -> Result<String, String> {
|
||||
let prompt_text = prompt::build_tool_execution_prompt(task, situation_report, identity_context);
|
||||
|
||||
agent_chat_with_retry(config, &prompt_text).await
|
||||
}
|
||||
|
||||
/// Execute with agentic-v1 in analysis-only mode (read-only tasks).
|
||||
///
|
||||
/// The prompt instructs the model to analyze but not execute write actions.
|
||||
async fn execute_with_agent_analysis(
|
||||
config: &mut crate::openhuman::config::Config,
|
||||
task: &SubconsciousTask,
|
||||
situation_report: &str,
|
||||
identity_context: &str,
|
||||
) -> Result<String, String> {
|
||||
#[cfg(test)]
|
||||
if test_mocks::is_agent_mocked() {
|
||||
return Err("cloud fallback: mocked failure (test)".into());
|
||||
}
|
||||
let prompt_text = prompt::build_analysis_only_prompt(task, situation_report, identity_context);
|
||||
|
||||
agent_chat_with_retry(config, &prompt_text).await
|
||||
}
|
||||
|
||||
/// Call agent_chat with rate-limit retry (429 only, up to 3 attempts).
|
||||
async fn agent_chat_with_retry(
|
||||
config: &mut crate::openhuman::config::Config,
|
||||
prompt: &str,
|
||||
) -> Result<String, String> {
|
||||
const MAX_RETRIES: u32 = 3;
|
||||
let mut attempt = 0;
|
||||
|
||||
loop {
|
||||
let result =
|
||||
crate::openhuman::inference::local::ops::agent_chat(config, prompt, None, Some(0.3))
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(outcome) => return Ok(outcome.value),
|
||||
Err(e) => {
|
||||
let is_rate_limit = e.contains("429") || e.to_lowercase().contains("rate limit");
|
||||
attempt += 1;
|
||||
|
||||
if is_rate_limit && attempt < MAX_RETRIES {
|
||||
let backoff_secs = 2u64 << (attempt - 1); // 2, 4, 8
|
||||
warn!(
|
||||
"[subconscious:executor] rate-limited (attempt {}/{}), retrying in {}s: {}",
|
||||
attempt, MAX_RETRIES, backoff_secs, e
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(backoff_secs)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
return Err(format!("agent execution: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the analysis output contains a recommended write action.
|
||||
/// Returns the recommendation text if found.
|
||||
fn extract_recommended_action(output: &str) -> Option<String> {
|
||||
// Look for "RECOMMENDED ACTION:" marker in the output
|
||||
for line_idx in output.lines().enumerate().filter_map(|(i, l)| {
|
||||
if l.trim().starts_with("RECOMMENDED ACTION:") {
|
||||
Some(i)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
let recommendation: String = output
|
||||
.lines()
|
||||
.skip(line_idx)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
.trim()
|
||||
.to_string();
|
||||
if !recommendation.is_empty() {
|
||||
return Some(recommendation);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Heuristic: does this task need the agentic loop (deeper reasoning, tools)?
|
||||
///
|
||||
/// Tasks escalated by the local model that involve complex analysis
|
||||
/// (multi-step reasoning, cross-referencing sources) benefit from agentic-v1
|
||||
/// even without write actions.
|
||||
fn needs_agent(title: &str) -> bool {
|
||||
let lower = title.to_lowercase();
|
||||
let agent_keywords = [
|
||||
"compare",
|
||||
"cross-reference",
|
||||
"correlate",
|
||||
"investigate",
|
||||
"deep dive",
|
||||
"research",
|
||||
"audit",
|
||||
"trace",
|
||||
"debug",
|
||||
"diagnose",
|
||||
];
|
||||
agent_keywords.iter().any(|kw| lower.contains(kw))
|
||||
}
|
||||
|
||||
/// Heuristic: does this task description imply needing external tools?
|
||||
///
|
||||
/// Tasks with action verbs (send, create, post, delete, move, publish, schedule)
|
||||
/// need the agentic loop. Tasks with passive verbs (summarize, check, monitor,
|
||||
/// review, analyze, extract, classify) can be handled by local model.
|
||||
pub fn needs_tools(title: &str) -> bool {
|
||||
let lower = title.to_lowercase();
|
||||
let tool_keywords = [
|
||||
"send",
|
||||
"post",
|
||||
"create",
|
||||
"delete",
|
||||
"remove",
|
||||
"move",
|
||||
"publish",
|
||||
"schedule",
|
||||
"forward",
|
||||
"reply",
|
||||
"draft and send",
|
||||
"upload",
|
||||
"download",
|
||||
"notify on",
|
||||
"alert on",
|
||||
"message",
|
||||
"write to",
|
||||
"update on",
|
||||
"sync to",
|
||||
];
|
||||
tool_keywords.iter().any(|kw| lower.contains(kw))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
use tempfile::tempdir;
|
||||
|
||||
/// Guard that sets an env var for the duration of the test and restores it on drop.
|
||||
struct EnvVarGuard {
|
||||
key: String,
|
||||
old: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn set_to_path(key: &str, value: &Path) -> Self {
|
||||
let old = std::env::var(key).ok();
|
||||
std::env::set_var(key, value.to_str().expect("path is valid utf-8"));
|
||||
Self {
|
||||
key: key.to_string(),
|
||||
old,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
match &self.old {
|
||||
Some(value) => std::env::set_var(&self.key, value),
|
||||
None => std::env::remove_var(&self.key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_subconscious_test_config(workspace_root: &Path, local_ai_enabled: bool) {
|
||||
let cfg = format!(
|
||||
r#"default_temperature = 0.7
|
||||
|
||||
[local_ai]
|
||||
runtime_enabled = {local_ai_enabled}
|
||||
provider = "ollama"
|
||||
|
||||
[local_ai.usage]
|
||||
subconscious = {local_ai_enabled}
|
||||
|
||||
[memory]
|
||||
backend = "sqlite"
|
||||
auto_save = true
|
||||
embedding_provider = "none"
|
||||
embedding_model = "none"
|
||||
embedding_dimensions = 0
|
||||
|
||||
[secrets]
|
||||
encrypt = false
|
||||
"#
|
||||
);
|
||||
std::fs::create_dir_all(workspace_root).expect("mkdir test workspace root");
|
||||
let config_path = workspace_root.join("config.toml");
|
||||
std::fs::write(&config_path, &cfg).expect("write test config");
|
||||
let _: crate::openhuman::config::Config =
|
||||
toml::from_str(&cfg).expect("test config should deserialize");
|
||||
}
|
||||
|
||||
fn make_text_task(title: &str) -> SubconsciousTask {
|
||||
SubconsciousTask {
|
||||
id: "test-id".into(),
|
||||
title: title.into(),
|
||||
source: super::super::types::TaskSource::User,
|
||||
recurrence: super::super::types::TaskRecurrence::Once,
|
||||
enabled: true,
|
||||
last_run_at: None,
|
||||
next_run_at: None,
|
||||
completed: false,
|
||||
created_at: 1700000000.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_task_routes_to_cloud_when_local_disabled() {
|
||||
let _env_lock = crate::openhuman::config::TEST_ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
test_mocks::reset();
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let _workspace = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", tmp.path());
|
||||
write_subconscious_test_config(tmp.path(), false);
|
||||
|
||||
test_mocks::mock_agent();
|
||||
let task = make_text_task("Summarize unread emails");
|
||||
let result = execute_task(&task, "", "").await;
|
||||
|
||||
assert!(result.is_err(), "expected error (cloud path)");
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
err.contains("cloud fallback"),
|
||||
"expected cloud fallback error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_task_routes_to_local_when_local_enabled() {
|
||||
let _env_lock = crate::openhuman::config::TEST_ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
test_mocks::reset();
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let _workspace = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", tmp.path());
|
||||
write_subconscious_test_config(tmp.path(), true);
|
||||
|
||||
test_mocks::mock_local();
|
||||
let task = make_text_task("Summarize unread emails");
|
||||
let result = execute_task(&task, "", "").await;
|
||||
|
||||
assert!(result.is_err(), "expected error (local path)");
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
err.contains("local model"),
|
||||
"expected local model error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_tools_detects_action_verbs() {
|
||||
assert!(needs_tools("Send email digest to Telegram"));
|
||||
assert!(needs_tools("Post weekly standup to Slack"));
|
||||
assert!(needs_tools("Create a summary in Notion"));
|
||||
assert!(needs_tools("Delete old calendar events"));
|
||||
assert!(needs_tools("Forward urgent emails to team"));
|
||||
assert!(needs_tools("Schedule a meeting for tomorrow"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_tools_rejects_passive_verbs() {
|
||||
assert!(!needs_tools("Summarize unread emails"));
|
||||
assert!(!needs_tools("Check skills runtime health"));
|
||||
assert!(!needs_tools("Monitor Ollama status"));
|
||||
assert!(!needs_tools("Review upcoming deadlines"));
|
||||
assert!(!needs_tools("Analyze email patterns"));
|
||||
assert!(!needs_tools("Extract key points from Notion pages"));
|
||||
assert!(!needs_tools("Classify email priority"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_tools_case_insensitive() {
|
||||
assert!(needs_tools("SEND a message to Slack"));
|
||||
assert!(needs_tools("Send A Message To Slack"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_agent_detects_complex_tasks() {
|
||||
assert!(needs_agent("Compare Q1 and Q2 revenue data"));
|
||||
assert!(needs_agent("Investigate why notifications stopped"));
|
||||
assert!(needs_agent("Audit all active skill connections"));
|
||||
assert!(!needs_agent("Check emails"));
|
||||
assert!(!needs_agent("Summarize today's events"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_recommended_action_finds_marker() {
|
||||
let output = "Analysis complete. Found 3 urgent emails.\n\nRECOMMENDED ACTION: Forward the 3 urgent emails to #team-alerts on Slack.";
|
||||
let action = extract_recommended_action(output);
|
||||
assert!(action.is_some());
|
||||
assert!(action.unwrap().contains("Forward"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_recommended_action_returns_none_when_absent() {
|
||||
let output = "All skills are healthy. No issues found.";
|
||||
assert!(extract_recommended_action(output).is_none());
|
||||
}
|
||||
}
|
||||
//! Legacy executor — retained as an empty module for backward
|
||||
//! compatibility. Task execution was removed when the subconscious
|
||||
//! switched to an agent-per-tick model. The agent now runs directly
|
||||
//! in the engine tick via the chat provider.
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
//! Global singleton for the SubconsciousEngine.
|
||||
//!
|
||||
//! Shared between the heartbeat background loop and RPC handlers
|
||||
//! so both see the same decision log, counters, and last_tick_at.
|
||||
//!
|
||||
//! Lifecycle note: the engine is bootstrapped **post-login** via
|
||||
//! [`bootstrap_after_login`] so that `seed_default_tasks` runs against the
|
||||
//! per-user workspace (`~/.openhuman/users/<id>/workspace/`) instead of the
|
||||
//! pre-login global default. See `load.rs::resolve_runtime_config_dirs` for
|
||||
//! how `active_user.toml` drives `config.workspace_dir`.
|
||||
//! so both see the same state and counters.
|
||||
|
||||
use super::engine::SubconsciousEngine;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -16,12 +10,7 @@ use tokio::sync::Mutex;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
static ENGINE: OnceLock<Arc<Mutex<Option<SubconsciousEngine>>>> = OnceLock::new();
|
||||
|
||||
/// True once [`bootstrap_after_login`] has successfully seeded the engine and
|
||||
/// spawned the heartbeat loop for the current active user.
|
||||
static BOOTSTRAPPED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Heartbeat loop handle so logout / user switch can abort it cleanly.
|
||||
static HEARTBEAT_HANDLE: OnceLock<Mutex<Option<JoinHandle<()>>>> = OnceLock::new();
|
||||
|
||||
fn engine_lock() -> &'static Arc<Mutex<Option<SubconsciousEngine>>> {
|
||||
@@ -32,7 +21,6 @@ fn heartbeat_slot() -> &'static Mutex<Option<JoinHandle<()>>> {
|
||||
HEARTBEAT_HANDLE.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
/// Get or initialize the global engine. Both heartbeat loop and RPC use this.
|
||||
pub async fn get_or_init_engine() -> Result<Arc<Mutex<Option<SubconsciousEngine>>>, String> {
|
||||
let lock = engine_lock();
|
||||
{
|
||||
@@ -42,7 +30,6 @@ pub async fn get_or_init_engine() -> Result<Arc<Mutex<Option<SubconsciousEngine>
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
let config = crate::openhuman::config::Config::load_or_init()
|
||||
.await
|
||||
.map_err(|e| format!("load config: {e}"))?;
|
||||
@@ -63,15 +50,6 @@ pub async fn get_or_init_engine() -> Result<Arc<Mutex<Option<SubconsciousEngine>
|
||||
Ok(Arc::clone(lock))
|
||||
}
|
||||
|
||||
/// Construct the engine (which seeds defaults into the per-user workspace)
|
||||
/// and spawn the heartbeat loop. Idempotent per-process via [`BOOTSTRAPPED`].
|
||||
///
|
||||
/// Call this:
|
||||
/// - after a successful login writes `active_user.toml`, OR
|
||||
/// - at sidecar startup **iff** `active_user.toml` already exists.
|
||||
///
|
||||
/// Calling before login would seed into the global pre-login workspace and
|
||||
/// then silently diverge from the per-user workspace the UI reads from.
|
||||
pub async fn bootstrap_after_login() -> Result<(), String> {
|
||||
if BOOTSTRAPPED.swap(true, Ordering::SeqCst) {
|
||||
tracing::debug!("[subconscious] bootstrap already ran — skipping");
|
||||
@@ -91,10 +69,6 @@ pub async fn bootstrap_after_login() -> Result<(), String> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Build the engine against the NOW-correct per-user workspace_dir.
|
||||
// SubconsciousEngine::new calls seed_default_tasks() inside the
|
||||
// constructor, so by the time this returns the 3 system defaults are
|
||||
// present in `<workspace>/subconscious/subconscious.db`.
|
||||
get_or_init_engine().await.inspect_err(|_e| {
|
||||
BOOTSTRAPPED.store(false, Ordering::SeqCst);
|
||||
})?;
|
||||
@@ -103,9 +77,6 @@ pub async fn bootstrap_after_login() -> Result<(), String> {
|
||||
"[subconscious] engine initialized against per-user workspace"
|
||||
);
|
||||
|
||||
// Spawn the heartbeat loop and keep the JoinHandle so we can cancel it
|
||||
// on logout. Without this the task would leak: tokio::spawn returns a
|
||||
// detached task that drops on handle-drop but keeps running.
|
||||
let heartbeat = crate::openhuman::heartbeat::engine::HeartbeatEngine::new(
|
||||
config.heartbeat.clone(),
|
||||
config.workspace_dir.clone(),
|
||||
@@ -124,16 +95,9 @@ pub async fn bootstrap_after_login() -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop only the heartbeat loop. Keep the engine cache intact so manual
|
||||
/// subconscious RPCs can still inspect state, while a future enable can spawn
|
||||
/// a fresh loop.
|
||||
pub async fn stop_heartbeat_loop() {
|
||||
if let Some(handle) = heartbeat_slot().lock().await.take() {
|
||||
handle.abort();
|
||||
// Await the aborted task so it fully releases its engine reference
|
||||
// before we let bootstrap_after_login spawn a fresh loop. Without this
|
||||
// the old task can still be executing engine.run_tick() against a
|
||||
// replaced engine state.
|
||||
match handle.await {
|
||||
Ok(()) => {
|
||||
tracing::debug!("[heartbeat] loop exited before abort completed");
|
||||
@@ -150,12 +114,6 @@ pub async fn stop_heartbeat_loop() {
|
||||
BOOTSTRAPPED.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Tear down the engine + heartbeat loop so the next login rebuilds them
|
||||
/// against the new user's workspace. Call on logout or account switch.
|
||||
///
|
||||
/// Without this, the engine `OnceLock` would stay frozen on the previous
|
||||
/// user's `workspace_dir` and subsequent ticks / RPC queries would leak
|
||||
/// into the wrong DB.
|
||||
pub async fn reset_engine_for_user_switch() {
|
||||
stop_heartbeat_loop().await;
|
||||
|
||||
|
||||
@@ -1,285 +1,88 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::openhuman::subconscious::decision_log::DecisionLog;
|
||||
use crate::openhuman::subconscious::store;
|
||||
use crate::openhuman::subconscious::types::{
|
||||
EscalationPriority, EscalationStatus, TaskRecurrence, TaskSource, TickDecision,
|
||||
use crate::openhuman::subconscious::reflection::{
|
||||
hydrate_draft, ReflectionDraft, ReflectionKind,
|
||||
};
|
||||
use crate::openhuman::subconscious::reflection_store;
|
||||
use crate::openhuman::subconscious::store;
|
||||
|
||||
#[test]
|
||||
fn sqlite_task_lifecycle_one_off() {
|
||||
fn reflection_with_thread_id_persists() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
store::with_connection(dir.path(), |conn| {
|
||||
// Add a one-off task
|
||||
let task = store::add_task(
|
||||
conn,
|
||||
"Remind about meeting",
|
||||
TaskSource::User,
|
||||
TaskRecurrence::Once,
|
||||
)?;
|
||||
assert!(!task.completed);
|
||||
assert_eq!(task.recurrence, TaskRecurrence::Once);
|
||||
|
||||
// Should be due immediately
|
||||
let due = store::due_tasks(conn, 9999999999.0)?;
|
||||
assert_eq!(due.len(), 1);
|
||||
|
||||
// Execute and complete
|
||||
store::add_log_entry(
|
||||
conn,
|
||||
&task.id,
|
||||
1000.0,
|
||||
"act",
|
||||
Some("Reminded user"),
|
||||
Some(50),
|
||||
)?;
|
||||
store::mark_task_completed(conn, &task.id)?;
|
||||
|
||||
// Should no longer be due
|
||||
let due = store::due_tasks(conn, 9999999999.0)?;
|
||||
assert_eq!(due.len(), 0);
|
||||
|
||||
// Task still exists but completed
|
||||
let t = store::get_task(conn, &task.id)?;
|
||||
assert!(t.completed);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_task_lifecycle_recurrent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
store::with_connection(dir.path(), |conn| {
|
||||
let task = store::add_task(
|
||||
conn,
|
||||
"Check email",
|
||||
TaskSource::User,
|
||||
TaskRecurrence::Cron("0 8 * * *".into()),
|
||||
)?;
|
||||
|
||||
// Execute and set next run
|
||||
let now = 1000.0;
|
||||
let next = 2000.0;
|
||||
store::add_log_entry(
|
||||
conn,
|
||||
&task.id,
|
||||
now,
|
||||
"act",
|
||||
Some("Checked 3 emails"),
|
||||
Some(200),
|
||||
)?;
|
||||
store::update_task_run_times(conn, &task.id, now, Some(next))?;
|
||||
|
||||
// Not due yet (before next_run_at)
|
||||
let due = store::due_tasks(conn, 1500.0)?;
|
||||
assert_eq!(due.len(), 0);
|
||||
|
||||
// Due after next_run_at
|
||||
let due = store::due_tasks(conn, 2500.0)?;
|
||||
assert_eq!(due.len(), 1);
|
||||
|
||||
// Task should NOT be completed
|
||||
let t = store::get_task(conn, &task.id)?;
|
||||
assert!(!t.completed);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escalation_approve_dismiss_flow() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
store::with_connection(dir.path(), |conn| {
|
||||
let task = store::add_task(
|
||||
conn,
|
||||
"Review deadline",
|
||||
TaskSource::User,
|
||||
TaskRecurrence::Once,
|
||||
)?;
|
||||
|
||||
// Create escalation
|
||||
let esc = store::add_escalation(
|
||||
conn,
|
||||
&task.id,
|
||||
None,
|
||||
"Deadline conflict",
|
||||
"Two deadlines on the same day",
|
||||
&EscalationPriority::Important,
|
||||
)?;
|
||||
assert_eq!(esc.status, EscalationStatus::Pending);
|
||||
assert_eq!(store::pending_escalation_count(conn)?, 1);
|
||||
|
||||
// Approve
|
||||
store::resolve_escalation(conn, &esc.id, &EscalationStatus::Approved)?;
|
||||
let resolved = store::get_escalation(conn, &esc.id)?;
|
||||
assert_eq!(resolved.status, EscalationStatus::Approved);
|
||||
assert!(resolved.resolved_at.is_some());
|
||||
assert_eq!(store::pending_escalation_count(conn)?, 0);
|
||||
|
||||
// Create another and dismiss
|
||||
let esc2 = store::add_escalation(
|
||||
conn,
|
||||
&task.id,
|
||||
None,
|
||||
"Budget warning",
|
||||
"Monthly spend at 90%",
|
||||
&EscalationPriority::Normal,
|
||||
)?;
|
||||
store::resolve_escalation(conn, &esc2.id, &EscalationStatus::Dismissed)?;
|
||||
let dismissed = store::get_escalation(conn, &esc2.id)?;
|
||||
assert_eq!(dismissed.status, EscalationStatus::Dismissed);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_log_tracks_history() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
store::with_connection(dir.path(), |conn| {
|
||||
let task = store::add_task(
|
||||
conn,
|
||||
"Check health",
|
||||
TaskSource::System,
|
||||
TaskRecurrence::Pending,
|
||||
)?;
|
||||
|
||||
store::add_log_entry(conn, &task.id, 1000.0, "noop", Some("All healthy"), None)?;
|
||||
store::add_log_entry(
|
||||
conn,
|
||||
&task.id,
|
||||
2000.0,
|
||||
"act",
|
||||
Some("Restarted skill"),
|
||||
Some(500),
|
||||
)?;
|
||||
store::add_log_entry(conn, &task.id, 3000.0, "noop", Some("All healthy"), None)?;
|
||||
|
||||
let entries = store::list_log_entries(conn, Some(&task.id), 10)?;
|
||||
assert_eq!(entries.len(), 3);
|
||||
// Most recent first
|
||||
assert_eq!(entries[0].tick_at, 3000.0);
|
||||
assert_eq!(entries[1].decision, "act");
|
||||
|
||||
// Global log
|
||||
let all = store::list_log_entries(conn, None, 2)?;
|
||||
assert_eq!(all.len(), 2); // limited to 2
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_log_dedup_still_works() {
|
||||
let mut log = DecisionLog::new();
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs_f64();
|
||||
|
||||
log.record(
|
||||
now,
|
||||
TickDecision::Escalate,
|
||||
"deadline email",
|
||||
vec!["doc-1".into()],
|
||||
);
|
||||
|
||||
// doc-1 should be filtered as already surfaced
|
||||
let unsurfaced = log.filter_unsurfaced(&["doc-1".into(), "doc-2".into()]);
|
||||
assert!(!unsurfaced.contains(&"doc-1".to_string()));
|
||||
assert!(unsurfaced.contains(&"doc-2".to_string()));
|
||||
|
||||
// Acknowledge doc-1
|
||||
log.mark_acknowledged(&["doc-1".into()]);
|
||||
assert!(!log.was_already_surfaced(&["doc-1".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_then_query_tasks() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
store::with_connection(dir.path(), |conn| {
|
||||
let count = store::seed_default_tasks(conn)?;
|
||||
assert_eq!(count, 3);
|
||||
|
||||
let tasks = store::list_tasks(conn, true)?;
|
||||
assert_eq!(tasks.len(), 3);
|
||||
assert!(tasks.iter().all(|t| t.source == TaskSource::System));
|
||||
assert!(tasks
|
||||
.iter()
|
||||
.all(|t| t.recurrence == TaskRecurrence::Pending));
|
||||
|
||||
// All should be due (no next_run_at set)
|
||||
let due = store::due_tasks(conn, 9999999999.0)?;
|
||||
assert_eq!(due.len(), 3);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Regression test for the "empty task list on fresh install" bug.
|
||||
///
|
||||
/// The core server's startup path calls `get_or_init_engine()` to
|
||||
/// eagerly construct a `SubconsciousEngine`, relying on the constructor
|
||||
/// to seed the 3 default system tasks. This test locks in that
|
||||
/// invariant: constructing the engine alone — with no tick, no
|
||||
/// trigger RPC, and no explicit seed call — must leave the 3 defaults
|
||||
/// in the SQLite store.
|
||||
#[test]
|
||||
fn engine_construction_seeds_default_tasks() {
|
||||
use crate::openhuman::config::HeartbeatConfig;
|
||||
use crate::openhuman::subconscious::SubconsciousEngine;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().to_path_buf();
|
||||
|
||||
// Construct the engine via the same path the core server uses at
|
||||
// startup. Memory client is not required for seeding.
|
||||
let _engine = SubconsciousEngine::from_heartbeat_config(
|
||||
&HeartbeatConfig::default(),
|
||||
workspace.clone(),
|
||||
None,
|
||||
);
|
||||
|
||||
// The 3 default system tasks must now exist in the store.
|
||||
store::with_connection(&workspace, |conn| {
|
||||
let tasks = store::list_tasks(conn, false)?;
|
||||
assert_eq!(
|
||||
tasks.len(),
|
||||
3,
|
||||
"engine construction must seed the 3 default system tasks"
|
||||
let draft = ReflectionDraft {
|
||||
kind: ReflectionKind::Opportunity,
|
||||
body: "Test thought".into(),
|
||||
proposed_action: Some("Do something".into()),
|
||||
source_refs: vec!["entity:test".into()],
|
||||
};
|
||||
let reflection = hydrate_draft(
|
||||
draft,
|
||||
"r-1".into(),
|
||||
1_700_000_000.0,
|
||||
Vec::new(),
|
||||
Some("thread-abc".into()),
|
||||
);
|
||||
assert!(tasks.iter().all(|t| t.source == TaskSource::System));
|
||||
assert!(tasks
|
||||
.iter()
|
||||
.all(|t| t.recurrence == TaskRecurrence::Pending));
|
||||
reflection_store::add_reflection(conn, &reflection)?;
|
||||
|
||||
let got = reflection_store::get_reflection(conn, "r-1")?.unwrap();
|
||||
assert_eq!(got.thread_id, Some("thread-abc".into()));
|
||||
assert_eq!(got.body, "Test thought");
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Reconstructing the engine on the same workspace must not
|
||||
// duplicate the defaults — seed_default_tasks is idempotent.
|
||||
#[test]
|
||||
fn reflection_without_thread_id_persists() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
store::with_connection(dir.path(), |conn| {
|
||||
let draft = ReflectionDraft {
|
||||
kind: ReflectionKind::DailyDigest,
|
||||
body: "No thread".into(),
|
||||
proposed_action: None,
|
||||
source_refs: vec![],
|
||||
};
|
||||
let reflection = hydrate_draft(draft, "r-2".into(), 1_700_000_000.0, Vec::new(), None);
|
||||
reflection_store::add_reflection(conn, &reflection)?;
|
||||
|
||||
let _engine2 = SubconsciousEngine::from_heartbeat_config(
|
||||
&HeartbeatConfig::default(),
|
||||
workspace.clone(),
|
||||
None,
|
||||
);
|
||||
let got = reflection_store::get_reflection(conn, "r-2")?.unwrap();
|
||||
assert!(got.thread_id.is_none());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
store::with_connection(&workspace, |conn| {
|
||||
let tasks = store::list_tasks(conn, false)?;
|
||||
assert_eq!(
|
||||
tasks.len(),
|
||||
3,
|
||||
"repeat engine construction must not duplicate default tasks"
|
||||
);
|
||||
#[test]
|
||||
fn list_recent_includes_thread_id() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
store::with_connection(dir.path(), |conn| {
|
||||
for i in 0..3 {
|
||||
let draft = ReflectionDraft {
|
||||
kind: ReflectionKind::HotnessSpike,
|
||||
body: format!("thought {i}"),
|
||||
proposed_action: None,
|
||||
source_refs: vec![],
|
||||
};
|
||||
let tid = if i == 1 {
|
||||
Some("thread-xyz".into())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let reflection = hydrate_draft(
|
||||
draft,
|
||||
format!("r-{i}"),
|
||||
1_700_000_000.0 + f64::from(i),
|
||||
Vec::new(),
|
||||
tid,
|
||||
);
|
||||
reflection_store::add_reflection(conn, &reflection)?;
|
||||
}
|
||||
|
||||
let list = reflection_store::list_recent(conn, 10, None)?;
|
||||
assert_eq!(list.len(), 3);
|
||||
assert_eq!(list[1].thread_id, Some("thread-xyz".into()));
|
||||
assert!(list[0].thread_id.is_none());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
pub mod engine;
|
||||
pub mod executor;
|
||||
pub mod global;
|
||||
pub mod prompt;
|
||||
pub mod reflection;
|
||||
@@ -10,9 +9,6 @@ pub mod source_chunk;
|
||||
pub mod store;
|
||||
pub mod types;
|
||||
|
||||
// Keep decision_log for potential future dedup queries against the log table.
|
||||
pub mod decision_log;
|
||||
|
||||
#[cfg(test)]
|
||||
mod integration_tests;
|
||||
|
||||
@@ -23,7 +19,4 @@ pub use schemas::{
|
||||
all_registered_controllers as all_subconscious_registered_controllers,
|
||||
};
|
||||
pub use source_chunk::SourceChunk;
|
||||
pub use types::{
|
||||
Escalation, EscalationStatus, SubconsciousLogEntry, SubconsciousStatus, SubconsciousTask,
|
||||
TaskRecurrence, TaskSource, TickDecision, TickResult,
|
||||
};
|
||||
pub use types::{SubconsciousStatus, TickResult};
|
||||
|
||||
@@ -1,216 +1,88 @@
|
||||
//! Prompt builders for the subconscious evaluation and execution phases.
|
||||
//! Prompt builder for the subconscious agent.
|
||||
//!
|
||||
//! Injects OpenClaw identity context (SOUL.md, PROFILE.md) so the local model
|
||||
//! reasons as the agent, not a generic evaluator.
|
||||
//! The subconscious agent is a periodic summarizer that reads the situation
|
||||
//! report (memory-tree signals, recent activity, hotness deltas) and
|
||||
//! produces structured thoughts (reflections) about the user's state.
|
||||
|
||||
use super::types::SubconsciousTask;
|
||||
use std::path::Path;
|
||||
|
||||
const IDENTITY_EXCERPT_CHARS: usize = 2000;
|
||||
|
||||
// ── Evaluation prompt ────────────────────────────────────────────────────────
|
||||
|
||||
/// Build the per-tick evaluation prompt. The local model evaluates each due
|
||||
/// task against the situation report and returns a per-task decision.
|
||||
pub fn build_evaluation_prompt(
|
||||
tasks: &[SubconsciousTask],
|
||||
situation_report: &str,
|
||||
identity_context: &str,
|
||||
) -> String {
|
||||
let task_list = tasks
|
||||
.iter()
|
||||
.map(|t| format!("- [{}] {}", t.id, t.title))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
/// Build the system prompt for the subconscious agent tick. The agent
|
||||
/// observes the user's world via the situation report and produces
|
||||
/// structured reflections.
|
||||
pub fn build_agent_prompt(situation_report: &str, identity_context: &str) -> String {
|
||||
format!(
|
||||
r#"{identity_context}
|
||||
|
||||
# Subconscious Loop — Task Evaluation
|
||||
# Subconscious Agent
|
||||
|
||||
You are the background awareness layer. You run periodically to evaluate
|
||||
user-defined tasks against the current workspace state.
|
||||
You are the user's background awareness layer. You wake up periodically,
|
||||
review what's happening in their world, and surface useful thoughts.
|
||||
|
||||
## Due tasks
|
||||
|
||||
{task_list}
|
||||
|
||||
## Current state
|
||||
## Situation Report (pre-loaded context)
|
||||
|
||||
{situation_report}
|
||||
|
||||
## Your job
|
||||
## Instructions
|
||||
|
||||
For each task, check if the current state has anything relevant. Decide:
|
||||
- **noop**: Nothing actionable for this task right now.
|
||||
- **act**: The task should be executed now (state has relevant data).
|
||||
- **escalate**: The task needs user approval before acting (ambiguous, risky, or irreversible).
|
||||
1. **Research**: Use your tools to look up relevant memory, recent activity,
|
||||
conversations, or web context that would deepen your understanding.
|
||||
Use `memory_recall` to query specific topics. Use `web_fetch` or search
|
||||
tools if external context would help.
|
||||
|
||||
## Reflections (#623 — proactive layer)
|
||||
2. **Observe**: Based on both the situation report and your research,
|
||||
identify patterns, deadlines, risks, opportunities, or interesting
|
||||
cross-source connections.
|
||||
|
||||
You also surface **reflections**: free-form observations grounded in the
|
||||
memory-tree signal sections (Hotness deltas, Recent summaries, Latest
|
||||
daily digest, Recap window). Reflections are *not* task evaluations —
|
||||
they let you point out something the user should know, even if no task
|
||||
covers it.
|
||||
3. **Promote to orchestrator**: If you find something that needs a deeper
|
||||
investigation or multi-step action, use `spawn_subagent` or
|
||||
`spawn_worker_thread` to delegate the work. The orchestrator can take
|
||||
action; you observe and delegate.
|
||||
|
||||
**Self vs. others (#1365)**: the situation report includes a *Your
|
||||
Identifiers* section listing the user's connected-account handles,
|
||||
emails, and user_ids. Use it as the source of truth for who the user is.
|
||||
4. **Surface thoughts**: Produce structured observations for the user.
|
||||
Only surface genuinely useful insights — skip trivial observations.
|
||||
|
||||
- *Hotness deltas* tagged `(you)` are the user's own identifiers. Frame
|
||||
reflections grounded in those in second person: *"Your phoenix mentions
|
||||
surged 4× this hour."* Untagged hotness items are someone or something
|
||||
else — reflect on them as *about that other entity*, not as the user.
|
||||
- For *Recent summaries*, *Latest global digest*, and *Recap window*
|
||||
body text, the prose mentions people by name, email, or handle inline.
|
||||
Match each mention against the *Your Identifiers* list: if it appears
|
||||
there, that sentence is about the user; otherwise it's about a
|
||||
collaborator/contact. **Never attribute another person's activity to
|
||||
the user.** A digest line "Cyrus deployed phoenix" is the user's
|
||||
activity only when *Cyrus* (or the matching email/handle) appears in
|
||||
*Your Identifiers* — otherwise Cyrus is someone the user is reading
|
||||
about, and the reflection should reflect that.
|
||||
- If a reflection mixes self and other signals, separate them
|
||||
explicitly: *"You spent the morning on phoenix; meanwhile, Sam pushed
|
||||
a refactor."*
|
||||
**Self vs. others**: the *Your Identifiers* section (if present) lists
|
||||
the user's handles, emails, and user_ids. Never attribute someone else's
|
||||
activity to the user.
|
||||
|
||||
For each reflection:
|
||||
- `kind`: one of `hotness_spike` | `cross_source_pattern` | `daily_digest`
|
||||
| `due_item` | `risk` | `opportunity`.
|
||||
- `body`: short markdown-friendly observation.
|
||||
- `proposed_action` (optional): one-tap action text. When the user taps
|
||||
the action button, OpenHuman opens a *new* conversation thread seeded
|
||||
with the body + this action — never auto-executed, never written into
|
||||
any existing chat.
|
||||
- `source_refs`: opaque ids from the situation report so we can trace
|
||||
provenance.
|
||||
**Anti-double-emit**: the *Recent reflections* section shows what you
|
||||
already surfaced. Re-emit only if the signal materially intensified.
|
||||
|
||||
**Anti-double-emit**: the situation report's "Recent reflections" section
|
||||
shows what you already noticed. Re-emit only if the underlying signal
|
||||
materially intensified — otherwise let it decay silently.
|
||||
Cap: at most **5 thoughts per tick**.
|
||||
|
||||
**No side effects, no thread bloat**: reflections are observation-only.
|
||||
They surface on the Intelligence tab; nothing is auto-posted into any
|
||||
conversation. Only emit a `proposed_action` when there is a concrete
|
||||
follow-up the user could plausibly want to start a chat about.
|
||||
## Final output
|
||||
|
||||
Cap: emit at most **5 reflections per tick**. Excess is dropped.
|
||||
|
||||
## Output format (strict JSON, no other text)
|
||||
After you've finished researching, end your final message with a JSON
|
||||
block containing your thoughts:
|
||||
|
||||
```json
|
||||
{{
|
||||
"evaluations": [
|
||||
{{"task_id": "<id>", "decision": "noop|act|escalate", "reason": "one sentence"}}
|
||||
],
|
||||
"reflections": [
|
||||
"thoughts": [
|
||||
{{
|
||||
"kind": "hotness_spike",
|
||||
"body": "Phoenix mentions surged 4× in last hour across Slack + email.",
|
||||
"proposed_action": "Pull the last 24h of Phoenix mentions into a thread",
|
||||
"source_refs": ["entity:phoenix", "summary:abc123"]
|
||||
"kind": "hotness_spike | cross_source_pattern | daily_digest | due_item | risk | opportunity",
|
||||
"body": "Short markdown observation.",
|
||||
"proposed_action": "Optional one-tap action text (or null).",
|
||||
"source_refs": ["entity:foo", "summary:bar"]
|
||||
}}
|
||||
]
|
||||
}}
|
||||
```
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Render a slice of recent reflections as a wire-format prompt block —
|
||||
/// matches what the LLM was taught about in `build_evaluation_prompt`.
|
||||
/// Used by the situation_report's "Recent reflections" section so the
|
||||
/// representation is identical between teaching and reading.
|
||||
/// Render a slice of recent reflections as a prompt block for the
|
||||
/// situation report's "Recent reflections" section.
|
||||
pub fn format_recent_reflections_for_prompt(
|
||||
reflections: &[crate::openhuman::subconscious::reflection::Reflection],
|
||||
) -> String {
|
||||
crate::openhuman::subconscious::situation_report::reflections::build_section(reflections)
|
||||
}
|
||||
|
||||
// ── Execution prompts ────────────────────────────────────────────────────────
|
||||
|
||||
/// Build the prompt for executing a text-only task via local Ollama model.
|
||||
/// Used for tasks that don't need tools (summarize, extract, classify, etc.)
|
||||
pub fn build_text_execution_prompt(
|
||||
task: &SubconsciousTask,
|
||||
situation_report: &str,
|
||||
identity_context: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
r#"{identity_context}
|
||||
|
||||
# Task Execution
|
||||
|
||||
Execute the following task based on the current state. Respond with the result only.
|
||||
|
||||
## Task
|
||||
{task_title}
|
||||
|
||||
## Current state
|
||||
{situation_report}
|
||||
|
||||
Do the task now. Return only the result — no explanations or meta-commentary."#,
|
||||
task_title = task.title
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the prompt for executing a tool-required task via the full agentic loop.
|
||||
/// Used for tasks that need side effects (send message, create doc, etc.)
|
||||
pub fn build_tool_execution_prompt(
|
||||
task: &SubconsciousTask,
|
||||
situation_report: &str,
|
||||
identity_context: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
r#"{identity_context}
|
||||
|
||||
# Background Task Execution
|
||||
|
||||
You are executing a user-defined background task. Use your available tools to complete it.
|
||||
|
||||
## Task
|
||||
{task_title}
|
||||
|
||||
## Current state
|
||||
{situation_report}
|
||||
|
||||
Execute this task using the appropriate tools. Complete the task fully — don't just describe what to do."#,
|
||||
task_title = task.title
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a read-only analysis prompt for agentic-v1. Used when a read-only task
|
||||
/// is escalated — the agent should analyze and recommend but NOT execute writes.
|
||||
pub fn build_analysis_only_prompt(
|
||||
task: &SubconsciousTask,
|
||||
situation_report: &str,
|
||||
identity_context: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
r#"{identity_context}
|
||||
|
||||
# Background Task — Analysis Only
|
||||
|
||||
You are analyzing a background task. You may use read-only tools to gather information,
|
||||
but you MUST NOT execute any write actions (send, post, create, delete, forward, reply, update, publish).
|
||||
|
||||
If you determine that a write action is needed, describe exactly what you would do in your response
|
||||
but do not execute it. Start your recommendation with "RECOMMENDED ACTION:" on its own line.
|
||||
|
||||
## Task
|
||||
{task_title}
|
||||
|
||||
## Current state
|
||||
{situation_report}
|
||||
|
||||
Analyze the situation and report your findings. If action is needed, describe it clearly but do NOT execute."#,
|
||||
task_title = task.title
|
||||
)
|
||||
}
|
||||
|
||||
// ── Identity loading ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Load identity context from SOUL.md and PROFILE.md in the workspace.
|
||||
/// Returns a formatted string to prepend to prompts.
|
||||
pub fn load_identity_context(workspace_dir: &Path) -> String {
|
||||
let prompts_dir = resolve_prompts_dir(workspace_dir);
|
||||
let mut ctx = String::new();
|
||||
@@ -222,8 +94,6 @@ pub fn load_identity_context(workspace_dir: &Path) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// PROFILE.md lives in the workspace root (not prompts dir) — it's
|
||||
// generated by the onboarding enrichment pipeline, not bundled.
|
||||
if let Some(profile) = load_file_excerpt(workspace_dir, "PROFILE.md") {
|
||||
ctx.push_str("## User Profile\n\n");
|
||||
ctx.push_str(&profile);
|
||||
@@ -238,13 +108,11 @@ pub fn load_identity_context(workspace_dir: &Path) -> String {
|
||||
}
|
||||
|
||||
fn resolve_prompts_dir(workspace_dir: &Path) -> Option<std::path::PathBuf> {
|
||||
// Check workspace AI dir
|
||||
let workspace_ai = workspace_dir.join("ai");
|
||||
if workspace_ai.is_dir() {
|
||||
return Some(workspace_ai);
|
||||
}
|
||||
|
||||
// Try CARGO_MANIFEST_DIR (dev builds)
|
||||
if let Some(dir) = option_env!("CARGO_MANIFEST_DIR").map(std::path::PathBuf::from) {
|
||||
let candidate = dir
|
||||
.join("src")
|
||||
@@ -256,7 +124,6 @@ fn resolve_prompts_dir(workspace_dir: &Path) -> Option<std::path::PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
// Walk up from cwd
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
return crate::openhuman::dev_paths::repo_ai_prompts_dir(&cwd);
|
||||
}
|
||||
@@ -281,60 +148,22 @@ fn load_file_excerpt(dir: &Path, filename: &str) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::openhuman::subconscious::types::{TaskRecurrence, TaskSource};
|
||||
|
||||
fn test_task(id: &str, title: &str) -> SubconsciousTask {
|
||||
SubconsciousTask {
|
||||
id: id.to_string(),
|
||||
title: title.to_string(),
|
||||
source: TaskSource::User,
|
||||
recurrence: TaskRecurrence::Once,
|
||||
enabled: true,
|
||||
last_run_at: None,
|
||||
next_run_at: None,
|
||||
completed: false,
|
||||
created_at: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluation_prompt_includes_tasks_and_report() {
|
||||
let tasks = vec![
|
||||
test_task("t1", "Check email"),
|
||||
test_task("t2", "Review calendar"),
|
||||
];
|
||||
let prompt = build_evaluation_prompt(&tasks, "## State\nSome data.", "Identity here");
|
||||
assert!(prompt.contains("[t1] Check email"));
|
||||
assert!(prompt.contains("[t2] Review calendar"));
|
||||
fn agent_prompt_includes_report_and_identity() {
|
||||
let prompt = build_agent_prompt("## State\nSome data.", "Identity here");
|
||||
assert!(prompt.contains("Some data."));
|
||||
assert!(prompt.contains("Identity here"));
|
||||
assert!(prompt.contains("thoughts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluation_prompt_includes_decision_schema() {
|
||||
let tasks = vec![test_task("t1", "Task")];
|
||||
let prompt = build_evaluation_prompt(&tasks, "", "");
|
||||
assert!(prompt.contains("noop"));
|
||||
assert!(prompt.contains("act"));
|
||||
assert!(prompt.contains("escalate"));
|
||||
assert!(prompt.contains("evaluations"));
|
||||
assert!(prompt.contains("task_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_execution_prompt_includes_task_title() {
|
||||
let task = test_task("t1", "Summarize urgent emails");
|
||||
let prompt = build_text_execution_prompt(&task, "3 new emails", "Identity");
|
||||
assert!(prompt.contains("Summarize urgent emails"));
|
||||
assert!(prompt.contains("3 new emails"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_execution_prompt_includes_tool_instructions() {
|
||||
let task = test_task("t1", "Send digest to Telegram");
|
||||
let prompt = build_tool_execution_prompt(&task, "Email data here", "Identity");
|
||||
assert!(prompt.contains("Send digest to Telegram"));
|
||||
assert!(prompt.contains("tools"));
|
||||
fn agent_prompt_includes_output_schema() {
|
||||
let prompt = build_agent_prompt("", "");
|
||||
assert!(prompt.contains("kind"));
|
||||
assert!(prompt.contains("body"));
|
||||
assert!(prompt.contains("proposed_action"));
|
||||
assert!(prompt.contains("source_refs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -54,6 +54,10 @@ pub struct Reflection {
|
||||
pub acted_on_at: Option<f64>,
|
||||
/// Epoch seconds when the user dismissed the card.
|
||||
pub dismissed_at: Option<f64>,
|
||||
/// Thread ID of the agent conversation that produced this reflection.
|
||||
/// Clicking the thought in the UI navigates to this thread.
|
||||
#[serde(default)]
|
||||
pub thread_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Categorisation of the underlying signal. Start narrow; we can grow
|
||||
@@ -132,6 +136,7 @@ pub fn hydrate_draft(
|
||||
id: String,
|
||||
now: f64,
|
||||
source_chunks: Vec<SourceChunk>,
|
||||
thread_id: Option<String>,
|
||||
) -> Reflection {
|
||||
Reflection {
|
||||
id,
|
||||
@@ -143,6 +148,7 @@ pub fn hydrate_draft(
|
||||
created_at: now,
|
||||
acted_on_at: None,
|
||||
dismissed_at: None,
|
||||
thread_id,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ pub const REFLECTION_SCHEMA_DDL: &str = "
|
||||
source_chunks TEXT NOT NULL DEFAULT '[]',
|
||||
created_at REAL NOT NULL,
|
||||
acted_on_at REAL,
|
||||
dismissed_at REAL
|
||||
dismissed_at REAL,
|
||||
thread_id TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reflections_created
|
||||
ON subconscious_reflections(created_at DESC);
|
||||
@@ -86,6 +87,13 @@ pub fn migrate_add_source_chunks_column(conn: &Connection) {
|
||||
);
|
||||
}
|
||||
|
||||
pub fn migrate_add_thread_id_column(conn: &Connection) {
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE subconscious_reflections ADD COLUMN thread_id TEXT",
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Reflection CRUD ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Persist a fresh reflection. Idempotent on `id`: if a row with the same
|
||||
@@ -101,8 +109,8 @@ pub fn add_reflection(conn: &Connection, reflection: &Reflection) -> Result<()>
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO subconscious_reflections (
|
||||
id, kind, body, proposed_action, source_refs, source_chunks,
|
||||
created_at, acted_on_at, dismissed_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
created_at, acted_on_at, dismissed_at, thread_id
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
params![
|
||||
reflection.id,
|
||||
reflection.kind.as_str(),
|
||||
@@ -113,14 +121,16 @@ pub fn add_reflection(conn: &Connection, reflection: &Reflection) -> Result<()>
|
||||
reflection.created_at,
|
||||
reflection.acted_on_at,
|
||||
reflection.dismissed_at,
|
||||
reflection.thread_id,
|
||||
],
|
||||
)
|
||||
.context("insert reflection")?;
|
||||
log::debug!(
|
||||
"[subconscious::reflection_store] added id={} kind={} chunks={}",
|
||||
"[subconscious::reflection_store] added id={} kind={} chunks={} thread={:?}",
|
||||
reflection.id,
|
||||
reflection.kind.as_str(),
|
||||
reflection.source_chunks.len()
|
||||
reflection.source_chunks.len(),
|
||||
reflection.thread_id,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -138,7 +148,7 @@ pub fn list_recent(
|
||||
let mapped: Vec<Reflection> = if let Some(ts) = since_ts {
|
||||
stmt = conn.prepare(
|
||||
"SELECT id, kind, body, proposed_action, source_refs, source_chunks,
|
||||
created_at, acted_on_at, dismissed_at
|
||||
created_at, acted_on_at, dismissed_at, thread_id
|
||||
FROM subconscious_reflections
|
||||
WHERE created_at > ?1
|
||||
ORDER BY created_at DESC LIMIT ?2",
|
||||
@@ -151,7 +161,7 @@ pub fn list_recent(
|
||||
} else {
|
||||
stmt = conn.prepare(
|
||||
"SELECT id, kind, body, proposed_action, source_refs, source_chunks,
|
||||
created_at, acted_on_at, dismissed_at
|
||||
created_at, acted_on_at, dismissed_at, thread_id
|
||||
FROM subconscious_reflections
|
||||
ORDER BY created_at DESC LIMIT ?1",
|
||||
)?;
|
||||
@@ -168,7 +178,7 @@ pub fn list_recent(
|
||||
pub fn get_reflection(conn: &Connection, id: &str) -> Result<Option<Reflection>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, kind, body, proposed_action, source_refs, source_chunks,
|
||||
created_at, acted_on_at, dismissed_at
|
||||
created_at, acted_on_at, dismissed_at, thread_id
|
||||
FROM subconscious_reflections WHERE id = ?1",
|
||||
)?;
|
||||
let r = stmt
|
||||
@@ -206,6 +216,7 @@ fn row_to_reflection(row: &rusqlite::Row) -> rusqlite::Result<Reflection> {
|
||||
let created_at: f64 = row.get(6)?;
|
||||
let acted_on_at: Option<f64> = row.get(7)?;
|
||||
let dismissed_at: Option<f64> = row.get(8)?;
|
||||
let thread_id: Option<String> = row.get(9)?;
|
||||
|
||||
let source_refs: Vec<String> =
|
||||
serde_json::from_str(&source_refs_json).unwrap_or_else(|_| Vec::new());
|
||||
@@ -222,6 +233,7 @@ fn row_to_reflection(row: &rusqlite::Row) -> rusqlite::Result<Reflection> {
|
||||
created_at,
|
||||
acted_on_at,
|
||||
dismissed_at,
|
||||
thread_id,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ fn sample_reflection(id: &str, created_at: f64) -> Reflection {
|
||||
proposed_action: Some("Take a look".into()),
|
||||
source_refs: vec!["entity:foo".into()],
|
||||
};
|
||||
hydrate_draft(draft, id.into(), created_at, Vec::new())
|
||||
hydrate_draft(draft, id.into(), created_at, Vec::new(), None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -61,7 +61,7 @@ fn hydrate_draft_fills_lifecycle_fields() {
|
||||
proposed_action: Some("Draft an invite list".into()),
|
||||
source_refs: vec!["entity:dinner".into()],
|
||||
};
|
||||
let r = hydrate_draft(draft, "abc-123".into(), 1_700_000_000.0, Vec::new());
|
||||
let r = hydrate_draft(draft, "abc-123".into(), 1_700_000_000.0, Vec::new(), None);
|
||||
assert_eq!(r.id, "abc-123");
|
||||
assert_eq!(r.created_at, 1_700_000_000.0);
|
||||
assert!(r.acted_on_at.is_none());
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
//! RPC endpoints for the subconscious task system.
|
||||
//! RPC endpoints for the subconscious agent loop.
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::global::get_or_init_engine;
|
||||
use super::reflection_store;
|
||||
use super::store;
|
||||
use super::types::{EscalationStatus, TaskPatch, TaskRecurrence, TaskSource};
|
||||
use crate::core::all::{ControllerFuture, RegisteredController};
|
||||
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
|
||||
use crate::rpc::RpcOutcome;
|
||||
@@ -14,14 +13,6 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
vec![
|
||||
schemas("status"),
|
||||
schemas("trigger"),
|
||||
schemas("tasks_list"),
|
||||
schemas("tasks_add"),
|
||||
schemas("tasks_update"),
|
||||
schemas("tasks_remove"),
|
||||
schemas("log_list"),
|
||||
schemas("escalations_list"),
|
||||
schemas("escalations_approve"),
|
||||
schemas("escalations_dismiss"),
|
||||
schemas("reflections_list"),
|
||||
schemas("reflections_act"),
|
||||
schemas("reflections_dismiss"),
|
||||
@@ -38,38 +29,6 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
schema: schemas("trigger"),
|
||||
handler: handle_trigger,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("tasks_list"),
|
||||
handler: handle_tasks_list,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("tasks_add"),
|
||||
handler: handle_tasks_add,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("tasks_update"),
|
||||
handler: handle_tasks_update,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("tasks_remove"),
|
||||
handler: handle_tasks_remove,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("log_list"),
|
||||
handler: handle_log_list,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("escalations_list"),
|
||||
handler: handle_escalations_list,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("escalations_approve"),
|
||||
handler: handle_escalations_approve,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("escalations_dismiss"),
|
||||
handler: handle_escalations_dismiss,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("reflections_list"),
|
||||
handler: handle_reflections_list,
|
||||
@@ -101,143 +60,30 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
inputs: vec![],
|
||||
outputs: vec![field("result", TypeSchema::Json, "Tick result.")],
|
||||
},
|
||||
"tasks_list" => ControllerSchema {
|
||||
namespace: "subconscious",
|
||||
function: "tasks_list",
|
||||
description: "List all subconscious tasks.",
|
||||
inputs: vec![field_opt(
|
||||
"enabled_only",
|
||||
TypeSchema::Bool,
|
||||
"Filter to enabled tasks only.",
|
||||
)],
|
||||
outputs: vec![field("tasks", TypeSchema::Json, "Array of tasks.")],
|
||||
},
|
||||
"tasks_add" => ControllerSchema {
|
||||
namespace: "subconscious",
|
||||
function: "tasks_add",
|
||||
description: "Add a new task. The agent classifies it as one-off or recurrent.",
|
||||
inputs: vec![
|
||||
field_req(
|
||||
"title",
|
||||
TypeSchema::String,
|
||||
"Natural language task description.",
|
||||
),
|
||||
field_opt(
|
||||
"source",
|
||||
TypeSchema::String,
|
||||
"Task source: 'user' (default) or 'system'.",
|
||||
),
|
||||
],
|
||||
outputs: vec![field("task", TypeSchema::Json, "The created task.")],
|
||||
},
|
||||
"tasks_update" => ControllerSchema {
|
||||
namespace: "subconscious",
|
||||
function: "tasks_update",
|
||||
description: "Update a task.",
|
||||
inputs: vec![
|
||||
field_req("task_id", TypeSchema::String, "Task ID to update."),
|
||||
field_opt("title", TypeSchema::String, "New title."),
|
||||
field_opt(
|
||||
"recurrence",
|
||||
TypeSchema::String,
|
||||
"New recurrence: 'once' | 'cron:<expr>' | 'pending'.",
|
||||
),
|
||||
field_opt("enabled", TypeSchema::Bool, "Enable or disable."),
|
||||
],
|
||||
outputs: vec![field("result", TypeSchema::Json, "Update confirmation.")],
|
||||
},
|
||||
"tasks_remove" => ControllerSchema {
|
||||
namespace: "subconscious",
|
||||
function: "tasks_remove",
|
||||
description: "Remove a task.",
|
||||
inputs: vec![field_req(
|
||||
"task_id",
|
||||
TypeSchema::String,
|
||||
"Task ID to remove.",
|
||||
)],
|
||||
outputs: vec![field("result", TypeSchema::Json, "Removal confirmation.")],
|
||||
},
|
||||
"log_list" => ControllerSchema {
|
||||
namespace: "subconscious",
|
||||
function: "log_list",
|
||||
description: "List execution log entries.",
|
||||
inputs: vec![
|
||||
field_opt("task_id", TypeSchema::String, "Filter by task ID."),
|
||||
field_opt("limit", TypeSchema::U64, "Max entries (default 50)."),
|
||||
],
|
||||
outputs: vec![field("entries", TypeSchema::Json, "Log entries.")],
|
||||
},
|
||||
"escalations_list" => ControllerSchema {
|
||||
namespace: "subconscious",
|
||||
function: "escalations_list",
|
||||
description: "List escalations.",
|
||||
inputs: vec![field_opt(
|
||||
"status",
|
||||
TypeSchema::String,
|
||||
"Filter: 'pending' | 'approved' | 'dismissed'.",
|
||||
)],
|
||||
outputs: vec![field(
|
||||
"escalations",
|
||||
TypeSchema::Json,
|
||||
"Escalation records.",
|
||||
)],
|
||||
},
|
||||
"escalations_approve" => ControllerSchema {
|
||||
namespace: "subconscious",
|
||||
function: "escalations_approve",
|
||||
description: "Approve an escalation — execute the task.",
|
||||
inputs: vec![field_req(
|
||||
"escalation_id",
|
||||
TypeSchema::String,
|
||||
"Escalation ID.",
|
||||
)],
|
||||
outputs: vec![field("result", TypeSchema::Json, "Approval confirmation.")],
|
||||
},
|
||||
"escalations_dismiss" => ControllerSchema {
|
||||
namespace: "subconscious",
|
||||
function: "escalations_dismiss",
|
||||
description: "Dismiss an escalation — don't execute.",
|
||||
inputs: vec![field_req(
|
||||
"escalation_id",
|
||||
TypeSchema::String,
|
||||
"Escalation ID.",
|
||||
)],
|
||||
outputs: vec![field("result", TypeSchema::Json, "Dismissal confirmation.")],
|
||||
},
|
||||
// ── #623: proactive reflection layer ─────────────────────────────────
|
||||
"reflections_list" => ControllerSchema {
|
||||
namespace: "subconscious",
|
||||
function: "reflections_list",
|
||||
description: "List recent subconscious reflections (Observe + Notify). \
|
||||
Newest first.",
|
||||
description: "List recent subconscious thoughts. Newest first.",
|
||||
inputs: vec![
|
||||
field_opt("limit", TypeSchema::U64, "Max entries (default 50)."),
|
||||
field_opt(
|
||||
"since_ts",
|
||||
TypeSchema::F64,
|
||||
"Epoch seconds — only return reflections newer than this.",
|
||||
"Epoch seconds — only return thoughts newer than this.",
|
||||
),
|
||||
],
|
||||
outputs: vec![field(
|
||||
"reflections",
|
||||
TypeSchema::Json,
|
||||
"Reflection records.",
|
||||
)],
|
||||
outputs: vec![field("reflections", TypeSchema::Json, "Thought records.")],
|
||||
},
|
||||
"reflections_act" => ControllerSchema {
|
||||
namespace: "subconscious",
|
||||
function: "reflections_act",
|
||||
description: "Act on a reflection — creates a fresh conversation thread \
|
||||
and seeds it with the reflection body as the first ASSISTANT \
|
||||
message (with proposed_action appended if present). No LLM \
|
||||
turn fires — the user lands in a thread that opens with the \
|
||||
observation from OpenHuman, ready for them to reply. Marks \
|
||||
`acted_on_at`. Returns the new thread id so the frontend can \
|
||||
navigate to it.",
|
||||
description: "Act on a thought — creates a fresh conversation thread \
|
||||
and seeds it with the thought body as the first ASSISTANT \
|
||||
message. Returns the new thread id.",
|
||||
inputs: vec![field_req(
|
||||
"reflection_id",
|
||||
TypeSchema::String,
|
||||
"Reflection ID.",
|
||||
"Thought ID.",
|
||||
)],
|
||||
outputs: vec![field(
|
||||
"result",
|
||||
@@ -248,11 +94,11 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
"reflections_dismiss" => ControllerSchema {
|
||||
namespace: "subconscious",
|
||||
function: "reflections_dismiss",
|
||||
description: "Dismiss a reflection card. Sets `dismissed_at`.",
|
||||
description: "Dismiss a thought card. Sets `dismissed_at`.",
|
||||
inputs: vec![field_req(
|
||||
"reflection_id",
|
||||
TypeSchema::String,
|
||||
"Reflection ID.",
|
||||
"Thought ID.",
|
||||
)],
|
||||
outputs: vec![field("result", TypeSchema::Json, "Dismissal confirmation.")],
|
||||
},
|
||||
@@ -270,42 +116,44 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
|
||||
fn handle_status(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
// Read status entirely from DB — never touch the engine mutex.
|
||||
// The engine lock is held for the full tick duration, so any RPC
|
||||
// that acquires it would block until the tick completes.
|
||||
// Prefer live engine status (includes in-memory tick counters).
|
||||
// Fall back to a config-derived snapshot when the engine is not yet
|
||||
// initialised — the counters will read 0, which is accurate at that
|
||||
// point because no ticks have run yet.
|
||||
let engine_arc = get_or_init_engine().await.ok();
|
||||
if let Some(arc) = engine_arc {
|
||||
let guard = arc.lock().await;
|
||||
if let Some(engine) = guard.as_ref() {
|
||||
let status = engine.status().await;
|
||||
return to_json(RpcOutcome::single_log(status, "subconscious status"));
|
||||
}
|
||||
}
|
||||
|
||||
// Engine not yet initialised — build a snapshot from config.
|
||||
let config = load_config().await?;
|
||||
let hb = &config.heartbeat;
|
||||
|
||||
let (task_count, pending_escalations, last_tick_at, total_ticks) =
|
||||
store::with_connection(&config.workspace_dir, |conn| {
|
||||
let tc = store::task_count(conn).unwrap_or(0);
|
||||
let pe = store::pending_escalation_count(conn).unwrap_or(0);
|
||||
let (lt, tt) = conn
|
||||
.query_row(
|
||||
"SELECT MAX(tick_at), COUNT(DISTINCT tick_at) FROM subconscious_log",
|
||||
[],
|
||||
|row| Ok((row.get::<_, Option<f64>>(0)?, row.get::<_, u64>(1)?)),
|
||||
)
|
||||
.unwrap_or((None, 0));
|
||||
Ok((tc, pe, lt, tt))
|
||||
})
|
||||
.map_err(|e| format!("{e:#}"))?;
|
||||
let last_tick_at =
|
||||
store::with_connection(&config.workspace_dir, |conn| store::get_last_tick_at(conn))
|
||||
.ok();
|
||||
|
||||
let provider_unavailable_reason = if hb.enabled && hb.inference_enabled {
|
||||
super::engine::subconscious_provider_unavailable_reason(&config)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mode = hb.effective_subconscious_mode();
|
||||
// total_ticks and consecutive_failures are 0 here because the engine
|
||||
// has not started; the engine Mutex cannot be held during RPC.
|
||||
let status = super::types::SubconsciousStatus {
|
||||
enabled: hb.enabled && hb.inference_enabled,
|
||||
enabled: mode.is_enabled(),
|
||||
mode: mode.as_str().to_string(),
|
||||
provider_available: provider_unavailable_reason.is_none(),
|
||||
provider_unavailable_reason,
|
||||
interval_minutes: hb.interval_minutes.max(5),
|
||||
last_tick_at,
|
||||
total_ticks,
|
||||
task_count,
|
||||
pending_escalations,
|
||||
consecutive_failures: 0, // Only available from in-memory state; 0 is fine for UI
|
||||
interval_minutes: mode.default_interval_minutes().max(5),
|
||||
last_tick_at: last_tick_at.filter(|v| *v > 0.0),
|
||||
total_ticks: 0,
|
||||
consecutive_failures: 0,
|
||||
};
|
||||
|
||||
to_json(RpcOutcome::single_log(status, "subconscious status"))
|
||||
@@ -316,8 +164,6 @@ fn handle_trigger(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let lock = get_or_init_engine().await?;
|
||||
|
||||
// Spawn the tick in the background so the RPC returns immediately.
|
||||
// The frontend can poll status/log to see in_progress → final transitions.
|
||||
let lock_clone = std::sync::Arc::clone(&lock);
|
||||
tokio::spawn(async move {
|
||||
let guard = lock_clone.lock().await;
|
||||
@@ -325,9 +171,9 @@ fn handle_trigger(_params: Map<String, Value>) -> ControllerFuture {
|
||||
match engine.tick().await {
|
||||
Ok(result) => {
|
||||
tracing::info!(
|
||||
"[subconscious] manual tick: executed={} escalated={} duration={}ms",
|
||||
result.executed,
|
||||
result.escalated,
|
||||
"[subconscious] manual tick: thoughts={} thread={:?} duration={}ms",
|
||||
result.thoughts_count,
|
||||
result.thread_id,
|
||||
result.duration_ms
|
||||
);
|
||||
}
|
||||
@@ -345,173 +191,6 @@ fn handle_trigger(_params: Map<String, Value>) -> ControllerFuture {
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_tasks_list(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let enabled_only = params
|
||||
.get("enabled_only")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let config = load_config().await?;
|
||||
let tasks = store::with_connection(&config.workspace_dir, |conn| {
|
||||
store::list_tasks(conn, enabled_only)
|
||||
})
|
||||
.map_err(|e| format!("{e:#}"))?;
|
||||
to_json(RpcOutcome::single_log(tasks, "tasks listed"))
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_tasks_add(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let title = params
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("title is required")?
|
||||
.to_string();
|
||||
let source = match params.get("source").and_then(|v| v.as_str()) {
|
||||
Some("system") => TaskSource::System,
|
||||
_ => TaskSource::User,
|
||||
};
|
||||
let lock = get_or_init_engine().await?;
|
||||
let guard = lock.lock().await;
|
||||
let engine = guard.as_ref().ok_or("engine not initialized")?;
|
||||
let task = engine
|
||||
.add_task(&title, source)
|
||||
.await
|
||||
.map_err(|e| format!("{e:#}"))?;
|
||||
to_json(RpcOutcome::single_log(task, "task added"))
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_tasks_update(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let task_id = params
|
||||
.get("task_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("task_id is required")?
|
||||
.to_string();
|
||||
let patch = TaskPatch {
|
||||
title: params
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
recurrence: params.get("recurrence").and_then(|v| v.as_str()).map(|s| {
|
||||
if s == "once" {
|
||||
TaskRecurrence::Once
|
||||
} else if let Some(expr) = s.strip_prefix("cron:") {
|
||||
TaskRecurrence::Cron(expr.to_string())
|
||||
} else {
|
||||
TaskRecurrence::Pending
|
||||
}
|
||||
}),
|
||||
enabled: params.get("enabled").and_then(|v| v.as_bool()),
|
||||
};
|
||||
let config = load_config().await?;
|
||||
store::with_connection(&config.workspace_dir, |conn| {
|
||||
store::update_task(conn, &task_id, &patch)
|
||||
})
|
||||
.map_err(|e| format!("{e:#}"))?;
|
||||
to_json(RpcOutcome::single_log(
|
||||
serde_json::json!({"updated": task_id}),
|
||||
"task updated",
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_tasks_remove(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let task_id = params
|
||||
.get("task_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("task_id is required")?
|
||||
.to_string();
|
||||
let config = load_config().await?;
|
||||
store::with_connection(&config.workspace_dir, |conn| {
|
||||
store::remove_task(conn, &task_id)
|
||||
})
|
||||
.map_err(|e| format!("{e:#}"))?;
|
||||
to_json(RpcOutcome::single_log(
|
||||
serde_json::json!({"removed": task_id}),
|
||||
"task removed",
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_log_list(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let task_id = params.get("task_id").and_then(|v| v.as_str());
|
||||
let limit = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
|
||||
let config = load_config().await?;
|
||||
let entries = store::with_connection(&config.workspace_dir, |conn| {
|
||||
store::list_log_entries(conn, task_id, limit)
|
||||
})
|
||||
.map_err(|e| format!("{e:#}"))?;
|
||||
to_json(RpcOutcome::single_log(entries, "log entries listed"))
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_escalations_list(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let status_filter = params
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| match s {
|
||||
"approved" => EscalationStatus::Approved,
|
||||
"dismissed" => EscalationStatus::Dismissed,
|
||||
_ => EscalationStatus::Pending,
|
||||
});
|
||||
let config = load_config().await?;
|
||||
let escalations = store::with_connection(&config.workspace_dir, |conn| {
|
||||
store::list_escalations(conn, status_filter.as_ref())
|
||||
})
|
||||
.map_err(|e| format!("{e:#}"))?;
|
||||
to_json(RpcOutcome::single_log(escalations, "escalations listed"))
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_escalations_approve(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let escalation_id = params
|
||||
.get("escalation_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("escalation_id is required")?
|
||||
.to_string();
|
||||
let lock = get_or_init_engine().await?;
|
||||
let guard = lock.lock().await;
|
||||
let engine = guard.as_ref().ok_or("engine not initialized")?;
|
||||
engine
|
||||
.approve_escalation(&escalation_id)
|
||||
.await
|
||||
.map_err(|e| format!("{e:#}"))?;
|
||||
to_json(RpcOutcome::single_log(
|
||||
serde_json::json!({"approved": escalation_id}),
|
||||
"escalation approved and executed",
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_escalations_dismiss(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let escalation_id = params
|
||||
.get("escalation_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("escalation_id is required")?
|
||||
.to_string();
|
||||
let lock = get_or_init_engine().await?;
|
||||
let guard = lock.lock().await;
|
||||
let engine = guard.as_ref().ok_or("engine not initialized")?;
|
||||
engine
|
||||
.dismiss_escalation(&escalation_id)
|
||||
.await
|
||||
.map_err(|e| format!("{e:#}"))?;
|
||||
to_json(RpcOutcome::single_log(
|
||||
serde_json::json!({"dismissed": escalation_id}),
|
||||
"escalation dismissed",
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
// ── #623: proactive reflection handlers ──────────────────────────────────────
|
||||
|
||||
fn handle_reflections_list(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let limit = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
|
||||
@@ -540,10 +219,6 @@ fn handle_reflections_act(params: Map<String, Value>) -> ControllerFuture {
|
||||
.map_err(|e| format!("{e:#}"))?
|
||||
.ok_or_else(|| format!("reflection not found: {reflection_id}"))?;
|
||||
|
||||
// Spawn a fresh conversation thread for this action. Reflections never
|
||||
// write into the user's existing threads — each act gets its own
|
||||
// chat so the active conversation stays uncluttered. Title is the
|
||||
// first ~60 chars of the body so it's recognisable in the thread list.
|
||||
let thread_id = uuid::Uuid::new_v4().to_string();
|
||||
let thread_title: String = {
|
||||
let mut s: String = reflection
|
||||
@@ -578,14 +253,6 @@ fn handle_reflections_act(params: Map<String, Value>) -> ControllerFuture {
|
||||
)
|
||||
.map_err(|e| format!("ensure_thread (reflection-spawned) failed: {e}"))?;
|
||||
|
||||
// Seed the new thread with the reflection as the FIRST message,
|
||||
// sent from `assistant` (i.e. OpenHuman speaking). The frontend
|
||||
// renders this as a regular AI message, so the user lands in a
|
||||
// thread that already starts with the observation. They can then
|
||||
// type their own reply — no auto LLM turn fires here. This is
|
||||
// distinct from `start_chat`, which would have appended the
|
||||
// reflection as a USER message and immediately triggered an
|
||||
// orchestrator response.
|
||||
let body_md = match reflection.proposed_action.as_deref() {
|
||||
Some(action) if !action.trim().is_empty() => format!(
|
||||
"{body}\n\n_Proposed action_: {action}",
|
||||
@@ -616,12 +283,6 @@ fn handle_reflections_act(params: Map<String, Value>) -> ControllerFuture {
|
||||
)
|
||||
.map_err(|e| format!("append seed reflection message failed: {e}"))?;
|
||||
|
||||
// Stamp acted_on_at on success. If the stamp write fails, log a
|
||||
// warning — the new thread already exists, so a silent failure
|
||||
// here would leave the reflection unmarked and the user could
|
||||
// re-Act on the same card and spawn a duplicate thread. The
|
||||
// reflection itself is still actionable from the user's
|
||||
// perspective, so we don't want to fail the whole call.
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs_f64())
|
||||
@@ -630,7 +291,7 @@ fn handle_reflections_act(params: Map<String, Value>) -> ControllerFuture {
|
||||
reflection_store::mark_acted(conn, &reflection_id, now)
|
||||
}) {
|
||||
log::warn!(
|
||||
"[subconscious] failed to stamp acted_on_at reflection={} thread={}: {e} — reflection card will reappear and a re-Act would spawn a duplicate thread",
|
||||
"[subconscious] failed to stamp acted_on_at reflection={} thread={}: {e}",
|
||||
reflection_id,
|
||||
thread_id
|
||||
);
|
||||
@@ -672,14 +333,6 @@ fn handle_reflections_dismiss(params: Map<String, Value>) -> ControllerFuture {
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn load_config() -> Result<crate::openhuman::config::Config, String> {
|
||||
// Use the same 30s-bounded loader every other JSON-RPC domain uses
|
||||
// (see cron/schemas.rs, webhooks/schemas.rs, etc.). Raw
|
||||
// `Config::load_or_init()` can stall on `SecretStore::new` plus a chain
|
||||
// of `decrypt_optional_secret` calls that may IPC to an OS keychain,
|
||||
// so the subconscious handlers used to be the only unbounded outlier
|
||||
// in the entire JSON-RPC surface. Under the Intelligence page's 3s
|
||||
// poll that chokepoint let a slow keychain call pin the frontend's
|
||||
// `Promise.all` and freeze the activity log on a stale snapshot.
|
||||
crate::openhuman::config::load_config_with_timeout().await
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn all_schemas_returns_thirteen() {
|
||||
// 10 task/escalation schemas + 3 reflection schemas (#623).
|
||||
assert_eq!(all_controller_schemas().len(), 13);
|
||||
fn all_schemas_returns_five() {
|
||||
assert_eq!(all_controller_schemas().len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_controllers_returns_thirteen() {
|
||||
assert_eq!(all_registered_controllers().len(), 13);
|
||||
fn all_controllers_returns_five() {
|
||||
assert_eq!(all_registered_controllers().len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -23,233 +22,22 @@ fn reflection_rpcs_are_registered() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_use_subconscious_namespace() {
|
||||
for s in all_controller_schemas() {
|
||||
assert_eq!(s.namespace, "subconscious");
|
||||
assert!(!s.description.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_controllers_match() {
|
||||
let s = all_controller_schemas();
|
||||
let c = all_registered_controllers();
|
||||
for (schema, ctrl) in s.iter().zip(c.iter()) {
|
||||
assert_eq!(schema.function, ctrl.schema.function);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_functions_resolve() {
|
||||
for fn_name in [
|
||||
"status",
|
||||
"trigger",
|
||||
"tasks_list",
|
||||
"tasks_add",
|
||||
"tasks_update",
|
||||
"tasks_remove",
|
||||
"log_list",
|
||||
"escalations_list",
|
||||
"escalations_approve",
|
||||
"escalations_dismiss",
|
||||
] {
|
||||
let s = schemas(fn_name);
|
||||
assert_ne!(s.function, "unknown", "{fn_name} fell through");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_function_returns_unknown() {
|
||||
let s = schemas("nonexistent");
|
||||
assert_eq!(s.function, "unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_schema_has_no_inputs() {
|
||||
assert!(schemas("status").inputs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trigger_schema_has_no_inputs() {
|
||||
assert!(schemas("trigger").inputs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tasks_add_requires_title() {
|
||||
let s = schemas("tasks_add");
|
||||
let required: Vec<&str> = s
|
||||
.inputs
|
||||
fn status_and_trigger_are_registered() {
|
||||
let names: Vec<&str> = all_controller_schemas()
|
||||
.iter()
|
||||
.filter(|f| f.required)
|
||||
.map(|f| f.name)
|
||||
.map(|s| s.function)
|
||||
.collect();
|
||||
assert!(required.contains(&"title"));
|
||||
assert!(names.contains(&"status"));
|
||||
assert!(names.contains(&"trigger"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tasks_update_requires_task_id() {
|
||||
let s = schemas("tasks_update");
|
||||
let required: Vec<&str> = s
|
||||
.inputs
|
||||
fn task_endpoints_are_removed() {
|
||||
let names: Vec<&str> = all_controller_schemas()
|
||||
.iter()
|
||||
.filter(|f| f.required)
|
||||
.map(|f| f.name)
|
||||
.map(|s| s.function)
|
||||
.collect();
|
||||
assert!(required.contains(&"task_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tasks_remove_requires_task_id() {
|
||||
let s = schemas("tasks_remove");
|
||||
let required: Vec<&str> = s
|
||||
.inputs
|
||||
.iter()
|
||||
.filter(|f| f.required)
|
||||
.map(|f| f.name)
|
||||
.collect();
|
||||
assert!(required.contains(&"task_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escalations_approve_requires_escalation_id() {
|
||||
let s = schemas("escalations_approve");
|
||||
assert!(s
|
||||
.inputs
|
||||
.iter()
|
||||
.any(|f| f.name == "escalation_id" && f.required));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escalations_dismiss_requires_escalation_id() {
|
||||
let s = schemas("escalations_dismiss");
|
||||
assert!(s
|
||||
.inputs
|
||||
.iter()
|
||||
.any(|f| f.name == "escalation_id" && f.required));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_list_has_optional_inputs() {
|
||||
let s = schemas("log_list");
|
||||
for input in &s.inputs {
|
||||
assert!(
|
||||
!input.required,
|
||||
"log_list input '{}' should be optional",
|
||||
input.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tasks_list_has_optional_enabled_only() {
|
||||
let s = schemas("tasks_list");
|
||||
let enabled = s.inputs.iter().find(|f| f.name == "enabled_only");
|
||||
assert!(enabled.is_some_and(|f| !f.required));
|
||||
}
|
||||
|
||||
// ── Field helpers ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn field_helper_is_required() {
|
||||
let f = field("name", TypeSchema::String, "desc");
|
||||
assert!(f.required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_req_helper_is_required() {
|
||||
let f = field_req("name", TypeSchema::String, "desc");
|
||||
assert!(f.required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_opt_helper_is_not_required() {
|
||||
let f = field_opt("name", TypeSchema::String, "desc");
|
||||
assert!(!f.required);
|
||||
}
|
||||
|
||||
// ── Error chain preservation ───────────────────────────────────
|
||||
//
|
||||
// The RPC handlers in this module bridge `anyhow::Result` (from
|
||||
// `store::with_connection` and the wrapped rusqlite errors) into the
|
||||
// JSON-RPC `Result<Value, String>` boundary via `map_err(|e| ...)`.
|
||||
//
|
||||
// **Critical for observability**: plain `e.to_string()` on an
|
||||
// `anyhow::Error` returns ONLY the outermost context. For a
|
||||
// `with_connection` failure the outer wrap is
|
||||
// `"failed to run subconscious schema DDL"` — the underlying rusqlite
|
||||
// root (the actual SQLite error code + message) is dropped. That
|
||||
// stringified message is what `jsonrpc::invoke_method_inner` later
|
||||
// passes to `report_error_or_expected`, which in turn captures it in
|
||||
// Sentry. Without the chain, Sentry events for TAURI-RUST-A only
|
||||
// surface the generic wrapper text and the rusqlite root cause is
|
||||
// permanently invisible.
|
||||
//
|
||||
// All `map_err` sites in `schemas.rs` use `format!("{e:#}")` (anyhow's
|
||||
// alternate Display walks the cause chain inline joined by `": "`) so
|
||||
// the rusqlite root reaches Sentry. These guard tests pin the format
|
||||
// so future contributors don't silently regress to `e.to_string()`.
|
||||
#[test]
|
||||
fn anyhow_alternate_display_walks_chain() {
|
||||
use anyhow::Context;
|
||||
|
||||
let inner = anyhow::anyhow!("database is locked").context("execute_batch failed");
|
||||
let outer: anyhow::Result<()> = Err(inner).context("failed to run subconscious schema DDL");
|
||||
|
||||
let err = outer.unwrap_err();
|
||||
|
||||
// Plain to_string() — the broken (pre-fix) shape. Only outer
|
||||
// wrapper reaches the caller, root cause lost.
|
||||
let lossy = err.to_string();
|
||||
assert_eq!(lossy, "failed to run subconscious schema DDL");
|
||||
assert!(
|
||||
!lossy.contains("database is locked"),
|
||||
"plain Display must drop the root cause — if this changes the chain-formatter \
|
||||
is no longer load-bearing, revisit observability assumptions"
|
||||
);
|
||||
|
||||
// Alternate Display — what schemas.rs map_err now produces. Every
|
||||
// layer joined by ": " so the rusqlite root reaches Sentry.
|
||||
let full = format!("{err:#}");
|
||||
assert!(
|
||||
full.contains("failed to run subconscious schema DDL"),
|
||||
"chain-formatted message must include outer wrapper, got: {full}"
|
||||
);
|
||||
assert!(
|
||||
full.contains("execute_batch failed"),
|
||||
"chain-formatted message must include middle context, got: {full}"
|
||||
);
|
||||
assert!(
|
||||
full.contains("database is locked"),
|
||||
"chain-formatted message must include the rusqlite root, got: {full}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anyhow_alternate_display_includes_rusqlite_error_chain() {
|
||||
use anyhow::Context;
|
||||
|
||||
// Simulate the exact shape produced by `with_connection`:
|
||||
// a real rusqlite Error wrapped in `with_context(...)`.
|
||||
let raw = rusqlite::Error::SqliteFailure(
|
||||
rusqlite::ffi::Error {
|
||||
code: rusqlite::ErrorCode::DatabaseBusy,
|
||||
extended_code: 5,
|
||||
},
|
||||
Some("database is locked".into()),
|
||||
);
|
||||
let wrapped: anyhow::Result<()> =
|
||||
Err(anyhow::Error::from(raw)).context("failed to run subconscious schema DDL");
|
||||
|
||||
let err = wrapped.unwrap_err();
|
||||
let chained = format!("{err:#}");
|
||||
|
||||
// Outer wrapper preserved.
|
||||
assert!(chained.contains("failed to run subconscious schema DDL"));
|
||||
// rusqlite-rendered root preserved — this is the signal Sentry
|
||||
// needs to distinguish a DDL lock-race from a corruption / disk-full
|
||||
// / permission failure. Without it, all four fingerprint identically.
|
||||
assert!(
|
||||
chained.contains("database is locked"),
|
||||
"rusqlite root must appear in chain-formatted message, got: {chained}"
|
||||
);
|
||||
assert!(!names.contains(&"tasks_list"));
|
||||
assert!(!names.contains(&"tasks_add"));
|
||||
assert!(!names.contains(&"escalations_list"));
|
||||
}
|
||||
|
||||
@@ -139,24 +139,8 @@ fn build_identifiers_section() -> String {
|
||||
out
|
||||
}
|
||||
|
||||
fn build_tasks_section(workspace_dir: &Path) -> String {
|
||||
use std::fmt::Write;
|
||||
let tasks = match super::store::with_connection(workspace_dir, |conn| {
|
||||
super::store::list_tasks(conn, false)
|
||||
}) {
|
||||
Ok(tasks) => tasks,
|
||||
Err(_) => return "## Pending Tasks\n\nFailed to read tasks.\n".to_string(),
|
||||
};
|
||||
|
||||
if tasks.is_empty() {
|
||||
return "## Pending Tasks\n\nNo tasks defined.\n".to_string();
|
||||
}
|
||||
|
||||
let mut section = String::from("## Pending Tasks\n\n");
|
||||
for task in &tasks {
|
||||
let _ = writeln!(section, "- {}", task.title);
|
||||
}
|
||||
section
|
||||
fn build_tasks_section(_workspace_dir: &Path) -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Append a section, truncating at a UTF-8 char boundary if it overflows
|
||||
|
||||
@@ -68,6 +68,7 @@ mod tests {
|
||||
id.into(),
|
||||
1.0,
|
||||
Vec::new(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,59 +1,24 @@
|
||||
//! SQLite persistence for subconscious tasks, execution log, and escalations.
|
||||
//! SQLite persistence for the subconscious engine.
|
||||
//!
|
||||
//! Follows the cron module's `with_connection` pattern: opens the database,
|
||||
//! runs DDL on every connection, and provides pure functions.
|
||||
//!
|
||||
//! ## Init-failure noise suppression (TAURI-RUST-A)
|
||||
//!
|
||||
//! `with_connection` runs the schema DDL on every call. Transient
|
||||
//! `SQLITE_BUSY` / `SQLITE_LOCKED` errors are handled by a per-connection
|
||||
//! busy timeout (5 s) plus an application-level retry loop (3 retries,
|
||||
//! 100 / 300 / 900 ms backoff).
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::types::{
|
||||
Escalation, EscalationPriority, EscalationStatus, SubconsciousLogEntry, SubconsciousTask,
|
||||
TaskPatch, TaskRecurrence, TaskSource,
|
||||
};
|
||||
|
||||
/// Per-connection busy handler window. Tracks the value used by the cron
|
||||
/// module + other domain stores (`memory_store::unified::init`, 15s) and
|
||||
/// the higher-throughput whatsapp/memory_queue path (5s). 5 s is enough
|
||||
/// for the subconscious tick — RPC handlers are user-driven (status
|
||||
/// polling at 3 s, manual triggers) and we'd rather fail fast than block
|
||||
/// the UI thread for the full 15 s of contention.
|
||||
const BUSY_TIMEOUT: Duration = Duration::from_millis(5000);
|
||||
|
||||
/// Maximum number of application-level retries after rusqlite's busy
|
||||
/// handler is exhausted. The first attempt is "attempt 0" — total
|
||||
/// attempts = `OPEN_RETRY_ATTEMPTS` + 1.
|
||||
const OPEN_RETRY_ATTEMPTS: u32 = 3;
|
||||
|
||||
/// Base backoff for application-level retries; per-attempt sleep is
|
||||
/// `BASE * 3^attempt` so the schedule is `100 ms / 300 ms / 900 ms`
|
||||
/// totalling ≤ 1.3 s before the final attempt fails through.
|
||||
const OPEN_RETRY_BASE_MS: u64 = 100;
|
||||
|
||||
/// Open the subconscious database and run schema migrations.
|
||||
///
|
||||
/// Three layers of defence against transient `SQLITE_BUSY` / `SQLITE_LOCKED`
|
||||
/// at the open / DDL boundary, motivated by Sentry TAURI-RUST-A
|
||||
/// (cross-platform, ~1.3k events / 24 h, RPC paths `subconscious_tasks_list`
|
||||
/// and `subconscious_status`):
|
||||
///
|
||||
/// 1. **Per-connection busy timeout** (`BUSY_TIMEOUT`, 5 s): SQLite's
|
||||
/// default is `0` — first lock contention returns `SQLITE_BUSY`
|
||||
/// immediately. The subconscious domain serialises several RPCs
|
||||
/// (status poll every 3 s, tasks-list on Intelligence page, manual
|
||||
/// trigger), each opening its own connection; without a timeout the
|
||||
/// first concurrent open races and one returns `SQLITE_BUSY` mid-DDL.
|
||||
/// 2. **Application-level retry** (3 attempts, exponential backoff
|
||||
/// 100 / 300 / 900 ms): catches the residual case where the busy
|
||||
/// handler is exhausted (long-running external write txn, AV scan
|
||||
/// holding the file). Mirrors `whatsapp_data::sqlite_retry` /
|
||||
/// `memory_queue::worker::is_sqlite_busy`.
|
||||
/// 3. **Retry classifier** (`is_sqlite_busy`): only retries
|
||||
/// `DatabaseBusy` / `DatabaseLocked`. Schema / syntax / corruption
|
||||
/// errors are real bugs or unrecoverable file-state failures —
|
||||
/// retrying just delays the report.
|
||||
pub fn with_connection<T>(
|
||||
workspace_dir: &Path,
|
||||
f: impl FnOnce(&Connection) -> Result<T>,
|
||||
@@ -68,11 +33,6 @@ pub fn with_connection<T>(
|
||||
f(&conn)
|
||||
}
|
||||
|
||||
/// Open the SQLite file, set `busy_timeout`, run `SCHEMA_DDL`, and apply
|
||||
/// the idempotent reflection-store migrations — retrying the whole
|
||||
/// sequence on `SQLITE_BUSY` / `SQLITE_LOCKED`. Split out so the retry
|
||||
/// loop has a single failure surface to classify and the happy path
|
||||
/// stays linear.
|
||||
fn open_and_initialize_with_retry(db_path: &Path) -> Result<Connection> {
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
|
||||
@@ -114,43 +74,23 @@ fn open_and_initialize_with_retry(db_path: &Path) -> Result<Connection> {
|
||||
Err(last_err.expect("OPEN_RETRY_ATTEMPTS >= 0 ensures at least one attempt"))
|
||||
}
|
||||
|
||||
/// Single-shot open + DDL + migrations. Each invocation returns an
|
||||
/// owned `Connection`; on failure the partially-initialised connection
|
||||
/// is dropped before the caller retries.
|
||||
fn open_and_initialize(db_path: &Path) -> Result<Connection> {
|
||||
let conn = Connection::open(db_path)
|
||||
.with_context(|| format!("failed to open subconscious DB: {}", db_path.display()))?;
|
||||
|
||||
// Set busy_timeout BEFORE running DDL — the very first PRAGMA / CREATE
|
||||
// TABLE in SCHEMA_DDL can race with another in-process connection
|
||||
// (subconscious RPCs each call `with_connection` independently), and
|
||||
// SQLite's default busy_timeout is 0.
|
||||
conn.busy_timeout(BUSY_TIMEOUT)
|
||||
.context("configure subconscious busy_timeout")?;
|
||||
|
||||
conn.execute_batch(SCHEMA_DDL)
|
||||
.context("failed to run subconscious schema DDL")?;
|
||||
|
||||
// Drop the legacy `disposition` / `surfaced_at` columns + their index
|
||||
// from previously-migrated DBs. Idempotent — fresh installs and
|
||||
// already-migrated DBs no-op via swallowed errors.
|
||||
super::reflection_store::migrate_drop_legacy_columns(&conn);
|
||||
|
||||
// Add the `source_chunks` JSON column to previously-migrated DBs.
|
||||
// Idempotent (duplicate-column errors swallowed).
|
||||
super::reflection_store::migrate_add_source_chunks_column(&conn);
|
||||
super::reflection_store::migrate_add_thread_id_column(&conn);
|
||||
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Returns true when `err` is transient SQLite contention worth retrying
|
||||
/// (`SQLITE_BUSY` / `SQLITE_LOCKED`). Schema / syntax / corruption errors
|
||||
/// are NOT retried — the retry would just delay the same failure.
|
||||
///
|
||||
/// Modelled on [`crate::openhuman::memory_queue::worker::is_sqlite_busy`]
|
||||
/// and [`crate::openhuman::whatsapp_data::sqlite_retry::is_sqlite_busy`];
|
||||
/// kept private to the subconscious store so the retry policy can evolve
|
||||
/// independently of those sibling domains.
|
||||
fn is_sqlite_busy(err: &anyhow::Error) -> bool {
|
||||
if let Some(rusqlite::Error::SqliteFailure(sqlite_err, _)) =
|
||||
err.downcast_ref::<rusqlite::Error>()
|
||||
@@ -160,11 +100,6 @@ fn is_sqlite_busy(err: &anyhow::Error) -> bool {
|
||||
rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
|
||||
);
|
||||
}
|
||||
// Fallback for errors wrapped under `.context(...)` layers — the
|
||||
// rusqlite root may sit a few levels deep after `with_context`
|
||||
// wraps the open / DDL failure. anyhow's alternate Display joins
|
||||
// every cause with ": " so the SQLite-rendered phrase remains
|
||||
// searchable.
|
||||
let msg = format!("{err:#}").to_ascii_lowercase();
|
||||
msg.contains("database is locked") || msg.contains("database table is locked")
|
||||
}
|
||||
@@ -173,6 +108,8 @@ const SCHEMA_DDL: &str = "
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA journal_mode = WAL;
|
||||
|
||||
-- Legacy tables retained for backward compatibility with existing DBs.
|
||||
-- No longer written to or read from.
|
||||
CREATE TABLE IF NOT EXISTS subconscious_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
@@ -184,11 +121,6 @@ const SCHEMA_DDL: &str = "
|
||||
completed INTEGER NOT NULL DEFAULT 0,
|
||||
created_at REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_next_run
|
||||
ON subconscious_tasks(next_run_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_enabled
|
||||
ON subconscious_tasks(enabled, completed);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subconscious_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
@@ -198,11 +130,6 @@ const SCHEMA_DDL: &str = "
|
||||
duration_ms INTEGER,
|
||||
created_at REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_log_task
|
||||
ON subconscious_log(task_id, tick_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_log_tick
|
||||
ON subconscious_log(tick_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subconscious_escalations (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
@@ -214,18 +141,7 @@ const SCHEMA_DDL: &str = "
|
||||
created_at REAL NOT NULL,
|
||||
resolved_at REAL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_escalations_status
|
||||
ON subconscious_escalations(status);
|
||||
|
||||
-- #623: reflection layer (proactive subconscious). Mirrored in
|
||||
-- `super::reflection_store::REFLECTION_SCHEMA_DDL` for the unit
|
||||
-- tests there. Legacy `disposition` / `surfaced_at` columns +
|
||||
-- their index were removed when the auto-post-into-thread flow
|
||||
-- was dropped — `migrate_drop_legacy_columns` cleans them off
|
||||
-- previously-migrated DBs. The `source_chunks` JSON column was
|
||||
-- added later for the memory-context snapshot feature —
|
||||
-- `migrate_add_source_chunks_column` backfills previously-migrated
|
||||
-- DBs that pre-date it.
|
||||
CREATE TABLE IF NOT EXISTS subconscious_reflections (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
@@ -235,7 +151,8 @@ const SCHEMA_DDL: &str = "
|
||||
source_chunks TEXT NOT NULL DEFAULT '[]',
|
||||
created_at REAL NOT NULL,
|
||||
acted_on_at REAL,
|
||||
dismissed_at REAL
|
||||
dismissed_at REAL,
|
||||
thread_id TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reflections_created
|
||||
ON subconscious_reflections(created_at DESC);
|
||||
@@ -246,495 +163,19 @@ const SCHEMA_DDL: &str = "
|
||||
captured_at REAL NOT NULL
|
||||
);
|
||||
|
||||
-- Tiny KV table for engine-local state that needs to survive
|
||||
-- process restarts. Currently holds:
|
||||
-- * `last_tick_at` — unix-seconds float of the most recent
|
||||
-- successful tick. Used by the situation-
|
||||
-- report sections (`summaries`, `query_window`,
|
||||
-- `digest`) as a `WHERE sealed_at_ms > ?` cutoff
|
||||
-- so the LLM only sees memory-tree rows that
|
||||
-- have appeared since it last looked. Without
|
||||
-- persistence the cutoff resets to 0 on every
|
||||
-- restart, the LLM keeps reading the same
|
||||
-- summaries, and `persist_and_surface_reflections`
|
||||
-- (which has no insert-time dedupe) accumulates
|
||||
-- near-duplicate reflections about the same
|
||||
-- chunks (#623).
|
||||
CREATE TABLE IF NOT EXISTS subconscious_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value REAL NOT NULL
|
||||
);
|
||||
";
|
||||
|
||||
/// Test-only re-export of [`SCHEMA_DDL`] for unit tests in sibling
|
||||
/// modules (e.g. `reflection_store_tests`) that need to spin up an
|
||||
/// in-memory connection with the full schema.
|
||||
#[cfg(test)]
|
||||
pub(crate) const SCHEMA_DDL_FOR_TESTS: &str = SCHEMA_DDL;
|
||||
|
||||
// ── Task CRUD ────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn add_task(
|
||||
conn: &Connection,
|
||||
title: &str,
|
||||
source: TaskSource,
|
||||
recurrence: TaskRecurrence,
|
||||
) -> Result<SubconsciousTask> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = now_secs();
|
||||
let source_str = serde_json::to_value(&source)
|
||||
.unwrap_or_default()
|
||||
.as_str()
|
||||
.unwrap_or("user")
|
||||
.to_string();
|
||||
let recurrence_str = recurrence_to_string(&recurrence);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO subconscious_tasks (id, title, source, recurrence, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
rusqlite::params![id, title, source_str, recurrence_str, now],
|
||||
)?;
|
||||
|
||||
Ok(SubconsciousTask {
|
||||
id,
|
||||
title: title.to_string(),
|
||||
source,
|
||||
recurrence,
|
||||
enabled: true,
|
||||
last_run_at: None,
|
||||
next_run_at: None,
|
||||
completed: false,
|
||||
created_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_task(conn: &Connection, task_id: &str) -> Result<SubconsciousTask> {
|
||||
conn.query_row(
|
||||
"SELECT id, title, source, recurrence, enabled, last_run_at, next_run_at, completed, created_at
|
||||
FROM subconscious_tasks WHERE id = ?1",
|
||||
[task_id],
|
||||
row_to_task,
|
||||
)
|
||||
.with_context(|| format!("task not found: {task_id}"))
|
||||
}
|
||||
|
||||
pub fn list_tasks(conn: &Connection, enabled_only: bool) -> Result<Vec<SubconsciousTask>> {
|
||||
let sql = if enabled_only {
|
||||
"SELECT id, title, source, recurrence, enabled, last_run_at, next_run_at, completed, created_at
|
||||
FROM subconscious_tasks WHERE enabled = 1 ORDER BY created_at"
|
||||
} else {
|
||||
"SELECT id, title, source, recurrence, enabled, last_run_at, next_run_at, completed, created_at
|
||||
FROM subconscious_tasks ORDER BY created_at"
|
||||
};
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let tasks = stmt
|
||||
.query_map([], row_to_task)?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(tasks)
|
||||
}
|
||||
|
||||
pub fn update_task(conn: &Connection, task_id: &str, patch: &TaskPatch) -> Result<()> {
|
||||
if let Some(ref title) = patch.title {
|
||||
conn.execute(
|
||||
"UPDATE subconscious_tasks SET title = ?1 WHERE id = ?2",
|
||||
rusqlite::params![title, task_id],
|
||||
)?;
|
||||
}
|
||||
if let Some(ref recurrence) = patch.recurrence {
|
||||
conn.execute(
|
||||
"UPDATE subconscious_tasks SET recurrence = ?1 WHERE id = ?2",
|
||||
rusqlite::params![recurrence_to_string(recurrence), task_id],
|
||||
)?;
|
||||
}
|
||||
if let Some(enabled) = patch.enabled {
|
||||
conn.execute(
|
||||
"UPDATE subconscious_tasks SET enabled = ?1 WHERE id = ?2",
|
||||
rusqlite::params![enabled, task_id],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a task. System tasks cannot be deleted — only disabled.
|
||||
pub fn remove_task(conn: &Connection, task_id: &str) -> Result<()> {
|
||||
let source: String = conn
|
||||
.query_row(
|
||||
"SELECT source FROM subconscious_tasks WHERE id = ?1",
|
||||
[task_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.with_context(|| format!("task not found: {task_id}"))?;
|
||||
|
||||
if source == "system" {
|
||||
anyhow::bail!("System tasks cannot be deleted. Disable them instead.");
|
||||
}
|
||||
|
||||
conn.execute("DELETE FROM subconscious_tasks WHERE id = ?1", [task_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get tasks that are due for evaluation (enabled, not completed, due now or never run).
|
||||
pub fn due_tasks(conn: &Connection, now: f64) -> Result<Vec<SubconsciousTask>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, title, source, recurrence, enabled, last_run_at, next_run_at, completed, created_at
|
||||
FROM subconscious_tasks
|
||||
WHERE enabled = 1 AND completed = 0
|
||||
AND (next_run_at IS NULL OR next_run_at <= ?1)
|
||||
ORDER BY next_run_at NULLS FIRST",
|
||||
)?;
|
||||
let tasks = stmt
|
||||
.query_map([now], row_to_task)?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(tasks)
|
||||
}
|
||||
|
||||
pub fn mark_task_completed(conn: &Connection, task_id: &str) -> Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE subconscious_tasks SET completed = 1 WHERE id = ?1",
|
||||
[task_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_task_run_times(
|
||||
conn: &Connection,
|
||||
task_id: &str,
|
||||
last_run_at: f64,
|
||||
next_run_at: Option<f64>,
|
||||
) -> Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE subconscious_tasks SET last_run_at = ?1, next_run_at = ?2 WHERE id = ?3",
|
||||
rusqlite::params![last_run_at, next_run_at, task_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn task_count(conn: &Connection) -> Result<u64> {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM subconscious_tasks WHERE enabled = 1 AND completed = 0",
|
||||
[],
|
||||
|row| row.get::<_, u64>(0),
|
||||
)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
// ── Log CRUD ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn add_log_entry(
|
||||
conn: &Connection,
|
||||
task_id: &str,
|
||||
tick_at: f64,
|
||||
decision: &str,
|
||||
result: Option<&str>,
|
||||
duration_ms: Option<i64>,
|
||||
) -> Result<SubconsciousLogEntry> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = now_secs();
|
||||
conn.execute(
|
||||
"INSERT INTO subconscious_log (id, task_id, tick_at, decision, result, duration_ms, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
rusqlite::params![id, task_id, tick_at, decision, result, duration_ms, now],
|
||||
)?;
|
||||
Ok(SubconsciousLogEntry {
|
||||
id,
|
||||
task_id: task_id.to_string(),
|
||||
tick_at,
|
||||
decision: decision.to_string(),
|
||||
result: result.map(String::from),
|
||||
duration_ms,
|
||||
created_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
/// Update an existing log entry's decision, result, and duration in place.
|
||||
pub fn update_log_entry(
|
||||
conn: &Connection,
|
||||
log_id: &str,
|
||||
decision: &str,
|
||||
result: Option<&str>,
|
||||
duration_ms: Option<i64>,
|
||||
) -> Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE subconscious_log SET decision = ?1, result = ?2, duration_ms = ?3 WHERE id = ?4",
|
||||
rusqlite::params![decision, result, duration_ms, log_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bulk-update ALL in_progress log entries to cancelled.
|
||||
/// Any entry still in_progress when a new tick starts is stale by definition.
|
||||
pub fn cancel_stale_in_progress(conn: &Connection) -> Result<usize> {
|
||||
let count = conn.execute(
|
||||
"UPDATE subconscious_log SET decision = 'cancelled', result = 'Superseded by new tick'
|
||||
WHERE decision = 'in_progress'",
|
||||
[],
|
||||
)?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub fn list_log_entries(
|
||||
conn: &Connection,
|
||||
task_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<SubconsciousLogEntry>> {
|
||||
let (sql, params): (&str, Vec<Box<dyn rusqlite::types::ToSql>>) = if let Some(tid) = task_id {
|
||||
(
|
||||
"SELECT id, task_id, tick_at, decision, result, duration_ms, created_at
|
||||
FROM subconscious_log WHERE task_id = ?1 ORDER BY tick_at DESC LIMIT ?2",
|
||||
vec![Box::new(tid.to_string()), Box::new(limit as i64)],
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"SELECT id, task_id, tick_at, decision, result, duration_ms, created_at
|
||||
FROM subconscious_log ORDER BY tick_at DESC LIMIT ?1",
|
||||
vec![Box::new(limit as i64)],
|
||||
)
|
||||
};
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let entries = stmt
|
||||
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
|
||||
Ok(SubconsciousLogEntry {
|
||||
id: row.get(0)?,
|
||||
task_id: row.get(1)?,
|
||||
tick_at: row.get(2)?,
|
||||
decision: row.get(3)?,
|
||||
result: row.get(4)?,
|
||||
duration_ms: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
// ── Escalation CRUD ──────────────────────────────────────────────────────────
|
||||
|
||||
pub fn add_escalation(
|
||||
conn: &Connection,
|
||||
task_id: &str,
|
||||
log_id: Option<&str>,
|
||||
title: &str,
|
||||
description: &str,
|
||||
priority: &EscalationPriority,
|
||||
) -> Result<Escalation> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = now_secs();
|
||||
let priority_str = serde_json::to_value(priority)
|
||||
.unwrap_or_default()
|
||||
.as_str()
|
||||
.unwrap_or("normal")
|
||||
.to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO subconscious_escalations (id, task_id, log_id, title, description, priority, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
rusqlite::params![id, task_id, log_id, title, description, priority_str, now],
|
||||
)?;
|
||||
Ok(Escalation {
|
||||
id,
|
||||
task_id: task_id.to_string(),
|
||||
log_id: log_id.map(String::from),
|
||||
title: title.to_string(),
|
||||
description: description.to_string(),
|
||||
priority: priority.clone(),
|
||||
status: EscalationStatus::Pending,
|
||||
created_at: now,
|
||||
resolved_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_escalations(
|
||||
conn: &Connection,
|
||||
status_filter: Option<&EscalationStatus>,
|
||||
) -> Result<Vec<Escalation>> {
|
||||
let (sql, params): (&str, Vec<Box<dyn rusqlite::types::ToSql>>) = if let Some(status) =
|
||||
status_filter
|
||||
{
|
||||
let status_str = serde_json::to_value(status)
|
||||
.unwrap_or_default()
|
||||
.as_str()
|
||||
.unwrap_or("pending")
|
||||
.to_string();
|
||||
(
|
||||
"SELECT id, task_id, log_id, title, description, priority, status, created_at, resolved_at
|
||||
FROM subconscious_escalations WHERE status = ?1 ORDER BY created_at DESC",
|
||||
vec![Box::new(status_str)],
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"SELECT id, task_id, log_id, title, description, priority, status, created_at, resolved_at
|
||||
FROM subconscious_escalations ORDER BY created_at DESC",
|
||||
vec![],
|
||||
)
|
||||
};
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(params.iter()), row_to_escalation)?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn resolve_escalation(
|
||||
conn: &Connection,
|
||||
escalation_id: &str,
|
||||
status: &EscalationStatus,
|
||||
) -> Result<()> {
|
||||
let now = now_secs();
|
||||
let status_str = serde_json::to_value(status)
|
||||
.unwrap_or_default()
|
||||
.as_str()
|
||||
.unwrap_or("dismissed")
|
||||
.to_string();
|
||||
conn.execute(
|
||||
"UPDATE subconscious_escalations SET status = ?1, resolved_at = ?2 WHERE id = ?3",
|
||||
rusqlite::params![status_str, now, escalation_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn pending_escalation_count(conn: &Connection) -> Result<u64> {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM subconscious_escalations WHERE status = 'pending'",
|
||||
[],
|
||||
|row| row.get::<_, u64>(0),
|
||||
)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn get_escalation(conn: &Connection, escalation_id: &str) -> Result<Escalation> {
|
||||
conn.query_row(
|
||||
"SELECT id, task_id, log_id, title, description, priority, status, created_at, resolved_at
|
||||
FROM subconscious_escalations WHERE id = ?1",
|
||||
[escalation_id],
|
||||
row_to_escalation,
|
||||
)
|
||||
.with_context(|| format!("escalation not found: {escalation_id}"))
|
||||
}
|
||||
|
||||
// ── Seed default system tasks ────────────────────────────────────────────────
|
||||
|
||||
/// Default system tasks that are always seeded and cannot be deleted.
|
||||
const DEFAULT_SYSTEM_TASKS: &[&str] = &[
|
||||
"Check connected skills for errors or disconnections",
|
||||
"Review new memory updates for actionable items",
|
||||
"Monitor system health (Ollama, memory, connections)",
|
||||
];
|
||||
|
||||
/// Seed default system tasks into SQLite.
|
||||
/// Skips tasks whose title already exists. Returns the count of newly created tasks.
|
||||
pub fn seed_default_tasks(conn: &Connection) -> Result<usize> {
|
||||
let mut count = 0;
|
||||
|
||||
for title in DEFAULT_SYSTEM_TASKS {
|
||||
if !task_title_exists(conn, title)? {
|
||||
add_task(conn, title, TaskSource::System, TaskRecurrence::Pending)?;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
fn task_title_exists(conn: &Connection, title: &str) -> Result<bool> {
|
||||
Ok(conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM subconscious_tasks WHERE title = ?1)",
|
||||
[title],
|
||||
|row| row.get(0),
|
||||
)?)
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn row_to_task(row: &rusqlite::Row) -> rusqlite::Result<SubconsciousTask> {
|
||||
let source_str: String = row.get(2)?;
|
||||
let recurrence_str: String = row.get(3)?;
|
||||
Ok(SubconsciousTask {
|
||||
id: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
source: string_to_source(&source_str),
|
||||
recurrence: string_to_recurrence(&recurrence_str),
|
||||
enabled: row.get::<_, bool>(4)?,
|
||||
last_run_at: row.get(5)?,
|
||||
next_run_at: row.get(6)?,
|
||||
completed: row.get::<_, bool>(7)?,
|
||||
created_at: row.get(8)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn row_to_escalation(row: &rusqlite::Row) -> rusqlite::Result<Escalation> {
|
||||
let priority_str: String = row.get(5)?;
|
||||
let status_str: String = row.get(6)?;
|
||||
Ok(Escalation {
|
||||
id: row.get(0)?,
|
||||
task_id: row.get(1)?,
|
||||
log_id: row.get(2)?,
|
||||
title: row.get(3)?,
|
||||
description: row.get(4)?,
|
||||
priority: string_to_priority(&priority_str),
|
||||
status: string_to_status(&status_str),
|
||||
created_at: row.get(7)?,
|
||||
resolved_at: row.get(8)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn recurrence_to_string(r: &TaskRecurrence) -> String {
|
||||
match r {
|
||||
TaskRecurrence::Once => "once".to_string(),
|
||||
TaskRecurrence::Cron(expr) => format!("cron:{expr}"),
|
||||
TaskRecurrence::Pending => "pending".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn string_to_recurrence(s: &str) -> TaskRecurrence {
|
||||
if s == "once" {
|
||||
TaskRecurrence::Once
|
||||
} else if let Some(expr) = s.strip_prefix("cron:") {
|
||||
TaskRecurrence::Cron(expr.to_string())
|
||||
} else {
|
||||
TaskRecurrence::Pending
|
||||
}
|
||||
}
|
||||
|
||||
fn string_to_source(s: &str) -> TaskSource {
|
||||
match s {
|
||||
"system" => TaskSource::System,
|
||||
_ => TaskSource::User,
|
||||
}
|
||||
}
|
||||
|
||||
fn string_to_priority(s: &str) -> EscalationPriority {
|
||||
match s {
|
||||
"critical" => EscalationPriority::Critical,
|
||||
"important" => EscalationPriority::Important,
|
||||
_ => EscalationPriority::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
fn string_to_status(s: &str) -> EscalationStatus {
|
||||
match s {
|
||||
"approved" => EscalationStatus::Approved,
|
||||
"dismissed" => EscalationStatus::Dismissed,
|
||||
_ => EscalationStatus::Pending,
|
||||
}
|
||||
}
|
||||
|
||||
fn now_secs() -> f64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs_f64())
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
// ── Engine state KV ──────────────────────────────────────────────────────────
|
||||
|
||||
/// SQLite key for the most recent successful tick, in unix seconds.
|
||||
/// Loaded by [`SubconsciousEngine::from_heartbeat_config`] on init and
|
||||
/// updated after every successful tick. See `subconscious_state` table
|
||||
/// docstring in [`SCHEMA_DDL`] for the dedupe rationale.
|
||||
const STATE_KEY_LAST_TICK_AT: &str = "last_tick_at";
|
||||
|
||||
/// Read the persisted `last_tick_at` from `subconscious_state`. Returns
|
||||
/// `0.0` when the row is absent (cold start or fresh workspace) so the
|
||||
/// caller can treat "never ticked" identically to "first run".
|
||||
pub fn get_last_tick_at(conn: &Connection) -> Result<f64> {
|
||||
let value: Option<f64> = conn
|
||||
.query_row(
|
||||
@@ -746,9 +187,6 @@ pub fn get_last_tick_at(conn: &Connection) -> Result<f64> {
|
||||
Ok(value.unwrap_or(0.0))
|
||||
}
|
||||
|
||||
/// Persist `last_tick_at` so the next process restart picks up where
|
||||
/// this run left off. Upsert via `INSERT OR REPLACE` — the table is one
|
||||
/// row per key, so collisions are the expected case.
|
||||
pub fn set_last_tick_at(conn: &Connection, value: f64) -> Result<()> {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO subconscious_state (key, value) VALUES (?1, ?2)",
|
||||
@@ -757,22 +195,11 @@ pub fn set_last_tick_at(conn: &Connection, value: f64) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compute the next run time for a cron expression.
|
||||
/// Normalizes 5-field cron to 6-field (prepends seconds=0) for the `cron` crate.
|
||||
pub fn compute_next_run(cron_expr: &str) -> Option<f64> {
|
||||
let normalized = normalize_cron_expr(cron_expr);
|
||||
let schedule = normalized.parse::<cron::Schedule>().ok()?;
|
||||
let next = schedule.upcoming(chrono::Utc).next()?;
|
||||
Some(next.timestamp() as f64)
|
||||
}
|
||||
|
||||
fn normalize_cron_expr(expr: &str) -> String {
|
||||
let fields: Vec<&str> = expr.split_whitespace().collect();
|
||||
if fields.len() == 5 {
|
||||
format!("0 {expr}")
|
||||
} else {
|
||||
expr.to_string()
|
||||
}
|
||||
fn now_secs() -> f64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs_f64())
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -7,335 +7,30 @@ fn test_conn() -> Connection {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crud_tasks() {
|
||||
fn last_tick_at_round_trip() {
|
||||
let conn = test_conn();
|
||||
let task = add_task(&conn, "Check email", TaskSource::User, TaskRecurrence::Once).unwrap();
|
||||
assert_eq!(task.title, "Check email");
|
||||
assert!(!task.completed);
|
||||
|
||||
let fetched = get_task(&conn, &task.id).unwrap();
|
||||
assert_eq!(fetched.title, "Check email");
|
||||
|
||||
let all = list_tasks(&conn, false).unwrap();
|
||||
assert_eq!(all.len(), 1);
|
||||
|
||||
update_task(
|
||||
&conn,
|
||||
&task.id,
|
||||
&TaskPatch {
|
||||
title: Some("Check Gmail".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let updated = get_task(&conn, &task.id).unwrap();
|
||||
assert_eq!(updated.title, "Check Gmail");
|
||||
|
||||
mark_task_completed(&conn, &task.id).unwrap();
|
||||
let done = get_task(&conn, &task.id).unwrap();
|
||||
assert!(done.completed);
|
||||
|
||||
remove_task(&conn, &task.id).unwrap();
|
||||
assert!(get_task(&conn, &task.id).is_err());
|
||||
assert_eq!(get_last_tick_at(&conn).unwrap(), 0.0);
|
||||
set_last_tick_at(&conn, 12345.678).unwrap();
|
||||
assert_eq!(get_last_tick_at(&conn).unwrap(), 12345.678);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn due_tasks_filters_correctly() {
|
||||
fn last_tick_at_upsert() {
|
||||
let conn = test_conn();
|
||||
let now = now_secs();
|
||||
|
||||
// Task with no next_run_at — should be due
|
||||
add_task(
|
||||
&conn,
|
||||
"No schedule",
|
||||
TaskSource::User,
|
||||
TaskRecurrence::Pending,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Task with future next_run_at — should NOT be due
|
||||
let future_task =
|
||||
add_task(&conn, "Future task", TaskSource::User, TaskRecurrence::Once).unwrap();
|
||||
update_task_run_times(&conn, &future_task.id, now, Some(now + 3600.0)).unwrap();
|
||||
|
||||
// Task with past next_run_at — should be due
|
||||
let past_task = add_task(&conn, "Past due", TaskSource::User, TaskRecurrence::Once).unwrap();
|
||||
update_task_run_times(&conn, &past_task.id, now - 7200.0, Some(now - 3600.0)).unwrap();
|
||||
|
||||
let due = due_tasks(&conn, now).unwrap();
|
||||
assert_eq!(due.len(), 2); // "No schedule" + "Past due"
|
||||
assert!(due.iter().any(|t| t.title == "No schedule"));
|
||||
assert!(due.iter().any(|t| t.title == "Past due"));
|
||||
assert!(!due.iter().any(|t| t.title == "Future task"));
|
||||
set_last_tick_at(&conn, 1.0).unwrap();
|
||||
set_last_tick_at(&conn, 2.0).unwrap();
|
||||
assert_eq!(get_last_tick_at(&conn).unwrap(), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crud_log_entries() {
|
||||
fn schema_ddl_creates_tables() {
|
||||
let conn = test_conn();
|
||||
let task = add_task(&conn, "Test", TaskSource::User, TaskRecurrence::Once).unwrap();
|
||||
let now = now_secs();
|
||||
|
||||
let entry = add_log_entry(
|
||||
&conn,
|
||||
&task.id,
|
||||
now,
|
||||
"act",
|
||||
Some("Did the thing"),
|
||||
Some(150),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(entry.decision, "act");
|
||||
|
||||
let entries = list_log_entries(&conn, Some(&task.id), 10).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].result.as_deref(), Some("Did the thing"));
|
||||
|
||||
let all_entries = list_log_entries(&conn, None, 10).unwrap();
|
||||
assert_eq!(all_entries.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crud_escalations() {
|
||||
let conn = test_conn();
|
||||
let task = add_task(&conn, "Test", TaskSource::User, TaskRecurrence::Once).unwrap();
|
||||
|
||||
let esc = add_escalation(
|
||||
&conn,
|
||||
&task.id,
|
||||
None,
|
||||
"Deadline moved",
|
||||
"The API deadline was moved to tomorrow",
|
||||
&EscalationPriority::Critical,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(esc.status, EscalationStatus::Pending);
|
||||
|
||||
let pending = list_escalations(&conn, Some(&EscalationStatus::Pending)).unwrap();
|
||||
assert_eq!(pending.len(), 1);
|
||||
|
||||
assert_eq!(pending_escalation_count(&conn).unwrap(), 1);
|
||||
|
||||
resolve_escalation(&conn, &esc.id, &EscalationStatus::Approved).unwrap();
|
||||
let resolved = get_escalation(&conn, &esc.id).unwrap();
|
||||
assert_eq!(resolved.status, EscalationStatus::Approved);
|
||||
assert!(resolved.resolved_at.is_some());
|
||||
|
||||
assert_eq!(pending_escalation_count(&conn).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_default_tasks_creates_system_tasks() {
|
||||
let conn = test_conn();
|
||||
|
||||
let count = seed_default_tasks(&conn).unwrap();
|
||||
assert_eq!(count, DEFAULT_SYSTEM_TASKS.len());
|
||||
|
||||
// Second seed should not duplicate
|
||||
let count2 = seed_default_tasks(&conn).unwrap();
|
||||
assert_eq!(count2, 0);
|
||||
|
||||
let tasks = list_tasks(&conn, false).unwrap();
|
||||
assert_eq!(tasks.len(), DEFAULT_SYSTEM_TASKS.len());
|
||||
assert!(tasks.iter().all(|t| t.source == TaskSource::System));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recurrence_roundtrip() {
|
||||
assert_eq!(
|
||||
string_to_recurrence(&recurrence_to_string(&TaskRecurrence::Once)),
|
||||
TaskRecurrence::Once
|
||||
);
|
||||
assert_eq!(
|
||||
string_to_recurrence(&recurrence_to_string(&TaskRecurrence::Pending)),
|
||||
TaskRecurrence::Pending
|
||||
);
|
||||
assert_eq!(
|
||||
string_to_recurrence(&recurrence_to_string(&TaskRecurrence::Cron(
|
||||
"0 8 * * *".into()
|
||||
))),
|
||||
TaskRecurrence::Cron("0 8 * * *".into())
|
||||
);
|
||||
}
|
||||
|
||||
// ── DDL resilience: classifier + retry happy path ──────────────
|
||||
//
|
||||
// These guards back Sentry TAURI-RUST-A: the production failure is
|
||||
// `Connection::open` + `execute_batch(SCHEMA_DDL)` racing against
|
||||
// another in-process connection that holds the write lock. With
|
||||
// `BUSY_TIMEOUT` set and the application-level retry loop in place,
|
||||
// the race resolves on its own; without them the first attempt
|
||||
// returns `SQLITE_BUSY` and the user sees "failed to run subconscious
|
||||
// schema DDL" in Sentry with no further context.
|
||||
|
||||
#[test]
|
||||
fn is_sqlite_busy_matches_database_busy_code() {
|
||||
let raw = rusqlite::Error::SqliteFailure(
|
||||
rusqlite::ffi::Error {
|
||||
code: rusqlite::ErrorCode::DatabaseBusy,
|
||||
extended_code: 5, // SQLITE_BUSY
|
||||
},
|
||||
Some("database is locked".into()),
|
||||
);
|
||||
let err = anyhow::Error::from(raw);
|
||||
assert!(is_sqlite_busy(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_sqlite_busy_matches_database_locked_code() {
|
||||
let raw = rusqlite::Error::SqliteFailure(
|
||||
rusqlite::ffi::Error {
|
||||
code: rusqlite::ErrorCode::DatabaseLocked,
|
||||
extended_code: 6, // SQLITE_LOCKED
|
||||
},
|
||||
Some("database table is locked".into()),
|
||||
);
|
||||
let err = anyhow::Error::from(raw);
|
||||
assert!(is_sqlite_busy(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_sqlite_busy_does_not_match_constraint_violation() {
|
||||
let raw = rusqlite::Error::SqliteFailure(
|
||||
rusqlite::ffi::Error {
|
||||
code: rusqlite::ErrorCode::ConstraintViolation,
|
||||
extended_code: 19,
|
||||
},
|
||||
Some("UNIQUE constraint failed".into()),
|
||||
);
|
||||
let err = anyhow::Error::from(raw);
|
||||
assert!(!is_sqlite_busy(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_sqlite_busy_does_not_match_schema_syntax_error() {
|
||||
// A genuine bug in `SCHEMA_DDL` (e.g. typo in CREATE TABLE) would
|
||||
// surface as a `SqliteFailure(Unknown, ...)` with "syntax error"
|
||||
// in the message — retrying just delays the same failure, so the
|
||||
// classifier must reject it. Use Unknown + non-busy message.
|
||||
let raw = rusqlite::Error::SqliteFailure(
|
||||
rusqlite::ffi::Error {
|
||||
code: rusqlite::ErrorCode::Unknown,
|
||||
extended_code: 1,
|
||||
},
|
||||
Some("near \"FOO\": syntax error".into()),
|
||||
);
|
||||
let err = anyhow::Error::from(raw);
|
||||
assert!(!is_sqlite_busy(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_sqlite_busy_matches_through_context_layers() {
|
||||
// The production failure shape: a rusqlite error wrapped under
|
||||
// `with_context("failed to run subconscious schema DDL")` —
|
||||
// exactly what `open_and_initialize` produces. Downcast must
|
||||
// still find the rusqlite root.
|
||||
let raw = rusqlite::Error::SqliteFailure(
|
||||
rusqlite::ffi::Error {
|
||||
code: rusqlite::ErrorCode::DatabaseBusy,
|
||||
extended_code: 5,
|
||||
},
|
||||
Some("database is locked".into()),
|
||||
);
|
||||
let wrapped: anyhow::Result<()> = Err(anyhow::Error::from(raw))
|
||||
.with_context(|| "failed to run subconscious schema DDL".to_string());
|
||||
let err = wrapped.unwrap_err();
|
||||
assert!(is_sqlite_busy(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_sqlite_busy_text_fallback_when_downcast_misses() {
|
||||
// If a future refactor stringifies the rusqlite error before
|
||||
// wrapping (e.g. via anyhow!("{e}")), the downcast misses but
|
||||
// the chain-formatter text still preserves "database is locked".
|
||||
let err = anyhow::anyhow!("failed to run subconscious schema DDL: database is locked");
|
||||
assert!(is_sqlite_busy(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_connection_resolves_external_write_contention() {
|
||||
use std::sync::mpsc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let workspace = tmp.path().to_path_buf();
|
||||
|
||||
// First call: prime the DB so the file exists and the schema is
|
||||
// initialized. Subsequent calls take the fast path.
|
||||
with_connection(&workspace, |conn| {
|
||||
add_task(conn, "primer", TaskSource::User, TaskRecurrence::Once)?;
|
||||
Ok(())
|
||||
})
|
||||
.expect("prime DB");
|
||||
|
||||
// Hold an EXCLUSIVE write lock for ~250 ms in a side thread.
|
||||
// The DDL loop in `open_and_initialize` re-runs PRAGMA journal_mode
|
||||
// and CREATE TABLE IF NOT EXISTS — both are no-ops on an already
|
||||
// initialized DB but still acquire the write lock briefly, which
|
||||
// races against the held lock. The application-level retry
|
||||
// (100 / 300 / 900 ms) plus the 5 s `busy_timeout` must absorb
|
||||
// this and let the second `with_connection` succeed.
|
||||
let db_path = workspace.join("subconscious").join("subconscious.db");
|
||||
let (lock_ready_tx, lock_ready_rx) = mpsc::channel::<()>();
|
||||
let (release_tx, release_rx) = mpsc::channel::<()>();
|
||||
let blocker = std::thread::spawn(move || {
|
||||
let conn = rusqlite::Connection::open(&db_path).expect("open blocker conn");
|
||||
conn.busy_timeout(std::time::Duration::from_millis(100))
|
||||
.expect("blocker busy_timeout");
|
||||
let tx = conn
|
||||
.unchecked_transaction()
|
||||
.expect("begin blocker transaction");
|
||||
// Force write-lock acquisition immediately.
|
||||
tx.execute(
|
||||
"INSERT INTO subconscious_tasks \
|
||||
(id, title, source, recurrence, created_at) \
|
||||
VALUES ('blocker', 'blocker', 'user', 'pending', 0.0)",
|
||||
let count: i32 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE 'subconscious_%'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("blocker insert");
|
||||
lock_ready_tx.send(()).expect("signal lock acquired");
|
||||
// Wait for the main thread to start contending, then a touch
|
||||
// longer so the first one or two retries collide.
|
||||
release_rx
|
||||
.recv_timeout(std::time::Duration::from_secs(2))
|
||||
.expect("release signal");
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
tx.rollback().expect("rollback blocker txn");
|
||||
});
|
||||
|
||||
// Wait until the blocker actually holds the write lock.
|
||||
lock_ready_rx
|
||||
.recv_timeout(std::time::Duration::from_secs(2))
|
||||
.expect("blocker never acquired lock");
|
||||
|
||||
// Contender: should retry through the busy window and succeed
|
||||
// once the blocker rolls back. We release the blocker after
|
||||
// ~250 ms so the second / third retry attempt lands in the
|
||||
// unlocked window.
|
||||
let release_tx_for_timer = release_tx.clone();
|
||||
let timer = std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
let _ = release_tx_for_timer.send(());
|
||||
});
|
||||
|
||||
let result = with_connection(&workspace, |conn| {
|
||||
// Confirm we can issue a real query through the contended
|
||||
// connection — proves the open + DDL completed cleanly.
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM subconscious_tasks", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap_or(-1);
|
||||
Ok(count)
|
||||
});
|
||||
|
||||
timer.join().expect("timer thread panicked");
|
||||
blocker.join().expect("blocker thread panicked");
|
||||
|
||||
let count = result.expect("contended with_connection must succeed via retry");
|
||||
// Primer row is "primer"; blocker's INSERT was rolled back, so
|
||||
// the count should be exactly 1.
|
||||
assert_eq!(
|
||||
count, 1,
|
||||
"expected only the primer row after blocker rollback, got {count}"
|
||||
);
|
||||
.unwrap();
|
||||
assert!(count >= 4);
|
||||
}
|
||||
|
||||
@@ -1,161 +1,17 @@
|
||||
//! Type definitions for the subconscious task execution system.
|
||||
//! Type definitions for the subconscious agent loop.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ── Task types ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// A task managed by the subconscious engine.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SubconsciousTask {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub source: TaskSource,
|
||||
pub recurrence: TaskRecurrence,
|
||||
pub enabled: bool,
|
||||
pub last_run_at: Option<f64>,
|
||||
pub next_run_at: Option<f64>,
|
||||
pub completed: bool,
|
||||
pub created_at: f64,
|
||||
}
|
||||
|
||||
/// Where the task came from.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaskSource {
|
||||
/// Auto-populated by the system (skills health, Ollama status, etc.)
|
||||
System,
|
||||
/// Added by the user via UI or agent.
|
||||
User,
|
||||
}
|
||||
|
||||
/// How often the task should run.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaskRecurrence {
|
||||
/// Execute once, then mark completed.
|
||||
Once,
|
||||
/// Recurrent on a cron schedule (5-field expression).
|
||||
Cron(String),
|
||||
/// Not yet classified — agent will decide on first tick.
|
||||
Pending,
|
||||
}
|
||||
|
||||
/// Partial update for a task.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TaskPatch {
|
||||
pub title: Option<String>,
|
||||
pub recurrence: Option<TaskRecurrence>,
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
// ── Tick evaluation types ────────────────────────────────────────────────────
|
||||
|
||||
/// Per-tick decision for a single task.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TickDecision {
|
||||
/// Nothing relevant in current state for this task.
|
||||
#[default]
|
||||
Noop,
|
||||
/// State has something relevant — execute the task.
|
||||
Act,
|
||||
/// Ambiguous or risky — surface to user for approval.
|
||||
Escalate,
|
||||
}
|
||||
|
||||
/// The local model's evaluation of a single task against the current state.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TaskEvaluation {
|
||||
pub task_id: String,
|
||||
pub decision: TickDecision,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// Full evaluation response from the per-tick LLM.
|
||||
///
|
||||
/// `evaluations` covers the task-bound layer (act/escalate/noop per
|
||||
/// existing task). `reflections` (#623) covers the proactive layer —
|
||||
/// LLM-emitted observations grounded in memory-tree signals. The two
|
||||
/// are independent: a tick may produce only one, the other, or both.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EvaluationResponse {
|
||||
pub evaluations: Vec<TaskEvaluation>,
|
||||
/// Proactive-layer reflections (#623). Defaults to empty so older
|
||||
/// LLM payloads remain forward-compatible.
|
||||
#[serde(default)]
|
||||
pub reflections: Vec<super::reflection::ReflectionDraft>,
|
||||
}
|
||||
|
||||
// ── Execution types ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Result of executing a single task.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExecutionResult {
|
||||
pub output: String,
|
||||
pub used_tools: bool,
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
// ── Log types ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A single entry in the execution log.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SubconsciousLogEntry {
|
||||
pub id: String,
|
||||
pub task_id: String,
|
||||
pub tick_at: f64,
|
||||
pub decision: String,
|
||||
pub result: Option<String>,
|
||||
pub duration_ms: Option<i64>,
|
||||
pub created_at: f64,
|
||||
}
|
||||
|
||||
// ── Escalation types ─────────────────────────────────────────────────────────
|
||||
|
||||
/// An escalation waiting for user input.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Escalation {
|
||||
pub id: String,
|
||||
pub task_id: String,
|
||||
pub log_id: Option<String>,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub priority: EscalationPriority,
|
||||
pub status: EscalationStatus,
|
||||
pub created_at: f64,
|
||||
pub resolved_at: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EscalationPriority {
|
||||
Critical,
|
||||
Important,
|
||||
Normal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EscalationStatus {
|
||||
Pending,
|
||||
Approved,
|
||||
Dismissed,
|
||||
}
|
||||
|
||||
// ── Status types ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Summary of the subconscious engine status.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SubconsciousStatus {
|
||||
pub enabled: bool,
|
||||
pub mode: String,
|
||||
pub provider_available: bool,
|
||||
pub provider_unavailable_reason: Option<String>,
|
||||
pub interval_minutes: u32,
|
||||
pub last_tick_at: Option<f64>,
|
||||
pub total_ticks: u64,
|
||||
pub task_count: u64,
|
||||
pub pending_escalations: u64,
|
||||
/// Number of consecutive tick failures (resets on success).
|
||||
pub consecutive_failures: u64,
|
||||
}
|
||||
|
||||
@@ -163,8 +19,7 @@ pub struct SubconsciousStatus {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TickResult {
|
||||
pub tick_at: f64,
|
||||
pub evaluations: Vec<TaskEvaluation>,
|
||||
pub executed: usize,
|
||||
pub escalated: usize,
|
||||
pub thoughts_count: usize,
|
||||
pub thread_id: Option<String>,
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user