2026-04-24 17:39:42 +00:00
|
|
|
//! Timer service — deferred agent start via one-shot timers.
|
|
|
|
|
//!
|
|
|
|
|
//! Provides [`TimerStore`] for persisting timers to `.huskies/timers.json`
|
|
|
|
|
//! and command parsing / handling for the `timer` bot command.
|
|
|
|
|
//! Due timers are fired by the unified background tick loop in `main`.
|
|
|
|
|
//!
|
|
|
|
|
//! Follows service-module conventions:
|
|
|
|
|
//! - `mod.rs` (this file) — public API, typed [`Error`], orchestration
|
|
|
|
|
//! - `io.rs` — the ONLY place that performs side effects (filesystem, clock, spawn)
|
|
|
|
|
//! - `parse.rs` — pure: command parsing, time display formatting
|
|
|
|
|
//! - `persist.rs` — pure: serialisation/deserialisation of `timers.json`
|
|
|
|
|
//! - `schedule.rs` — pure: next-fire-time calculation given a reference instant
|
|
|
|
|
|
|
|
|
|
pub(super) mod io;
|
|
|
|
|
pub(super) mod parse;
|
|
|
|
|
pub(super) mod persist;
|
|
|
|
|
pub(super) mod schedule;
|
|
|
|
|
|
|
|
|
|
pub use io::{TimerStore, next_occurrence_of_hhmm, spawn_rate_limit_auto_scheduler, tick_once};
|
|
|
|
|
pub use parse::{TimerCommand, extract_timer_command};
|
|
|
|
|
pub use persist::TimerEntry;
|
|
|
|
|
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
|
|
|
|
|
// ── Error type ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// Typed errors returned by `service::timer` operations.
|
|
|
|
|
///
|
|
|
|
|
/// HTTP handlers and bot commands may map these to user-facing messages:
|
|
|
|
|
/// - [`Error::Parse`] → "Invalid time format"
|
|
|
|
|
/// - [`Error::DuplicateSchedule`] → "Timer already scheduled"
|
|
|
|
|
/// - [`Error::NoSuchSchedule`] → "No timer found"
|
|
|
|
|
/// - [`Error::Io`] → "Internal error saving timer"
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
pub enum Error {
|
|
|
|
|
/// The supplied `HH:MM` string could not be parsed or is out of range.
|
|
|
|
|
Parse(String),
|
|
|
|
|
/// A timer already exists for the given story ID.
|
|
|
|
|
DuplicateSchedule(String),
|
|
|
|
|
/// No timer exists for the given story ID.
|
|
|
|
|
NoSuchSchedule(String),
|
|
|
|
|
/// A filesystem read or write operation failed.
|
|
|
|
|
Io(String),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl std::fmt::Display for Error {
|
|
|
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
|
|
|
match self {
|
|
|
|
|
Self::Parse(msg) => write!(f, "Parse error: {msg}"),
|
|
|
|
|
Self::DuplicateSchedule(id) => {
|
|
|
|
|
write!(f, "Timer already exists for story '{id}'")
|
|
|
|
|
}
|
|
|
|
|
Self::NoSuchSchedule(id) => write!(f, "No timer found for story '{id}'"),
|
|
|
|
|
Self::Io(msg) => write!(f, "I/O error: {msg}"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Typed public API ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// Schedule a new timer for `story_id` to fire at the next `HH:MM` occurrence.
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
///
|
|
|
|
|
/// Returns the scheduled UTC instant on success.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
/// - [`Error::DuplicateSchedule`] if a timer already exists for `story_id`.
|
|
|
|
|
/// - [`Error::Parse`] if `hhmm` is not a valid `HH:MM` string.
|
|
|
|
|
/// - [`Error::Io`] if persisting the timer to disk fails.
|
|
|
|
|
pub fn schedule_timer(
|
|
|
|
|
store: &TimerStore,
|
|
|
|
|
story_id: &str,
|
|
|
|
|
hhmm: &str,
|
|
|
|
|
timezone: Option<&str>,
|
|
|
|
|
) -> Result<chrono::DateTime<chrono::Utc>, Error> {
|
|
|
|
|
if store.list().iter().any(|t| t.story_id == story_id) {
|
|
|
|
|
return Err(Error::DuplicateSchedule(story_id.to_string()));
|
|
|
|
|
}
|
|
|
|
|
let scheduled_at = next_occurrence_of_hhmm(hhmm, timezone)
|
|
|
|
|
.ok_or_else(|| Error::Parse(format!("invalid HH:MM: '{hhmm}'")))?;
|
|
|
|
|
store
|
|
|
|
|
.add(story_id.to_string(), scheduled_at)
|
|
|
|
|
.map_err(Error::Io)?;
|
|
|
|
|
Ok(scheduled_at)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Cancel an existing timer for `story_id`.
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
/// - [`Error::NoSuchSchedule`] if no timer exists for `story_id`.
|
|
|
|
|
pub fn cancel_timer(store: &TimerStore, story_id: &str) -> Result<(), Error> {
|
|
|
|
|
if store.remove(story_id) {
|
|
|
|
|
Ok(())
|
|
|
|
|
} else {
|
|
|
|
|
Err(Error::NoSuchSchedule(story_id.to_string()))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Command handler ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// Handle a parsed `timer` command. Returns a markdown-formatted response.
|
|
|
|
|
pub async fn handle_timer_command(
|
|
|
|
|
cmd: TimerCommand,
|
|
|
|
|
store: &TimerStore,
|
|
|
|
|
project_root: &Path,
|
|
|
|
|
) -> String {
|
|
|
|
|
// Load the configured timezone (if any) from project.toml.
|
|
|
|
|
let config_tz: Option<String> = crate::config::ProjectConfig::load(project_root)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|c| c.timezone);
|
|
|
|
|
let tz_str: Option<&str> = config_tz.as_deref();
|
|
|
|
|
|
|
|
|
|
match cmd {
|
|
|
|
|
TimerCommand::Schedule {
|
|
|
|
|
story_number_or_id,
|
|
|
|
|
hhmm,
|
|
|
|
|
} => {
|
|
|
|
|
let story_id = match io::resolve_story_id(&story_number_or_id, project_root) {
|
|
|
|
|
Some(id) => id,
|
|
|
|
|
None => {
|
|
|
|
|
return format!("No story with number or ID **{story_number_or_id}** found.");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// The story must be in backlog or current. When the timer fires,
|
|
|
|
|
// backlog stories are moved to current automatically.
|
|
|
|
|
// Check CRDT state first, then fall back to filesystem.
|
|
|
|
|
let in_valid_stage =
|
|
|
|
|
if let Ok(Some(item)) = crate::pipeline_state::read_typed(&story_id) {
|
|
|
|
|
use crate::pipeline_state::Stage;
|
|
|
|
|
matches!(item.stage, Stage::Backlog | Stage::Coding)
|
|
|
|
|
} else {
|
|
|
|
|
let work_dir = project_root.join(".huskies").join("work");
|
|
|
|
|
work_dir
|
|
|
|
|
.join("1_backlog")
|
|
|
|
|
.join(format!("{story_id}.md"))
|
|
|
|
|
.exists()
|
|
|
|
|
|| work_dir
|
|
|
|
|
.join("2_current")
|
|
|
|
|
.join(format!("{story_id}.md"))
|
|
|
|
|
.exists()
|
|
|
|
|
};
|
|
|
|
|
if !in_valid_stage {
|
|
|
|
|
return format!("Story **{story_id}** is not in backlog or current.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let scheduled_at = match next_occurrence_of_hhmm(&hhmm, tz_str) {
|
|
|
|
|
Some(t) => t,
|
|
|
|
|
None => {
|
|
|
|
|
return format!("Invalid time **{hhmm}**. Use `HH:MM` format (e.g. `14:30`).");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
match store.add(story_id.clone(), scheduled_at) {
|
|
|
|
|
Ok(()) => {
|
|
|
|
|
let (display_time, tz_label) = parse::format_in_timezone(scheduled_at, tz_str);
|
|
|
|
|
format!("Timer set for **{story_id}** at **{display_time}** ({tz_label}).")
|
|
|
|
|
}
|
|
|
|
|
Err(e) => format!("Failed to save timer: {e}"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
TimerCommand::List => {
|
|
|
|
|
let timers = store.list();
|
|
|
|
|
if timers.is_empty() {
|
|
|
|
|
return "No pending timers.".to_string();
|
|
|
|
|
}
|
|
|
|
|
let mut lines = vec!["**Pending timers:**".to_string()];
|
|
|
|
|
for t in &timers {
|
|
|
|
|
let (display_time, _) = parse::format_in_timezone(t.scheduled_at, tz_str);
|
|
|
|
|
lines.push(format!("- **{}** → {}", t.story_id, display_time));
|
|
|
|
|
}
|
|
|
|
|
lines.join("\n")
|
|
|
|
|
}
|
|
|
|
|
TimerCommand::Cancel { story_number_or_id } => {
|
|
|
|
|
let story_id = io::resolve_story_id(&story_number_or_id, project_root)
|
|
|
|
|
.unwrap_or(story_number_or_id.clone());
|
|
|
|
|
if store.remove(&story_id) {
|
|
|
|
|
format!("Timer for **{story_id}** cancelled.")
|
|
|
|
|
} else {
|
|
|
|
|
format!("No timer found for **{story_id}**.")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
TimerCommand::BadArgs => "Usage:\n\
|
|
|
|
|
- `timer <story_id> <HH:MM>` — schedule deferred start\n\
|
|
|
|
|
- `timer list` — show pending timers\n\
|
|
|
|
|
- `timer cancel <story_id>` — remove a timer"
|
|
|
|
|
.to_string(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use chrono::{Duration, Utc};
|
|
|
|
|
use tempfile::TempDir;
|
|
|
|
|
|
|
|
|
|
// ── Error Display ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn error_parse_display() {
|
|
|
|
|
let e = Error::Parse("bad value".to_string());
|
|
|
|
|
assert!(e.to_string().contains("Parse error"));
|
|
|
|
|
assert!(e.to_string().contains("bad value"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn error_duplicate_schedule_display() {
|
|
|
|
|
let e = Error::DuplicateSchedule("421_story_foo".to_string());
|
|
|
|
|
assert!(e.to_string().contains("already exists"));
|
|
|
|
|
assert!(e.to_string().contains("421_story_foo"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn error_no_such_schedule_display() {
|
|
|
|
|
let e = Error::NoSuchSchedule("421_story_foo".to_string());
|
|
|
|
|
assert!(e.to_string().contains("No timer found"));
|
|
|
|
|
assert!(e.to_string().contains("421_story_foo"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn error_io_display() {
|
|
|
|
|
let e = Error::Io("disk full".to_string());
|
|
|
|
|
assert!(e.to_string().contains("I/O error"));
|
|
|
|
|
assert!(e.to_string().contains("disk full"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── schedule_timer ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn schedule_timer_returns_duplicate_when_already_exists() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = TimerStore::load(dir.path().join("timers.json"));
|
|
|
|
|
let t = Utc::now() + Duration::hours(1);
|
|
|
|
|
store.add("421_story_foo".to_string(), t).unwrap();
|
|
|
|
|
let result = schedule_timer(&store, "421_story_foo", "14:30", None);
|
|
|
|
|
assert!(
|
|
|
|
|
matches!(result, Err(Error::DuplicateSchedule(_))),
|
|
|
|
|
"expected DuplicateSchedule: {result:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn schedule_timer_returns_parse_error_for_bad_hhmm() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = TimerStore::load(dir.path().join("timers.json"));
|
|
|
|
|
let result = schedule_timer(&store, "421_story_foo", "99:99", None);
|
|
|
|
|
assert!(
|
|
|
|
|
matches!(result, Err(Error::Parse(_))),
|
|
|
|
|
"expected Parse error: {result:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── cancel_timer ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn cancel_timer_returns_no_such_when_missing() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = TimerStore::load(dir.path().join("timers.json"));
|
|
|
|
|
let result = cancel_timer(&store, "421_story_foo");
|
|
|
|
|
assert!(
|
|
|
|
|
matches!(result, Err(Error::NoSuchSchedule(_))),
|
|
|
|
|
"expected NoSuchSchedule: {result:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn cancel_timer_succeeds_when_exists() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = TimerStore::load(dir.path().join("timers.json"));
|
|
|
|
|
let t = Utc::now() + Duration::hours(1);
|
|
|
|
|
store.add("421_story_foo".to_string(), t).unwrap();
|
|
|
|
|
assert!(cancel_timer(&store, "421_story_foo").is_ok());
|
|
|
|
|
assert!(store.list().is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── handle_timer_command ────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn handle_list_empty() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = TimerStore::load(dir.path().join("timers.json"));
|
|
|
|
|
let result = handle_timer_command(TimerCommand::List, &store, dir.path()).await;
|
|
|
|
|
assert!(result.contains("No pending timers"), "unexpected: {result}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn handle_cancel_not_found() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = TimerStore::load(dir.path().join("timers.json"));
|
|
|
|
|
let result = handle_timer_command(
|
|
|
|
|
TimerCommand::Cancel {
|
|
|
|
|
story_number_or_id: "421_story_foo".to_string(),
|
|
|
|
|
},
|
|
|
|
|
&store,
|
|
|
|
|
dir.path(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
assert!(result.contains("No timer found"), "unexpected: {result}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn handle_schedule_story_not_in_backlog_or_current() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
// Ensure CRDT content store is initialised so the DB-first lookup works.
|
|
|
|
|
crate::db::ensure_content_store();
|
|
|
|
|
// No story written — "9950_story_timer_neg" should not be found.
|
|
|
|
|
let store = TimerStore::load(dir.path().join("timers.json"));
|
|
|
|
|
let result = handle_timer_command(
|
|
|
|
|
TimerCommand::Schedule {
|
|
|
|
|
story_number_or_id: "9950_story_timer_neg".to_string(),
|
|
|
|
|
hhmm: "14:30".to_string(),
|
|
|
|
|
},
|
|
|
|
|
&store,
|
|
|
|
|
dir.path(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
assert!(
|
|
|
|
|
result.contains("not in backlog or current"),
|
|
|
|
|
"unexpected: {result}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn handle_schedule_accepts_backlog_story() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let backlog_dir = dir.path().join(".huskies/work/1_backlog");
|
|
|
|
|
std::fs::create_dir_all(&backlog_dir).unwrap();
|
|
|
|
|
std::fs::write(
|
|
|
|
|
backlog_dir.join("421_story_foo.md"),
|
|
|
|
|
"---\nname: Foo\n---\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
let store = TimerStore::load(dir.path().join("timers.json"));
|
|
|
|
|
let result = handle_timer_command(
|
|
|
|
|
TimerCommand::Schedule {
|
|
|
|
|
story_number_or_id: "421_story_foo".to_string(),
|
|
|
|
|
hhmm: "14:30".to_string(),
|
|
|
|
|
},
|
|
|
|
|
&store,
|
|
|
|
|
dir.path(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
assert!(
|
|
|
|
|
result.contains("Timer set"),
|
|
|
|
|
"backlog story should be accepted: {result}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn handle_schedule_success() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let current_dir = dir.path().join(".huskies/work/2_current");
|
|
|
|
|
std::fs::create_dir_all(¤t_dir).unwrap();
|
|
|
|
|
std::fs::write(current_dir.join("421_story_foo.md"), "---\nname: Foo\n---").unwrap();
|
|
|
|
|
let store = TimerStore::load(dir.path().join("timers.json"));
|
|
|
|
|
let result = handle_timer_command(
|
|
|
|
|
TimerCommand::Schedule {
|
|
|
|
|
story_number_or_id: "421_story_foo".to_string(),
|
|
|
|
|
hhmm: "23:59".to_string(),
|
|
|
|
|
},
|
|
|
|
|
&store,
|
|
|
|
|
dir.path(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
assert!(result.contains("Timer set for"), "unexpected: {result}");
|
|
|
|
|
assert_eq!(store.list().len(), 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn handle_schedule_invalid_time() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let current_dir = dir.path().join(".huskies/work/2_current");
|
|
|
|
|
std::fs::create_dir_all(¤t_dir).unwrap();
|
|
|
|
|
std::fs::write(current_dir.join("421_story_foo.md"), "---\nname: Foo\n---").unwrap();
|
|
|
|
|
let store = TimerStore::load(dir.path().join("timers.json"));
|
|
|
|
|
let result = handle_timer_command(
|
|
|
|
|
TimerCommand::Schedule {
|
|
|
|
|
story_number_or_id: "421_story_foo".to_string(),
|
|
|
|
|
hhmm: "99:00".to_string(),
|
|
|
|
|
},
|
|
|
|
|
&store,
|
|
|
|
|
dir.path(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
assert!(result.contains("Invalid time"), "unexpected: {result}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn handle_cancel_existing_timer() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = TimerStore::load(dir.path().join("timers.json"));
|
|
|
|
|
let future = Utc::now() + Duration::hours(1);
|
|
|
|
|
store.add("421_story_foo".to_string(), future).unwrap();
|
|
|
|
|
let result = handle_timer_command(
|
|
|
|
|
TimerCommand::Cancel {
|
|
|
|
|
story_number_or_id: "421_story_foo".to_string(),
|
|
|
|
|
},
|
|
|
|
|
&store,
|
|
|
|
|
dir.path(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
assert!(result.contains("cancelled"), "unexpected: {result}");
|
|
|
|
|
assert!(store.list().is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn handle_list_with_entries() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = TimerStore::load(dir.path().join("timers.json"));
|
|
|
|
|
let future = Utc::now() + Duration::hours(1);
|
|
|
|
|
store.add("421_story_foo".to_string(), future).unwrap();
|
|
|
|
|
let result = handle_timer_command(TimerCommand::List, &store, dir.path()).await;
|
|
|
|
|
assert!(result.contains("421_story_foo"), "unexpected: {result}");
|
|
|
|
|
assert!(result.contains("Pending timers"), "unexpected: {result}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── firing a timer for a backlog story moves it to current ───────────
|
|
|
|
|
|
|
|
|
|
/// When a timer fires for a story in backlog, the tick loop calls
|
|
|
|
|
/// `move_story_to_current` before `start_agent`. This test exercises
|
|
|
|
|
/// that exact sequence (minus the agent pool) to prove the story ends
|
|
|
|
|
/// up in `2_current/` after firing.
|
|
|
|
|
#[test]
|
|
|
|
|
fn fired_timer_for_backlog_story_moves_to_current() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let root = dir.path();
|
|
|
|
|
let backlog = root.join(".huskies/work/1_backlog");
|
|
|
|
|
let current = root.join(".huskies/work/2_current");
|
|
|
|
|
fs::create_dir_all(&backlog).unwrap();
|
|
|
|
|
fs::create_dir_all(¤t).unwrap();
|
|
|
|
|
let content = "---\nname: Foo\n---\n";
|
|
|
|
|
fs::write(backlog.join("421_story_foo.md"), content).unwrap();
|
|
|
|
|
crate::db::ensure_content_store();
|
2026-05-13 11:22:57 +00:00
|
|
|
crate::db::write_content(crate::db::ContentKey::Story("421_story_foo"), content);
|
2026-04-24 17:39:42 +00:00
|
|
|
|
|
|
|
|
// Add a past timer so take_due returns it immediately.
|
|
|
|
|
let store = TimerStore::load(root.join("timers.json"));
|
|
|
|
|
let past = Utc::now() - Duration::seconds(1);
|
|
|
|
|
store.add("421_story_foo".to_string(), past).unwrap();
|
|
|
|
|
|
|
|
|
|
// Drain due timers — same as the tick loop does.
|
|
|
|
|
let due = store.take_due(Utc::now());
|
|
|
|
|
assert_eq!(due.len(), 1, "expected one fired timer");
|
|
|
|
|
|
|
|
|
|
// Apply the move-to-current step the tick loop performs.
|
|
|
|
|
for entry in &due {
|
2026-04-27 19:51:27 +00:00
|
|
|
crate::agents::lifecycle::move_story_to_current(&entry.story_id)
|
2026-04-24 17:39:42 +00:00
|
|
|
.expect("move_story_to_current should succeed for backlog story");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Story must still be accessible in the content store after the move.
|
|
|
|
|
assert!(
|
2026-05-13 11:22:57 +00:00
|
|
|
crate::db::read_content(crate::db::ContentKey::Story("421_story_foo")).is_some(),
|
2026-04-24 17:39:42 +00:00
|
|
|
"story should be in the content store after timer fires"
|
|
|
|
|
);
|
|
|
|
|
// Timer was consumed.
|
|
|
|
|
assert!(
|
|
|
|
|
store.list().is_empty(),
|
|
|
|
|
"fired timer should be removed from store"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|