Skip to content

Commit 36eab97

Browse files
Merge pull request #218 from ScriptedAlchemy/codex/activity-coupled-triggering
feat(automation): couple scheduler triggering to LCM session activity
2 parents 2894baf + c0cc9da commit 36eab97

10 files changed

Lines changed: 585 additions & 53 deletions

File tree

docs/SELF-IMPROVING-LOOPS-CONTRACTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ TraceDecay standalone automation is time-scheduled by the daemon, not by Codex n
2727
| `session_reflector` | Every 15 minutes, with a 5-minute cooldown | Validated accepted session facts auto-apply under the same memory auto-apply policy; otherwise they stay as dashboard fact proposals. |
2828
| `skill_writer` | Every 60 minutes, after a 15-minute idle window, with a 5-minute cooldown | Creates or updates managed skill drafts; skills are not auto-enabled while `auto_enable_skills=false`. |
2929

30+
Scheduling is activity-coupled, not purely wall-clock. The scheduler reads the newest LCM session-message timestamp for the project store as its session-activity signal on every tick:
31+
32+
- `min_idle_secs` is a true idle window: the task only runs after the project has been quiet (no LCM session ingest) for at least that long. A missing session store counts as idle.
33+
- `session_reflector` and `skill_writer` additionally require new session activity since their last successful run; when nothing new landed they skip with `no_new_session_activity` instead of re-reviewing the same transcripts. Because skips do not consume the interval clock, the task fires on the first tick after fresh activity lands (once the idle window is satisfied).
34+
- `memory_curator` reviews the fact store rather than session transcripts, so it keeps the plain interval/cooldown cadence.
35+
3036
The daemon loop is the host for these jobs. It should not create Codex top-level chats for scheduler work, and it should not rely on Codex native recurring automations for liveness. Host backends provide the model call; TraceDecay owns evidence collection, validation, ledgers, apply policy, and scheduler state.
3137

3238
## Standalone And Delegated Modes

