Skip to content

Commit 3a46fc5

Browse files
RoyLinRoyLin
authored andcommitted
feat: add /loop slash command with session-scoped cron scheduler
Adds three new slash commands backed by a lightweight CronScheduler: - /loop [interval] <prompt> [every <interval>] — schedule a recurring prompt at a given interval (e.g. /loop 5m check the deployment) - /cron-list — list all active scheduled tasks with next-fire countdown - /cron-cancel <id> — cancel a scheduled task by ID CronScheduler design: - std::sync::Mutex inner state for sync access from command handlers - Background tokio task ticks every 1s, holds Weak<CronScheduler> so it exits automatically when the session is dropped - Fires prompts via tokio::sync::mpsc::UnboundedSender<ScheduledFire> - AgentSession drains the channel after each send() call and runs fired prompts via Box::pin(self.send(...)) to break the recursive future size - AtomicBool guard prevents nested cron processing Interval syntax: - Leading: /loop 5m check the build - Trailing every-clause: /loop check the build every 2h - No interval: defaults to 10 minutes Features: - Deterministic jitter: 0-10% of period (capped at 15 min), derived from task ID hash to avoid thundering herd - Max 50 tasks per session; recurring tasks auto-expire after 3 days - Supported units: s, m, h, d - 11 new unit tests in scheduler.rs Adds 1500 → 1500 passing tests (11 new in scheduler module).
1 parent a556952 commit 3a46fc5

6 files changed

Lines changed: 680 additions & 5 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,9 @@ Interactive session commands dispatched before the LLM. Custom commands via the
251251
| `/history` | Show conversation turn count and token stats |
252252
| `/tools` | List registered tools |
253253
| `/mcp` | List connected MCP servers and their tools |
254+
| `/loop [interval] <prompt>` | Schedule a recurring prompt (e.g., `/loop 5m check build`) |
255+
| `/cron-list` | List all scheduled recurring prompts |
256+
| `/cron-cancel <id>` | Cancel a scheduled task by ID |
254257

255258
```rust
256259
use a3s_code_core::commands::{SlashCommand, CommandContext, CommandOutput};

core/src/agent_api.rs

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
//! ```
1818
1919
use crate::agent::{AgentConfig, AgentEvent, AgentLoop, AgentResult};
20-
use crate::commands::{CommandContext, CommandRegistry};
20+
use crate::commands::{
21+
CommandContext, CommandRegistry, CronCancelCommand, CronListCommand, LoopCommand,
22+
};
2123
use crate::config::CodeConfig;
2224
use crate::error::{read_or_recover, write_or_recover, Result};
2325
use crate::llm::{LlmClient, Message};
@@ -26,12 +28,14 @@ use crate::queue::{
2628
ExternalTask, ExternalTaskResult, LaneHandlerConfig, SessionLane, SessionQueueConfig,
2729
SessionQueueStats,
2830
};
31+
use crate::scheduler::{CronScheduler, ScheduledFire};
2932
use crate::session_lane_queue::SessionLaneQueue;
3033
use crate::tools::{ToolContext, ToolExecutor};
3134
use a3s_lane::{DeadLetter, MetricsSnapshot};
3235
use a3s_memory::{FileMemoryStore, MemoryStore};
3336
use anyhow::Context;
3437
use std::path::{Path, PathBuf};
38+
use std::sync::atomic::{AtomicBool, Ordering};
3539
use std::sync::{Arc, RwLock};
3640
use tokio::sync::{broadcast, mpsc};
3741
use tokio::task::JoinHandle;
@@ -1197,6 +1201,20 @@ impl Agent {
11971201
None
11981202
};
11991203

1204+
// Build the cron scheduler and register scheduler-backed commands.
1205+
let (cron_scheduler, cron_rx) = CronScheduler::new();
1206+
CronScheduler::start(Arc::clone(&cron_scheduler));
1207+
let mut command_registry = CommandRegistry::new();
1208+
command_registry.register(Arc::new(LoopCommand {
1209+
scheduler: Arc::clone(&cron_scheduler),
1210+
}));
1211+
command_registry.register(Arc::new(CronListCommand {
1212+
scheduler: Arc::clone(&cron_scheduler),
1213+
}));
1214+
command_registry.register(Arc::new(CronCancelCommand {
1215+
scheduler: Arc::clone(&cron_scheduler),
1216+
}));
1217+
12001218
Ok(AgentSession {
12011219
llm_client,
12021220
tool_executor,
@@ -1211,7 +1229,7 @@ impl Agent {
12111229
auto_save: opts.auto_save,
12121230
hook_engine: Arc::new(crate::hooks::HookEngine::new()),
12131231
init_warning,
1214-
command_registry: CommandRegistry::new(),
1232+
command_registry,
12151233
model_name: opts
12161234
.model
12171235
.clone()
@@ -1222,6 +1240,9 @@ impl Agent {
12221240
.clone()
12231241
.or_else(|| self.global_mcp.clone())
12241242
.unwrap_or_else(|| Arc::new(crate::mcp::manager::McpManager::new())),
1243+
cron_scheduler,
1244+
cron_rx: tokio::sync::Mutex::new(cron_rx),
1245+
is_processing_cron: AtomicBool::new(false),
12251246
})
12261247
}
12271248
}
@@ -1262,6 +1283,12 @@ pub struct AgentSession {
12621283
model_name: String,
12631284
/// Shared MCP manager — all add_mcp_server / remove_mcp_server calls go here.
12641285
mcp_manager: Arc<crate::mcp::manager::McpManager>,
1286+
/// Session-scoped prompt scheduler (backs /loop, /cron-list, /cron-cancel).
1287+
cron_scheduler: Arc<CronScheduler>,
1288+
/// Receiver for scheduled prompt fires; drained after each `send()`.
1289+
cron_rx: tokio::sync::Mutex<mpsc::UnboundedReceiver<ScheduledFire>>,
1290+
/// Guard: prevents nested cron processing when a scheduled prompt itself calls send().
1291+
is_processing_cron: AtomicBool,
12651292
}
12661293

