From 2f645b1ed292c775164b107650fb82a2c68ef4b5 Mon Sep 17 00:00:00 2001 From: HAL Date: Sat, 12 Sep 2026 06:09:04 +0100 Subject: [PATCH] v3.30.6: release claims when recurring tasks advance --- Cargo.lock | 14 ++--- Cargo.toml | 14 ++--- crates/ptask-core/src/tasks.rs | 99 ++++++++++++++++++++++++++++++++-- docs/gen/pt.1 | 4 +- docs/recurrence.md | 29 +++++----- 5 files changed, 128 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fd6d2875..76659ad0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2649,7 +2649,7 @@ dependencies = [ [[package]] name = "ptask-bot" -version = "3.30.5" +version = "3.30.6" dependencies = [ "anyhow", "jiff", @@ -2665,7 +2665,7 @@ dependencies = [ [[package]] name = "ptask-cli" -version = "3.30.5" +version = "3.30.6" dependencies = [ "anyhow", "axum", @@ -2693,7 +2693,7 @@ dependencies = [ [[package]] name = "ptask-core" -version = "3.30.5" +version = "3.30.6" dependencies = [ "hex", "interim", @@ -2714,7 +2714,7 @@ dependencies = [ [[package]] name = "ptask-distill" -version = "3.30.5" +version = "3.30.6" dependencies = [ "anyhow", "candle-core", @@ -2737,7 +2737,7 @@ dependencies = [ [[package]] name = "ptask-notify" -version = "3.30.5" +version = "3.30.6" dependencies = [ "lettre", "ptask-core", @@ -2750,7 +2750,7 @@ dependencies = [ [[package]] name = "ptask-server" -version = "3.30.5" +version = "3.30.6" dependencies = [ "anyhow", "axum", @@ -2777,7 +2777,7 @@ dependencies = [ [[package]] name = "ptask-tui" -version = "3.30.5" +version = "3.30.6" dependencies = [ "anyhow", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index b0d40e5f..b3d24d14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ resolver = "2" [workspace.package] -version = "3.30.5" +version = "3.30.6" edition = "2024" license-file = "LICENSE" authors = ["PureTensor, Inc. "] @@ -52,12 +52,12 @@ rmcp = { version = "3.3.0", features = ["server", "macros", "schemars", "transpo futures-util = { version = "0.3", default-features = false } # Internal crates -ptask-core = { path = "crates/ptask-core", version = "3.30.5" } -ptask-tui = { path = "crates/ptask-tui", version = "3.30.5" } -ptask-server = { path = "crates/ptask-server", version = "3.30.5" } -ptask-bot = { path = "crates/ptask-bot", version = "3.30.5" } -ptask-distill = { path = "crates/ptask-distill", version = "3.30.5" } -ptask-notify = { path = "crates/ptask-notify", version = "3.30.5" } +ptask-core = { path = "crates/ptask-core", version = "3.30.6" } +ptask-tui = { path = "crates/ptask-tui", version = "3.30.6" } +ptask-server = { path = "crates/ptask-server", version = "3.30.6" } +ptask-bot = { path = "crates/ptask-bot", version = "3.30.6" } +ptask-distill = { path = "crates/ptask-distill", version = "3.30.6" } +ptask-notify = { path = "crates/ptask-notify", version = "3.30.6" } # HMAC for outbound webhook signing (v0.3.5). sha2 = "0.10" diff --git a/crates/ptask-core/src/tasks.rs b/crates/ptask-core/src/tasks.rs index fc55db77..61103121 100644 --- a/crates/ptask-core/src/tasks.rs +++ b/crates/ptask-core/src/tasks.rs @@ -668,9 +668,9 @@ pub fn mark_done(db: &Db, task: &Task, ctx: &EventCtx) -> Result { } // Look up the recurrence rule, if any. - let rec_row: Option<(String, String, Option)> = tx + let rec_row: Option<(String, String, Option, String)> = tx .query_row( - "SELECT r.mode, r.original_input, t.deadline + "SELECT r.mode, r.original_input, t.deadline, t.status_v2 FROM pt_recurrence AS r JOIN tasks AS t ON t.id = r.task_uuid WHERE r.task_uuid = ?1", @@ -680,6 +680,7 @@ pub fn mark_done(db: &Db, task: &Task, ctx: &EventCtx) -> Result { r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, Option>(2)?, + r.get::<_, String>(3)?, )) }, ) @@ -689,7 +690,14 @@ pub fn mark_done(db: &Db, task: &Task, ctx: &EventCtx) -> Result { other => Err(other), })?; - if let Some((mode_str, original, current_deadline)) = rec_row { + if let Some((mode_str, original, current_deadline, status)) = rec_row { + // Resolve the current state inside this transaction: callers may + // hold a Task from before another actor dismissed the recurrence. + if matches!(status.as_str(), "done" | "dismissed") { + return Err(crate::Error::Other( + "recurring task is terminal; reopen it before completing an occurrence".into(), + )); + } let rec = crate::recurrence::parse(&original) .map_err(|e| crate::Error::Other(format!("re-parse recurrence: {}", e)))?; let completion_now = crate::dates::now_in_operator_tz()?; @@ -724,7 +732,8 @@ pub fn mark_done(db: &Db, task: &Task, ctx: &EventCtx) -> Result { let next_iso = crate::dates::format_iso(&next_z); tx.execute( - "UPDATE tasks SET deadline=?1, updated_at=?2 WHERE id=?3", + "UPDATE tasks SET deadline=?1, updated_at=?2, status='pending', + status_v2='todo', snoozed_until=NULL WHERE id=?3", params![next_iso, now, task.id], )?; tx.execute( @@ -2692,6 +2701,88 @@ mod tests { .unwrap(); } + #[test] + fn recurring_completion_does_not_resurrect_a_dismissed_task() { + let (_dir, db) = fresh_db(); + let ctx = EventCtx::test(); + let mut new = NewTask::minimal("cancelled recurring work"); + new.deadline = Some("2099-01-01T09:00:00Z".into()); + let task = create_with_extensions( + &db, + new, + Extensions { + recurrence: Some(crate::recurrence::parse("every day").unwrap()), + ..Default::default() + }, + &ctx, + ) + .unwrap(); + dismiss(&db, &task.id, &ctx).unwrap(); + let cursor = crate::event_log::current_cursor(&db).unwrap(); + let schedule = load_detail(&db, &task.id).unwrap().recurrence_next; + assert!( + mark_done(&db, &task, &ctx).is_err(), + "stale Task must not reopen recurrence" + ); + let after = resolve_for_lookup(&db, &task.id, true).unwrap(); + assert_eq!(after.status, "dismissed"); + assert_eq!(after.deadline, task.deadline); + assert_eq!( + load_detail(&db, &task.id).unwrap().recurrence_next, + schedule + ); + assert_eq!(crate::event_log::current_cursor(&db).unwrap(), cursor); + } + + #[test] + fn recurring_completion_releases_prior_claim_and_snooze() { + for rule in ["every day", "every! 5 days"] { + for state in ["claimed", "snoozed"] { + let (_dir, db) = fresh_db(); + let ctx = EventCtx::test(); + let mut new = NewTask::minimal("recurring work"); + new.deadline = Some("2099-01-01T09:00:00Z".into()); + let ext = Extensions { + recurrence: Some(crate::recurrence::parse(rule).unwrap()), + ..Default::default() + }; + let task = create_with_extensions(&db, new, ext, &ctx).unwrap(); + if state == "claimed" { + claim(&db, &task.id, &ctx).unwrap(); + } else { + snooze(&db, &task.id, "2099-02-01", &ctx).unwrap(); + } + let outcome = mark_done(&db, &task, &ctx).unwrap(); + let DoneOutcome::Advanced { next_deadline } = outcome else { + panic!("must recur") + }; + let next = resolve_for_lookup(&db, &task.id, true).unwrap(); + assert_eq!(next.status, "todo", "{rule}, {state}"); + assert_eq!(next.pt_id, task.pt_id); + assert_eq!(next.deadline.as_deref(), Some(next_deadline.as_str())); + db.with_conn(|c| { + let (status, snoozed): (String, Option) = c.query_row( + "SELECT status, snoozed_until FROM tasks WHERE id=?1", + [&task.id], + |r| Ok((r.get(0)?, r.get(1)?)), + )?; + assert_eq!(status, "pending"); + assert!(snoozed.is_none()); + Ok(()) + }) + .unwrap(); + assert_eq!( + load_detail(&db, &task.id) + .unwrap() + .recurrence_next + .as_deref(), + Some(next_deadline.as_str()) + ); + claim(&db, &task.id, &ctx).expect("new occurrence must be claimable"); + } + } + } + #[test] fn mark_done_advances_fixed_recurring_from_current_deadline() { let (_dir, db) = fresh_db(); diff --git a/docs/gen/pt.1 b/docs/gen/pt.1 index 600dc383..c7d00ce1 100644 --- a/docs/gen/pt.1 +++ b/docs/gen/pt.1 @@ -1,6 +1,6 @@ .ie \n(.g .ds Aq \(aq .el .ds Aq ' -.TH pt 1 "pt 3.30.5" +.TH pt 1 "pt 3.30.6" .SH NAME pt \- Sovereign task manager for PureTensor .SH SYNOPSIS @@ -166,4 +166,4 @@ Generate shell completions (bash/zsh/fish) to stdout pt\-help(1) Print this message or the help of the given subcommand(s) .SH VERSION -v3.30.5 +v3.30.6 diff --git a/docs/recurrence.md b/docs/recurrence.md index 538f3623..e4418134 100644 --- a/docs/recurrence.md +++ b/docs/recurrence.md @@ -21,34 +21,39 @@ compiles natural-language input to an RRULE-style string + a mode flag | `every weekday` | `FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR` | fixed | | `every monday` | `FREQ=WEEKLY;BYDAY=MO` | fixed | | `every monday at 9am` | `FREQ=WEEKLY;BYDAY=MO` + time | fixed | -| `every other tuesday` | `FREQ=WEEKLY;INTERVAL=2;BYDAY=TU` | fixed | +| `every mon, wed, fri` | `FREQ=WEEKLY;BYDAY=MO,WE,FR` | fixed | | `every 1, 15, 27` | `FREQ=MONTHLY;BYMONTHDAY=1,15,27` | fixed | -| `every last friday` | `FREQ=MONTHLY;BYDAY=-1FR` | fixed | +| `every 2 months` | `FREQ=MONTHLY;INTERVAL=2` | fixed | | `every! 5 days` | `FREQ=DAILY;INTERVAL=5` | completion | | `every! 2 weeks` | `FREQ=WEEKLY;INTERVAL=2` | completion | -Combine with the date parser for the start anchor: -`every weekday starting tomorrow 9am`. +Use a quick-add recurrence phrase, such as `pt add "Standup every weekday at 9am"`. +The parser does not support `every other tuesday`, `every last friday`, or a +`starting ...` suffix. ## Advancement -On `pt done` for a recurring task, the engine clones the row with a -new `deadline` and a fresh PT-N: +On `pt done` for a recurring task, the engine advances the same row to its +next `deadline`, preserving its UUID, PT-N and history: -- `fixed`: compute the next RRULE occurrence from the **original due**. +- `fixed`: advance from the **current deadline**, skipping missed occurrences + until the next deadline is after the completion time. - `completion`: compute the next RRULE occurrence from **now**. -The completed instance keeps `status='done'`; the clone is `status='pending'`. +The next occurrence is `status_v2='todo'` (legacy `status='pending'`) with +`snoozed_until` cleared. A previous claim ends with completion, so the next +occurrence can be claimed again. A `task.recurrence_advanced` event records +the new deadline; no new task is created. ## Editing ``` -pt edit --recurrence 'every weekday' # set / change -pt edit --recurrence none # stop +pt edit --deadline 2099-01-01 # move the next occurrence ``` -(`pt edit` lands in the v1.0.x polish phase; until then mutate -`pt_recurrence` via `pt view`-driven SQL or wait for the verb.) +This also updates `pt_recurrence.next_occurrence`. Clearing the deadline on +a recurring task is rejected. There is no `pt edit --recurrence` flag; +create recurrence through quick-add, and dismiss the task to stop working it. ## Storage