huskies: merge 1039
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
use crate::agents::ReconciliationEvent;
|
||||
use crate::io::watcher::WatcherEvent;
|
||||
use crate::rebuild::{BotShutdownNotifier, ShutdownReason};
|
||||
use crate::service::timer::TimerStore;
|
||||
use crate::service::timer::{ScheduledTimerStore, TimerStore};
|
||||
use crate::services::Services;
|
||||
use crate::state::SessionState;
|
||||
use crate::store::JsonFileStore;
|
||||
@@ -74,6 +74,9 @@ pub struct AppContext {
|
||||
/// spawned by the bot so that cancellations take effect in-memory rather
|
||||
/// than only on disk.
|
||||
pub timer_store: Arc<TimerStore>,
|
||||
/// Generic scheduled-timer store for `schedule_timer` / `list_timers` /
|
||||
/// `cancel_timer` MCP tools. Persists to `.huskies/scheduled_timers.json`.
|
||||
pub scheduled_timer_store: Arc<ScheduledTimerStore>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -90,6 +93,9 @@ impl AppContext {
|
||||
let timer_store = Arc::new(TimerStore::load(
|
||||
project_root.join(".huskies").join("timers.json"),
|
||||
));
|
||||
let scheduled_timer_store = Arc::new(ScheduledTimerStore::load(
|
||||
project_root.join(".huskies").join("scheduled_timers.json"),
|
||||
));
|
||||
let agents = Arc::new(AgentPool::new(3001, watcher_tx.clone()));
|
||||
let services = Arc::new(Services {
|
||||
project_root: project_root.clone(),
|
||||
@@ -116,6 +122,7 @@ impl AppContext {
|
||||
bot_shutdown: None,
|
||||
matrix_shutdown_tx: None,
|
||||
timer_store,
|
||||
scheduled_timer_store,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use serde_json::Value;
|
||||
|
||||
use super::{
|
||||
agent_tools, diagnostics, git_tools, merge_tools, qa_tools, shell_tools, status_tools,
|
||||
story_tools, wizard_tools,
|
||||
story_tools, timer_tools, wizard_tools,
|
||||
};
|
||||
use crate::http::context::AppContext;
|
||||
|
||||
@@ -128,6 +128,10 @@ pub async fn dispatch_tool_call(
|
||||
"wizard_confirm" => wizard_tools::tool_wizard_confirm(ctx),
|
||||
"wizard_skip" => wizard_tools::tool_wizard_skip(ctx),
|
||||
"wizard_retry" => wizard_tools::tool_wizard_retry(ctx),
|
||||
// Scheduled timer tools
|
||||
"schedule_timer" => timer_tools::tool_schedule_timer(&args, ctx),
|
||||
"list_timers" => timer_tools::tool_list_timers(ctx),
|
||||
"cancel_timer" => timer_tools::tool_cancel_timer(&args, ctx),
|
||||
_ => Err(format!("Unknown tool: {tool_name}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ pub mod shell_tools;
|
||||
pub mod status_tools;
|
||||
/// MCP tools for creating, updating, and managing stories and bugs.
|
||||
pub mod story_tools;
|
||||
/// MCP tools for generic scheduled timers (`schedule_timer`, `list_timers`, `cancel_timer`).
|
||||
pub mod timer_tools;
|
||||
/// MCP tool schema definitions for `tools/list`.
|
||||
pub mod tools_list;
|
||||
/// MCP tools for the project setup wizard.
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
//! MCP tools for generic scheduled timers: `schedule_timer`, `list_timers`,
|
||||
//! `cancel_timer`.
|
||||
|
||||
use chrono::Utc;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::http::context::AppContext;
|
||||
use crate::service::timer::scheduled::{
|
||||
ScheduledTimer, TimerAction, TimerMode, parse_interval_str, parse_when_str,
|
||||
};
|
||||
|
||||
// ── schedule_timer ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Register a new scheduled timer.
|
||||
pub fn tool_schedule_timer(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let when = args
|
||||
.get("when")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required parameter: 'when'")?;
|
||||
|
||||
let action_val = args
|
||||
.get("action")
|
||||
.ok_or("Missing required parameter: 'action'")?;
|
||||
|
||||
let action_type = action_val
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("'action' must have a 'type' field: 'mcp' or 'prompt'")?;
|
||||
|
||||
let action = match action_type {
|
||||
"mcp" => {
|
||||
let method = action_val
|
||||
.get("method")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("'mcp' action requires 'method'")?
|
||||
.to_string();
|
||||
let args_val = action_val
|
||||
.get("args")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::json!({}));
|
||||
TimerAction::Mcp {
|
||||
method,
|
||||
args: args_val,
|
||||
}
|
||||
}
|
||||
"prompt" => {
|
||||
let text = action_val
|
||||
.get("text")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("'prompt' action requires 'text'")?
|
||||
.to_string();
|
||||
TimerAction::Prompt { text }
|
||||
}
|
||||
other => {
|
||||
return Err(format!(
|
||||
"Unknown action type '{other}'. Use 'mcp' or 'prompt'."
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let mode_str = args.get("mode").and_then(|v| v.as_str()).unwrap_or("once");
|
||||
|
||||
let now = Utc::now();
|
||||
let (fire_at, inferred_interval) = parse_when_str(when, now)?;
|
||||
|
||||
let mode = match mode_str {
|
||||
"once" => TimerMode::Once,
|
||||
"recurring" => {
|
||||
// Prefer explicit `interval` arg; fall back to interval inferred from
|
||||
// the relative `when` duration.
|
||||
let interval_secs = if let Some(iv) = args
|
||||
.get("interval")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(parse_interval_str)
|
||||
{
|
||||
iv
|
||||
} else if let Some(iv) = inferred_interval {
|
||||
iv
|
||||
} else {
|
||||
return Err(
|
||||
"Recurring timers with an absolute 'when' require an 'interval' \
|
||||
parameter (e.g. '2h', '15 minutes')."
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
TimerMode::Recurring { interval_secs }
|
||||
}
|
||||
other => {
|
||||
return Err(format!(
|
||||
"Unknown mode '{other}'. Use 'once' or 'recurring'."
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let label = args
|
||||
.get("label")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string);
|
||||
|
||||
let timer = ScheduledTimer {
|
||||
id: ScheduledTimer::new_id(),
|
||||
label: label.clone(),
|
||||
fire_at,
|
||||
action,
|
||||
mode,
|
||||
created_at: now,
|
||||
};
|
||||
|
||||
let id = timer.id.clone();
|
||||
ctx.scheduled_timer_store.add(timer)?;
|
||||
|
||||
let label_str = label.map(|l| format!(": {l}")).unwrap_or_default();
|
||||
Ok(format!(
|
||||
"Timer `{id}`{label_str} scheduled to fire at {fire_at} (UTC)."
|
||||
))
|
||||
}
|
||||
|
||||
// ── list_timers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// List all pending scheduled timers.
|
||||
pub fn tool_list_timers(ctx: &AppContext) -> Result<String, String> {
|
||||
let timers = ctx.scheduled_timer_store.list();
|
||||
if timers.is_empty() {
|
||||
return Ok("No pending scheduled timers.".to_string());
|
||||
}
|
||||
let mut lines = vec![format!("**Pending scheduled timers ({}):**", timers.len())];
|
||||
for t in &timers {
|
||||
let label = t
|
||||
.label
|
||||
.as_deref()
|
||||
.map(|l| format!(" ({l})"))
|
||||
.unwrap_or_default();
|
||||
let mode_str = match &t.mode {
|
||||
TimerMode::Once => "once".to_string(),
|
||||
TimerMode::Recurring { interval_secs } => {
|
||||
format!("recurring every {interval_secs}s")
|
||||
}
|
||||
};
|
||||
let action_str = match &t.action {
|
||||
TimerAction::Mcp { method, .. } => format!("mcp:{method}"),
|
||||
TimerAction::Prompt { text } => {
|
||||
let preview: String = text.chars().take(40).collect();
|
||||
format!("prompt:{preview}")
|
||||
}
|
||||
};
|
||||
lines.push(format!(
|
||||
"- `{}` {}{} — fires at {} UTC [{}] action={}",
|
||||
t.id,
|
||||
mode_str,
|
||||
label,
|
||||
t.fire_at.format("%Y-%m-%dT%H:%M:%SZ"),
|
||||
mode_str,
|
||||
action_str
|
||||
));
|
||||
}
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
// ── cancel_timer ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Cancel a scheduled timer by ID.
|
||||
pub fn tool_cancel_timer(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let id = args
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required parameter: 'id'")?;
|
||||
|
||||
if ctx.scheduled_timer_store.remove_by_id(id) {
|
||||
Ok(format!("Timer `{id}` cancelled."))
|
||||
} else {
|
||||
Err(format!("No scheduled timer found with id '{id}'."))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::test_helpers::test_ctx;
|
||||
|
||||
#[test]
|
||||
fn schedule_timer_mcp_action_once() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(dir.path());
|
||||
let args = serde_json::json!({
|
||||
"when": "in 1 hour",
|
||||
"action": { "type": "mcp", "method": "get_pipeline_status", "args": {} },
|
||||
"mode": "once",
|
||||
"label": "hourly check"
|
||||
});
|
||||
let result = tool_schedule_timer(&args, &ctx).unwrap();
|
||||
assert!(result.contains("tm-"), "expected timer id: {result}");
|
||||
assert!(
|
||||
result.contains("scheduled to fire"),
|
||||
"expected schedule msg: {result}"
|
||||
);
|
||||
assert_eq!(ctx.scheduled_timer_store.list().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_timer_prompt_action() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(dir.path());
|
||||
let args = serde_json::json!({
|
||||
"when": "30m",
|
||||
"action": { "type": "prompt", "text": "standup time!" }
|
||||
});
|
||||
let result = tool_schedule_timer(&args, &ctx).unwrap();
|
||||
assert!(result.contains("scheduled"), "unexpected: {result}");
|
||||
let list = ctx.scheduled_timer_store.list();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert!(matches!(&list[0].action, TimerAction::Prompt { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_timer_recurring_relative() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(dir.path());
|
||||
let args = serde_json::json!({
|
||||
"when": "in 2 hours",
|
||||
"action": { "type": "prompt", "text": "reminder" },
|
||||
"mode": "recurring"
|
||||
});
|
||||
let result = tool_schedule_timer(&args, &ctx).unwrap();
|
||||
assert!(result.contains("scheduled"), "unexpected: {result}");
|
||||
let list = ctx.scheduled_timer_store.list();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert!(
|
||||
matches!(
|
||||
&list[0].mode,
|
||||
TimerMode::Recurring {
|
||||
interval_secs: 7200
|
||||
}
|
||||
),
|
||||
"expected 7200s interval: {:?}",
|
||||
list[0].mode
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_timer_recurring_absolute_requires_interval() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(dir.path());
|
||||
let args = serde_json::json!({
|
||||
"when": "2026-12-01T09:00:00Z",
|
||||
"action": { "type": "prompt", "text": "reminder" },
|
||||
"mode": "recurring"
|
||||
// no interval
|
||||
});
|
||||
assert!(tool_schedule_timer(&args, &ctx).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_timer_recurring_absolute_with_interval() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(dir.path());
|
||||
let args = serde_json::json!({
|
||||
"when": "2026-12-01T09:00:00Z",
|
||||
"action": { "type": "prompt", "text": "daily" },
|
||||
"mode": "recurring",
|
||||
"interval": "24h"
|
||||
});
|
||||
let result = tool_schedule_timer(&args, &ctx).unwrap();
|
||||
assert!(result.contains("scheduled"), "unexpected: {result}");
|
||||
let list = ctx.scheduled_timer_store.list();
|
||||
assert!(
|
||||
matches!(
|
||||
&list[0].mode,
|
||||
TimerMode::Recurring {
|
||||
interval_secs: 86400
|
||||
}
|
||||
),
|
||||
"expected 86400s (24h): {:?}",
|
||||
list[0].mode
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_timers_empty() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(dir.path());
|
||||
let result = tool_list_timers(&ctx).unwrap();
|
||||
assert!(result.contains("No pending"), "unexpected: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_timers_shows_entries() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(dir.path());
|
||||
let args = serde_json::json!({
|
||||
"when": "1h",
|
||||
"action": { "type": "prompt", "text": "foo" },
|
||||
"label": "my-timer"
|
||||
});
|
||||
tool_schedule_timer(&args, &ctx).unwrap();
|
||||
let result = tool_list_timers(&ctx).unwrap();
|
||||
assert!(result.contains("my-timer"), "unexpected: {result}");
|
||||
assert!(result.contains("tm-"), "expected timer id: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_timer_removes_it() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(dir.path());
|
||||
let args = serde_json::json!({
|
||||
"when": "1h",
|
||||
"action": { "type": "prompt", "text": "foo" }
|
||||
});
|
||||
tool_schedule_timer(&args, &ctx).unwrap();
|
||||
let id = ctx.scheduled_timer_store.list()[0].id.clone();
|
||||
|
||||
let result = tool_cancel_timer(&serde_json::json!({ "id": &id }), &ctx).unwrap();
|
||||
assert!(result.contains("cancelled"), "unexpected: {result}");
|
||||
assert!(ctx.scheduled_timer_store.list().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_timer_not_found_is_err() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(dir.path());
|
||||
assert!(tool_cancel_timer(&serde_json::json!({ "id": "tm-notexist" }), &ctx).is_err());
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,10 @@ mod tests {
|
||||
assert!(names.contains(&"unfreeze_story"));
|
||||
assert!(names.contains(&"find_orphaned_items"));
|
||||
assert!(names.contains(&"recover_half_written_items"));
|
||||
assert_eq!(tools.len(), 76);
|
||||
assert!(names.contains(&"schedule_timer"));
|
||||
assert!(names.contains(&"list_timers"));
|
||||
assert!(names.contains(&"cancel_timer"));
|
||||
assert_eq!(tools.len(), 79);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -357,6 +357,66 @@ pub(super) fn system_tools() -> Vec<Value> {
|
||||
"properties": {}
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "schedule_timer",
|
||||
"description": "Register a durable scheduled timer that fires an MCP call or reminder at a specified time. Survives server restart and rebuild. Use 'once' for a one-shot timer or 'recurring' to re-arm automatically after each fire.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"when": {
|
||||
"type": "string",
|
||||
"description": "When to fire: relative duration ('in 2 hours', '15 minutes', '30s') or ISO 8601 absolute timestamp ('2026-05-15T10:00:00Z')"
|
||||
},
|
||||
"action": {
|
||||
"type": "object",
|
||||
"description": "What to do when the timer fires. Either {\"type\":\"mcp\",\"method\":\"tool_name\",\"args\":{...}} for an MCP call or {\"type\":\"prompt\",\"text\":\"...\"} for a server-log reminder.",
|
||||
"properties": {
|
||||
"type": { "type": "string", "enum": ["mcp", "prompt"] },
|
||||
"method": { "type": "string", "description": "MCP tool name (required for type='mcp')" },
|
||||
"args": { "type": "object", "description": "MCP tool arguments (for type='mcp')" },
|
||||
"text": { "type": "string", "description": "Reminder text (required for type='prompt')" }
|
||||
},
|
||||
"required": ["type"]
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["once", "recurring"],
|
||||
"description": "Fire once (default) or keep recurring. For recurring with absolute 'when', also provide 'interval'."
|
||||
},
|
||||
"interval": {
|
||||
"type": "string",
|
||||
"description": "Re-arm interval for recurring timers when 'when' is an absolute timestamp (e.g. '24h', '1 hour', '30 minutes'). Optional when 'when' is relative — interval is inferred."
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Optional human-readable label shown in list_timers output."
|
||||
}
|
||||
},
|
||||
"required": ["when", "action"]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "list_timers",
|
||||
"description": "List all pending scheduled timers registered via schedule_timer.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "cancel_timer",
|
||||
"description": "Cancel a scheduled timer by its ID. Use list_timers to find the ID.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Timer ID to cancel (e.g. 'tm-a3f7b9c2')"
|
||||
}
|
||||
},
|
||||
"required": ["id"]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "recover_half_written_items",
|
||||
"description": "Recover half-written (orphaned) pipeline items by lifting each onto a fresh non-tombstoned ID. For each orphan, allocates a new ID, copies the content, re-applies item_type and depends_on from front matter, verifies the new entry is live in the CRDT, then removes the orphaned row. Pass 'only' to restrict recovery to specific orphan IDs (safe for live systems); omit to recover all. Returns old_id → new_id mappings for every successful recovery.",
|
||||
|
||||
Reference in New Issue
Block a user