12671294
impl std::fmt::Debug for AgentSession {
@@ -1340,6 +1367,11 @@ impl AgentSession {
13401367
self.command_registry.register(cmd);
13411368
}
13421369

1370+
/// Access the session's cron scheduler (backs `/loop`, `/cron-list`, `/cron-cancel`).
1371+
pub fn cron_scheduler(&self) -> &Arc<CronScheduler> {
1372+
&self.cron_scheduler
1373+
}
1374+
13431375
/// Send a prompt and wait for the complete response.
13441376
///
13451377
/// When `history` is `None`, uses (and auto-updates) the session's
@@ -1390,6 +1422,34 @@ impl AgentSession {
13901422
}
13911423
}
13921424

1425+
// Drain scheduled prompt fires.
1426+
// The `is_processing_cron` guard prevents recursive processing when a
1427+
// cron-fired prompt itself calls send() (which would otherwise try to
1428+
// drain again on return).
1429+
if !self.is_processing_cron.swap(true, Ordering::Relaxed) {
1430+
let fires = {
1431+
let mut rx = self.cron_rx.lock().await;
1432+
let mut fires: Vec<ScheduledFire> = Vec::new();
1433+
while let Ok(fire) = rx.try_recv() {
1434+
fires.push(fire);
1435+
}
1436+
fires
1437+
};
1438+
for fire in fires {
1439+
tracing::debug!(
1440+
task_id = %fire.task_id,
1441+
"Firing scheduled cron task"
1442+
);
1443+
if let Err(e) = Box::pin(self.send(&fire.prompt, None)).await {
1444+
tracing::warn!(
1445+
task_id = %fire.task_id,
1446+
"Scheduled task failed: {e}"
1447+
);
1448+
}
1449+
}
1450+
self.is_processing_cron.store(false, Ordering::Relaxed);
1451+
}
1452+
13931453
Ok(result)
13941454
}
13951455

core/src/commands.rs

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
//! | `/history` | Show conversation turn count and token stats |
1717
//! | `/tools` | List registered tools |
1818
//! | `/mcp` | List connected MCP servers and their tools |
19+
//! | `/loop` | Schedule a recurring prompt |
20+
//! | `/cron-list` | List all scheduled recurring prompts |
21+
//! | `/cron-cancel` | Cancel a scheduled task by ID |
1922
//!
2023
//! ## Custom Commands
2124
//!
@@ -33,6 +36,7 @@
3336
//! }
3437
//! ```
3538
39+
use crate::scheduler::{format_duration, parse_loop_args, CronScheduler};
3640
use std::collections::HashMap;
3741
use std::sync::Arc;
3842

@@ -411,6 +415,129 @@ impl SlashCommand for McpCommand {
411415
}
412416
}
413417