src/automation/lifecycle.rs

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ use super::run_ledger::{
1616
AutomationTrigger,
1717
};
1818
use super::scheduler::{
19-
schedule_decision, stale_lock_secs, AutomationScheduleDecision, AutomationTaskLock,
19+
load_session_activity, schedule_decision, stale_lock_secs, AutomationScheduleDecision,
20+
AutomationTaskLock,
2021
};
2122
use crate::errors::{Result, TraceDecayError};
2223
use crate::tracedecay::current_timestamp;
@@ -37,6 +38,9 @@ pub(crate) struct AgentTaskRunContext<'a> {
3738
pub(crate) run_id: String,
3839
pub(crate) trigger: AutomationTrigger,
3940
pub(crate) dashboard_root: PathBuf,
41+
/// LCM sessions database for the project store; the scheduler gate reads
42+
/// its newest message timestamp as the session-activity signal.
43+
sessions_db_path: PathBuf,
4044
config: &'a AutomationConfig,
4145
task: AgentTaskKind,
4246
started_at: String,
@@ -50,6 +54,7 @@ pub(crate) struct AgentTaskRunContext<'a> {
5054
impl<'a> AgentTaskRunContext<'a> {
5155
pub(crate) fn new(
5256
dashboard_root: PathBuf,
57+
sessions_db_path: PathBuf,
5358
run_id: Option<String>,
5459
run_id_prefix: &'static str,
5560
trigger: AutomationTrigger,
@@ -60,6 +65,7 @@ impl<'a> AgentTaskRunContext<'a> {
6065
run_id: run_id.unwrap_or_else(|| generated_run_id(run_id_prefix)),
6166
trigger,
6267
dashboard_root,
68+
sessions_db_path,
6369
config,
6470
task,
6571
started_at: current_timestamp().to_string(),
@@ -72,8 +78,14 @@ impl<'a> AgentTaskRunContext<'a> {
7278
}
7379

7480
pub(crate) async fn gate(&mut self) -> Result<SchedulerGate> {
75-
let (gate, records) =
76-
task_run_gate(self.config, &self.dashboard_root, self.task, self.trigger).await?;
81+
let (gate, records) = task_run_gate(
82+
self.config,
83+
&self.dashboard_root,
84+
&self.sessions_db_path,
85+
self.task,
86+
self.trigger,
87+
)
88+
.await?;
7789
self.ledger_records = records;
7890
Ok(gate)
7991
}
@@ -149,6 +161,7 @@ pub(crate) fn task_skip_reason(
149161
pub(crate) async fn scheduler_gate(
150162
config: &AutomationConfig,
151163
dashboard_root: &Path,
164+
sessions_db_path: &Path,
152165
task: AgentTaskKind,
153166
trigger: AutomationTrigger,
154167
) -> Result<(SchedulerGate, Option<Vec<AutomationRunLedgerRecord>>)> {
@@ -169,7 +182,8 @@ pub(crate) async fn scheduler_gate(
169182
return Ok((SchedulerGate::Skip("scheduler_lock_active"), Some(records)));
170183
};
171184

172-
let decision = schedule_decision(config, task, &records, now_secs);
185+
let activity = load_session_activity(sessions_db_path).await;
186+
let decision = schedule_decision(config, task, &records, activity, now_secs);
173187
if let Some(reason) = scheduler_skip_reason(&decision, task) {
174188
return Ok((SchedulerGate::Skip(reason), Some(records)));
175189
}
@@ -180,10 +194,12 @@ pub(crate) async fn scheduler_gate(
180194
pub(crate) async fn task_run_gate(
181195
config: &AutomationConfig,
182196
dashboard_root: &Path,
197+
sessions_db_path: &Path,
183198
task: AgentTaskKind,
184199
trigger: AutomationTrigger,
185200
) -> Result<(SchedulerGate, Option<Vec<AutomationRunLedgerRecord>>)> {
186-
let (gate, records) = scheduler_gate(config, dashboard_root, task, trigger).await?;
201+
let (gate, records) =
202+
scheduler_gate(config, dashboard_root, sessions_db_path, task, trigger).await?;
187203
let gate = match gate {
188204
SchedulerGate::Skip(reason) => SchedulerGate::Skip(reason),
189205
SchedulerGate::Proceed(lock) => match task_skip_reason(config, task) {
@@ -695,6 +711,7 @@ mod tests {
695711
let config = AutomationConfig::default();
696712
let mut run = AgentTaskRunContext::new(
697713
dashboard_root.to_path_buf(),
714+
dashboard_root.join("sessions.db"),
698715
Some(run_id.to_string()),
699716
"test",
700717
trigger,
@@ -825,6 +842,7 @@ mod tests {
825842
let config = scheduler_enabled_config();
826843
let mut run = AgentTaskRunContext::new(
827844
dashboard_root.to_path_buf(),
845+
dashboard_root.join("sessions.db"),
828846
Some(run_id.to_string()),
829847
"test",
830848
AutomationTrigger::Scheduler,

src/automation/memory_curator.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ pub async fn run_memory_curator_with_backend(
5353
) -> Result<MemoryCuratorAutomationRun> {
5454
let mut run = AgentTaskRunContext::new(
5555
cg.store_layout().dashboard_root.clone(),
56+
cg.store_layout().sessions_db_path.clone(),
5657
options.run_id.clone(),
5758
"memory_curator",
5859
options.trigger,

src/automation/runner.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ pub async fn run_session_reflector_with_backend(
175175
) -> Result<SessionReflectorAutomationRun> {
176176
let mut run = AgentTaskRunContext::new(
177177
cg.store_layout().dashboard_root.clone(),
178+
cg.store_layout().sessions_db_path.clone(),
178179
options.run_id.clone(),
179180
"session_reflector",
180181
options.trigger,
@@ -446,6 +447,7 @@ pub async fn run_skill_writer_with_backend(
446447
) -> Result<SkillWriterAutomationRun> {
447448
let mut run = AgentTaskRunContext::new(
448449
cg.store_layout().dashboard_root.clone(),
450+
cg.store_layout().sessions_db_path.clone(),
449451
options.run_id.clone(),
450452
"skill_writer",
451453
options.trigger,

src/automation/scheduler.rs

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use super::config::{
99
};
1010
use super::run_ledger::{AutomationRunLedgerRecord, AutomationRunStatus, AutomationTrigger};
1111
use crate::errors::{Result, TraceDecayError};
12+
use crate::global_db::GlobalDb;
1213

1314
const DEFAULT_FAILURE_COOLDOWN_SECS: u64 = 300;
1415
const DEFAULT_STALE_LOCK_SECS: u64 = 6 * 60 * 60;
@@ -27,6 +28,42 @@ pub enum AutomationSchedule {
2728
Interval { every_secs: u64 },
2829
}
2930

31+
/// Most recent LCM session ingest activity for the project, in unix seconds.
32+
///
33+
/// `None` means the session store does not exist yet or holds no timestamped
34+
/// messages; gates that need an activity signal treat that as "no activity
35+
/// observed" rather than an error.
36+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37+
pub struct SessionActivity {
38+
pub last_activity_secs: Option<i64>,
39+
}
40+
41+
impl SessionActivity {
42+
pub fn none() -> Self {
43+
Self::default()
44+
}
45+
46+
pub fn at(last_activity_secs: i64) -> Self {
47+
Self {
48+
last_activity_secs: Some(last_activity_secs),
49+
}
50+
}
51+
}
52+
53+
/// Reads the session-activity signal from the LCM sessions database.
54+
///
55+
/// This is a single indexed `ORDER BY timestamp DESC LIMIT 1` lookup against
56+
/// the read-only store, so it is cheap and race-safe to call from every
57+
/// scheduler tick; concurrent ingest writers only ever move the value forward.
58+
pub async fn load_session_activity(sessions_db_path: &Path) -> SessionActivity {
59+
let Some(db) = GlobalDb::open_read_only_at(sessions_db_path).await else {
60+
return SessionActivity::none();
61+
};
62+
SessionActivity {
63+
last_activity_secs: db.latest_session_activity_secs().await,
64+
}
65+
}
66+
3067
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3168
pub struct AutomationScheduleDecision {
3269
skip_reason: Option<&'static str>,
@@ -170,6 +207,7 @@ pub fn schedule_decision(
170207
config: &AutomationConfig,
171208
task: AgentTaskKind,
172209
records: &[AutomationRunLedgerRecord],
210+
activity: SessionActivity,
173211
now_secs: i64,
174212
) -> AutomationScheduleDecision {
175213
if !config.enabled {
@@ -200,10 +238,12 @@ pub fn schedule_decision(
200238
return AutomationScheduleDecision::skipped("scheduler_schedule_manual");
201239
};
202240

241+
// `min_idle_secs` is a true idle window: the project must have been quiet
242+
// (no LCM session ingest activity) for at least this long. An unknown
243+
// activity signal (no session store yet) counts as idle.
203244
if let Some(min_idle_secs) = task_config.min_idle_secs {
204-
if let Some(record) = latest_non_skipped_record(records, task, None) {
205-
let completed_at = record.completed_at.parse::<i64>().ok().unwrap_or(0);
206-
if elapsed_secs(completed_at, now_secs) < min_idle_secs {
245+
if let Some(last_activity) = activity.last_activity_secs {
246+
if elapsed_secs(last_activity, now_secs) < min_idle_secs {
207247
return AutomationScheduleDecision::skipped("scheduler_idle_window_active");
208248
}
209249
}
@@ -235,9 +275,34 @@ pub fn schedule_decision(
235275
}
236276
}
237277

278+
// Session-evidence tasks only re-run when new session activity landed
279+
// after their last successful run started; a run without fresh evidence
280+
// would re-review the same transcript slices. Skips do not consume the
281+
// interval clock, so the task fires on the first tick after new activity.
282+
if task_consumes_session_evidence(task) {
283+
if let Some(record) = latest_successful_record(records, task) {
284+
let started_at = record.started_at.parse::<i64>().ok().unwrap_or(0);
285+
let has_new_activity = activity
286+
.last_activity_secs
287+
.is_some_and(|last_activity| last_activity > started_at);
288+
if !has_new_activity {
289+
return AutomationScheduleDecision::skipped("no_new_session_activity");
290+
}
291+
}
292+
}
293+
238294
AutomationScheduleDecision::due()
239295
}
240296

297+
/// Tasks whose evidence comes from the LCM session store; they are gated on
298+
/// new session activity since their last successful run.
299+
fn task_consumes_session_evidence(task: AgentTaskKind) -> bool {
300+
match task {
301+
AgentTaskKind::SessionReflector | AgentTaskKind::SkillWriter => true,
302+
AgentTaskKind::MemoryCurator => false,
303+
}
304+
}
305+
241306
pub fn stale_lock_secs(config: &AutomationConfig, task: AgentTaskKind) -> Option<u64> {
242307
task_config(config, task)
243308
.stale_lock_secs
@@ -303,6 +368,16 @@ fn task_config(config: &AutomationConfig, task: AgentTaskKind) -> &AutomationTas
303368
}
304369
}
305370

371+
fn latest_successful_record(
372+
records: &[AutomationRunLedgerRecord],
373+
task: AgentTaskKind,
374+
) -> Option<&AutomationRunLedgerRecord> {
375+
records
376+
.iter()
377+
.filter(|record| record.task == task && record.status == AutomationRunStatus::Succeeded)
378+
.max_by_key(|record| record.completed_at.parse::<i64>().ok().unwrap_or(0))
379+
}
380+
306381
fn latest_non_skipped_record(
307382
records: &[AutomationRunLedgerRecord],
308383
task: AgentTaskKind,

src/dashboard/automation_scheduler_api.rs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ use crate::automation::backend::{task_key, AgentTaskKind};
1111
use crate::automation::config::{effective_config, load_project_config, AutomationConfig};
1212
use crate::automation::run_ledger::{load_run_records, AutomationRunLedgerRecord};
1313
use crate::automation::scheduler::{
14-
load_scheduler_control, save_scheduler_control, schedule_decision, scheduler_control_path,
15-
AutomationSchedulerControl,
14+
load_scheduler_control, load_session_activity, save_scheduler_control, schedule_decision,
15+
scheduler_control_path, AutomationSchedulerControl, SessionActivity,
1616
};
1717
use crate::automation::staged_notice::{count_pending_automation_output, AutomationPendingCounts};
1818
use crate::tracedecay::current_timestamp;
@@ -69,6 +69,8 @@ async fn scheduler_status_payload(state: &DashboardState) -> ApiResult {
6969
Err(_) => AutomationPendingCounts::default(),
7070
};
7171
let now = current_timestamp();
72+
let activity =
73+
load_session_activity(&state.store_root.join(crate::storage::SESSIONS_DB_FILENAME)).await;
7274
Ok(Json(json!({
7375
"status": scheduler_status_label(&effective, control.paused),
7476
"paused": control.paused,
@@ -77,16 +79,17 @@ async fn scheduler_status_payload(state: &DashboardState) -> ApiResult {
7779
"enabled": effective.enabled,
7880
"scheduler_tick_secs": effective.scheduler_tick_secs,
7981
"now": now,
82+
"last_session_activity": activity.last_activity_secs,
8083
"project_config_path": crate::automation::config::project_config_path(&state.dashboard_root)
8184
.display()
8285
.to_string(),
8386
"control_path": scheduler_control_path(&state.dashboard_root)
8487
.display()
8588
.to_string(),
8689
"tasks": [
87-
task_status(&effective, control.paused, &records, now, AgentTaskKind::MemoryCurator),
88-
task_status(&effective, control.paused, &records, now, AgentTaskKind::SessionReflector),
89-
task_status(&effective, control.paused, &records, now, AgentTaskKind::SkillWriter),
90+
task_status(&effective, control.paused, &records, activity, now, AgentTaskKind::MemoryCurator),
91+
task_status(&effective, control.paused, &records, activity, now, AgentTaskKind::SessionReflector),
92+
task_status(&effective, control.paused, &records, activity, now, AgentTaskKind::SkillWriter),
9093
],
9194
})))
9295
}
@@ -95,13 +98,14 @@ fn task_status(
9598
config: &AutomationConfig,
9699
paused: bool,
97100
records: &[AutomationRunLedgerRecord],
101+
activity: SessionActivity,
98102
now: i64,
99103
task: AgentTaskKind,
100104
) -> Value {
101105
let decision = if paused {
102106
crate::automation::scheduler::AutomationScheduleDecision::skipped("scheduler_paused")
103107
} else {
104-
schedule_decision(config, task, records, now)
108+
schedule_decision(config, task, records, activity, now)
105109
};
106110
let latest_scheduler = records
107111
.iter()

src/global_db.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2297,6 +2297,32 @@ impl GlobalDb {
22972297
Ok(out)
22982298
}
22992299

2300+
/// Timestamp of the most recent ingested session message, in unix
2301+
/// seconds, or `None` when no timestamped messages exist. Providers store
2302+
/// either seconds or milliseconds; millisecond values are normalized.
2303+
/// Backed by `idx_session_messages_timestamp`, so this is a cheap index
2304+
/// seek suitable for every automation scheduler tick.
2305+
pub async fn latest_session_activity_secs(&self) -> Option<i64> {
2306+
let mut rows = self
2307+
.conn
2308+
.query(
2309+
"SELECT timestamp FROM session_messages
2310+
WHERE timestamp IS NOT NULL
2311+
ORDER BY timestamp DESC
2312+
LIMIT 1",
2313+
(),
2314+
)
2315+
.await
2316+
.ok()?;
2317+
let row = rows.next().await.ok()??;
2318+
let timestamp = row.get::<i64>(0).ok()?;
2319+
Some(if timestamp >= 1_000_000_000_000 {
2320+
timestamp / 1000
2321+
} else {
2322+
timestamp
2323+
})
2324+
}
2325+
23002326
/// Inserts or replaces a provider message. Returns `false` on any DB error.
23012327
pub async fn upsert_session_message(&self, message: &SessionMessageRecord) -> bool {
23022328
if self.conn.execute("BEGIN IMMEDIATE", ()).await.is_err() {

0 commit comments

Comments
 (0)