418+
// ─── Scheduler-backed Commands ───────────────────────────────────────────────
419+
//
420+
// These commands require a shared `Arc<CronScheduler>` and are registered by
421+
// `AgentSession::build_session()` after the scheduler is created, NOT by
422+
// `CommandRegistry::new()`.
423+
424+
/// `/loop` — Schedule a recurring prompt.
425+
///
426+
/// Syntax (all equivalent):
427+
/// - `/loop 5m check the deployment`
428+
/// - `/loop check the deployment every 5m`
429+
/// - `/loop check the deployment` (defaults to every 10 minutes)
430+
pub struct LoopCommand {
431+
pub scheduler: Arc<CronScheduler>,
432+
}
433+
434+
impl SlashCommand for LoopCommand {
435+
fn name(&self) -> &str {
436+
"loop"
437+
}
438+
fn description(&self) -> &str {
439+
"Schedule a recurring prompt at a given interval"
440+
}
441+
fn usage(&self) -> Option<&str> {
442+
Some("/loop [interval] <prompt> [every <interval>]")
443+
}
444+
fn execute(&self, args: &str, _ctx: &CommandContext) -> CommandOutput {
445+
let args = args.trim();
446+
if args.is_empty() {
447+
return CommandOutput::text(concat!(
448+
"Usage: /loop [interval] <prompt> [every <interval>]\n\n",
449+
"Examples:\n",
450+
" /loop 5m check the deployment status\n",
451+
" /loop monitor memory usage every 2h\n",
452+
" /loop check the build (defaults to every 10m)\n\n",
453+
"Supported units: s (seconds), m (minutes), h (hours), d (days)",
454+
));
455+
}
456+
let (interval, prompt) = parse_loop_args(args);
457+
match self.scheduler.create_task(prompt.clone(), interval, true) {
458+
Ok(id) => CommandOutput::text(format!(
459+
"Scheduled [{id}]: \"{prompt}\" — fires every {}",
460+
format_duration(interval.as_secs())
461+
)),
462+
Err(e) => CommandOutput::text(format!("Error: {e}")),
463+
}
464+
}
465+
}
466+
467+
/// `/cron-list` — List all active scheduled tasks.
468+
pub struct CronListCommand {
469+
pub scheduler: Arc<CronScheduler>,
470+
}
471+
472+
impl SlashCommand for CronListCommand {
473+
fn name(&self) -> &str {
474+
"cron-list"
475+
}
476+
fn description(&self) -> &str {
477+
"List all scheduled recurring prompts"
478+
}
479+
fn execute(&self, _args: &str, _ctx: &CommandContext) -> CommandOutput {
480+
let tasks = self.scheduler.list_tasks();
481+
if tasks.is_empty() {
482+
return CommandOutput::text(
483+
"No scheduled tasks. Use /loop to schedule a recurring prompt.",
484+
);
485+
}
486+
let mut out = format!("Scheduled tasks ({}):\n", tasks.len());
487+
for t in &tasks {
488+
let next = format_duration(t.next_fire_in_secs);
489+
let cadence = if t.recurring {
490+
format!("every {}", format_duration(t.interval_secs))
491+
} else {
492+
"once".to_string()
493+
};
494+
let preview = if t.prompt.len() > 60 {
495+
format!("{}…", &t.prompt[..60])
496+
} else {
497+
t.prompt.clone()
498+
};
499+
out.push_str(&format!(
500+
" [{id}] {cadence} — fires in {next} (×{fires}) — \"{preview}\"\n",
501+
id = t.id,
502+
fires = t.fire_count,
503+
));
504+
}
505+
CommandOutput::text(out.trim_end().to_string())
506+
}
507+
}
508+
509+
/// `/cron-cancel <id>` — Cancel a scheduled task.
510+
pub struct CronCancelCommand {
511+
pub scheduler: Arc<CronScheduler>,
512+
}
513+
514+
impl SlashCommand for CronCancelCommand {
515+
fn name(&self) -> &str {
516+
"cron-cancel"
517+
}
518+
fn description(&self) -> &str {
519+
"Cancel a scheduled task by ID"
520+
}
521+
fn usage(&self) -> Option<&str> {
522+
Some("/cron-cancel <task-id>")
523+
}
524+
fn execute(&self, args: &str, _ctx: &CommandContext) -> CommandOutput {
525+
let id = args.trim();
526+
if id.is_empty() {
527+
return CommandOutput::text(
528+
"Usage: /cron-cancel <task-id>\nUse /cron-list to see active task IDs.",
529+
);
530+
}
531+
if self.scheduler.cancel_task(id) {
532+
CommandOutput::text(format!("Cancelled task [{id}]"))
533+
} else {
534+
CommandOutput::text(format!(
535+
"No task found with ID [{id}]. Use /cron-list to see active tasks."
536+
))
537+
}
538+
}
539+
}
540+
414541
#[cfg(test)]
415542
mod tests {
416543
use super::*;

core/src/lib.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ pub(crate) mod prompts;
7373
pub mod queue;
7474
pub(crate) mod retry;
7575
pub mod sandbox;
76+
pub mod scheduler;
7677
pub mod security;
7778
pub mod session;
7879
pub mod session_lane_queue;
@@ -93,7 +94,10 @@ pub use agent_teams::{
9394
AgentExecutor, AgentTeam, TeamConfig, TeamMember, TeamMemberOptions, TeamMessage, TeamRole,
9495
TeamRunResult, TeamRunner, TeamTaskBoard,
9596
};
96-
pub use commands::{CommandAction, CommandContext, CommandOutput, CommandRegistry, SlashCommand};
97+
pub use commands::{
98+
CommandAction, CommandContext, CommandOutput, CommandRegistry, CronCancelCommand,
99+
CronListCommand, LoopCommand, SlashCommand,
100+
};
97101
pub use config::{CodeConfig, ModelConfig, ModelCost, ModelLimit, ModelModalities, ProviderConfig};
98102
pub use error::{CodeError, Result};
99103
pub use hooks::HookEngine;
@@ -108,6 +112,7 @@ pub use queue::{
108112
SessionQueueConfig, SessionQueueStats, TaskHandlerMode,
109113
};
110114
pub use sandbox::SandboxConfig;
115+
pub use scheduler::{CronScheduler, ScheduledFire, ScheduledTask, ScheduledTaskInfo};
111116
pub use session::{SessionConfig, SessionManager, SessionState};
112117
pub use session_lane_queue::SessionLaneQueue;
113118
pub use skills::{builtin_skills, Skill, SkillKind};

0 commit comments

Comments
 (0)