Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/changes/unreleased/1837-edit-finished-reminder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
kind: fixed
title: a finished reminder moved later with e wakes again, and a save after its new moment is refused
pr: 1837
surface: [chat, engine, docs]
invalidates:
- "The edit line `e` puts in the box wrote a one-time moment to the minute, so a reminder made with `in 2m` came back a few seconds earlier and the next minute in the box could be seconds away. It keeps the seconds (`at 2026-10-14T20:33:13`), a moment in the hour a fall-back repeats is written with its offset, and the line saved unedited is the same moment."
- "`Store.Update` saved a new one-time moment that had already passed while the card was up, so the clock said the reminder at once and the row was finished again. It refuses it with `… has already passed` (to the second when it passed in the current minute), and nothing changes. `Store.Create` is unchanged: an add answered late still runs once, late."
- "A typed edit of a paused automation said `saved … · next …` for a slot it does not run at. It says `saved … · still paused — resume starts it again`."
- "The manual did not say a change can be refused at save time, or that a finished reminder can be renamed. `automations.md` has a section for both."
- "A finished reminder's own edit line was refused as `… has already passed` even when only its title changed. Only a line that moves the moment into the past is refused."
- "The typed edit's card worked its next run out from the schedule alone, so a paused one or an unchanged rhythm promised a run the save did not give. Both doors use `automation.NextAfterChange`, the rule the save keeps."
---
10 changes: 7 additions & 3 deletions docs/design/automations/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,13 @@ The clauses are `title`, `at` (`18:00`, `tomorrow 18:00`, `2026-10-12T09:00`),
`in` (`20m`), `every` (a cron line or an interval), `zone`, `say`, `do`, `look`
(a command), `files` (a glob), `until` (the condition), `time` and `usd` (the
limits), `folder`, and the flags `once`, `worktree` and `checkout`. An id may be
any unique prefix. A moment that has already passed is refused. Every
automation writes back out as the `edit` line that would make it what it is, so
the line `e` puts in the box changes nothing when it is saved unedited.
any unique prefix. A moment that has already passed is refused when it is
typed, and a change is refused again when its card is saved after its new moment
(`Store.Update`; an add answered late still runs once, late). An edit that leaves
a finished reminder's moment where it was is not refused. Every automation writes back out as the
`edit` line that would make it what it is — a moment with seconds keeps them
(`2026-10-12T09:00:27`) — so the line `e` puts in the box changes nothing when
it is saved unedited.

**`/automations`** opens the list: every automation with its mark, title, kind,
schedule, next run and last result. `enter` opens its history (every run: when,
Expand Down
57 changes: 42 additions & 15 deletions internal/automation/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,26 +228,24 @@ func (c Command) Apply(base Automation, now time.Time) (Automation, error) {
a.Workspace = folder
}
}
if !a.Schedule.Repeats() && !a.Schedule.At.IsZero() && !a.Schedule.At.After(now) && c.touchesWhen() {
return Automation{}, fmt.Errorf("%s has already passed", a.Schedule.At.In(loc).Format("Mon 2 Jan 15:04"))
if !a.Schedule.Repeats() && !a.Schedule.At.IsZero() && !a.Schedule.At.After(now) && c.setsWhen(base, a) {
return Automation{}, errPassed(a.Schedule, now)
}
if err := a.Validate(); err != nil {
return Automation{}, err
}
return a, nil
}

// touchesWhen reports whether the line set the schedule, which is the only time
// a moment in the past is the person's mistake rather than the automation's
// history.
func (c Command) touchesWhen() bool {
for _, cl := range c.clauses {
switch cl.word {
case "at", "in", "every", "zone":
return true
}
}
return c.Verb == VerbAdd
// setsWhen reports whether the line put a's moment where it is, which is the
// only time a moment in the past is the person's mistake rather than the
// automation's history.
//
// AN EDIT LINE CARRIES THE MOMENT IT WAS WRITTEN WITH ([CommandLine]), so `at`
// being in it says nothing: a finished reminder renamed keeps a moment that has
// passed, and only a line that moves the moment is refused for where it put it.
func (c Command) setsWhen(base, a Automation) bool {
return c.Verb == VerbAdd || ScheduleChanged(base.Schedule, a.Schedule)
}

// CommandLine is the `edit` line that makes an automation exactly what it is —
Expand All @@ -268,7 +266,7 @@ func CommandLine(a Automation) string {
if err != nil {
loc = time.Local
}
parts = append(parts, "at", a.Schedule.At.In(loc).Format(momentLayout))
parts = append(parts, "at", momentWord(a.Schedule.At.In(loc)))
}
if look := a.Look; look != nil {
switch {
Expand Down Expand Up @@ -307,6 +305,35 @@ func CommandLine(a Automation) string {
// clock in the schedule's zone, to the minute.
const momentLayout = "2006-01-02T15:04"

// momentSecondsLayout is a moment written to the second and below. Reading it
// takes any fraction of a second after the seconds, as Go's parsing does.
const momentSecondsLayout = "2006-01-02T15:04:05"

// momentWord writes a moment the way [parseMoment] reads it back as the same
// instant: to the minute when that is all it has, and otherwise with its
// seconds and their fraction.
//
// A WALL CLOCK THAT NAMES TWO INSTANTS CARRIES ITS OFFSET. In the hour a
// fall-back repeats, `01:30` reads as the first one, so a moment in the second
// is written as RFC 3339 with its offset, which [parseMoment] reads exactly.
//
// A MOMENT WITH SECONDS KEEPS THEM. `in 2m` and the conversation's `when.in`
// make moments with seconds in them, and an edit line written to the minute
// moved the moment it was meant to keep — earlier, so the next minute in the
// box could be seconds away, and a save answered a beat late stored a moment
// that had already passed.
func momentWord(at time.Time) string {
layout := momentSecondsLayout + ".999999999"
if at.Second() == 0 && at.Nanosecond() == 0 {
layout = momentLayout
}
word := at.Format(layout)
if back, err := time.ParseInLocation(layout, word, at.Location()); err != nil || !back.Equal(at) {
return at.Format(time.RFC3339Nano)
}
return word
}

// parseMoment reads a moment the way a person types one: a time of day —
// `18:00`, `6pm`, `6:30pm`, `18` — today, or after `today`, `tomorrow` or a
// day of the week (`monday 9am`, `fri 17:30`, the next one to come); a date and
Expand All @@ -317,7 +344,7 @@ func parseMoment(text string, now time.Time, loc *time.Location) (time.Time, err
if at, err := time.Parse(time.RFC3339, text); err == nil {
return at, nil
}
for _, layout := range []string{momentLayout, "2006-01-02 15:04"} {
for _, layout := range []string{momentLayout, momentSecondsLayout, "2006-01-02 15:04", "2006-01-02 15:04:05"} {
if at, err := time.ParseInLocation(layout, text, loc); err == nil {
return at, nil
}
Expand Down
116 changes: 116 additions & 0 deletions internal/automation/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,119 @@ func TestAFolderUnderHomeIsKeptForTheStoreToRead(t *testing.T) {
t.Fatalf("the store kept %q, want this machine's %q", made.Workspace, filepath.Join(house, "app"))
}
}

// A MOMENT KEEPS ITS SECONDS ON THE WAY ROUND. `in 2m` and the conversation's
// `when.in` make moments with seconds in them; an edit line written to the
// minute moved the reminder by them, so the box put a different moment in front
// of the person than the one it would run at.
func TestCommandLineKeepsTheSecondsOfAMoment(t *testing.T) {
now, loc := commandNow(t)
for _, at := range []time.Time{
time.Date(2026, 10, 10, 14, 32, 27, 0, loc),
time.Date(2026, 10, 10, 14, 32, 27, 481_000_000, loc),
time.Date(2026, 10, 10, 14, 33, 0, 0, loc),
} {
original := Automation{ID: "r", Title: "stretch", Workspace: "/work/project", Schedule: Schedule{At: at, Zone: "America/Toronto"}, Action: Action{Say: "stand up"}}
line := CommandLine(original)
cmd, err := ParseCommand(line)
if err != nil {
t.Fatalf("%s: %v", line, err)
}
back, err := cmd.Apply(original, now)
if err != nil {
t.Fatalf("%s: %v", line, err)
}
if !back.Schedule.At.Equal(at) || ScheduleChanged(original.Schedule, back.Schedule) {
t.Fatalf("%s moved the moment %v to %v", line, at, back.Schedule.At)
}
}
if line := CommandLine(Automation{ID: "r", Schedule: Schedule{At: time.Date(2026, 10, 10, 14, 33, 0, 0, loc), Zone: "America/Toronto"}}); !strings.HasSuffix(line, " at 2026-10-10T14:33") {
t.Fatalf("a whole minute is written %q, want it to the minute", line)
}
}

// A FINISHED REMINDER'S OWN LINE IS NOT A MOMENT IN THE PAST TYPED BY MISTAKE.
// Its moment is its history, so a line that leaves it where it was — a new
// title, a new line to say — reads, and only a moment moved into the past is
// refused.
func TestAnEditThatKeepsAPassedMomentReads(t *testing.T) {
now, loc := commandNow(t)
finished := Automation{ID: "r", Title: "stretch", Workspace: "/work/project", Status: StatusFinished,
Schedule: Schedule{At: now.Add(-3*time.Minute - 27*time.Second), Zone: "America/Toronto"}, Action: Action{Say: "stand up"}}
cmd, err := ParseCommand(CommandLine(finished) + ` title "stretch again"`)
if err != nil {
t.Fatal(err)
}
edited, err := cmd.Apply(finished, now)
if err != nil {
t.Fatalf("retitling a finished reminder: %v", err)
}
if edited.Title != "stretch again" || !edited.Schedule.At.Equal(finished.Schedule.At) {
t.Fatalf("the edit read as %+v", edited)
}
earlier := finished.Schedule.At.In(loc).Add(-time.Hour).Format("2006-01-02T15:04")
cmd, err = ParseCommand("edit r at " + earlier)
if err != nil {
t.Fatal(err)
}
if _, err := cmd.Apply(finished, now); err == nil || !strings.Contains(err.Error(), "already passed") {
t.Fatalf("a moment moved into the past = %v, want it refused", err)
}
}

// A MOMENT IN THE HOUR A FALL-BACK REPEATS KEEPS WHICH ONE IT IS. Toronto's
// clocks go from 01:59 EDT back to 01:00 EST on Sunday 1 November 2026, so
// `01:30` names two instants; the line carries the offset for the second.
func TestAMomentInARepeatedHourRoundTrips(t *testing.T) {
_, loc := commandNow(t)
second := time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC).In(loc) // 01:30 EST
first := time.Date(2026, 11, 1, 5, 30, 0, 0, time.UTC).In(loc) // 01:30 EDT
for _, c := range []struct {
at, now time.Time
status Status
more string
}{
// Saved unedited while it is still to come.
{second, second.Add(-3 * time.Hour), StatusActive, ""},
{first, first.Add(-3 * time.Hour), StatusActive, ""},
// Renamed after it was said.
{second, second.Add(time.Hour), StatusFinished, ` title "stretch again"`},
} {
original := Automation{ID: "r", Title: "stretch", Workspace: "/work/project", Status: c.status,
Schedule: Schedule{At: c.at, Zone: "America/Toronto"}, Action: Action{Say: "stand up"}}
line := CommandLine(original) + c.more
cmd, err := ParseCommand(line)
if err != nil {
t.Fatalf("%s: %v", line, err)
}
back, err := cmd.Apply(original, c.now)
if err != nil {
t.Fatalf("%s: %v", line, err)
}
if !back.Schedule.At.Equal(c.at) {
t.Fatalf("%s moved the moment %v to %v", line, c.at, back.Schedule.At)
}
}
if line := CommandLine(Automation{ID: "r", Schedule: Schedule{At: first, Zone: "America/Toronto"}}); !strings.HasSuffix(line, " at 2026-11-01T01:30") {
t.Fatalf("the first 01:30 is written %q, want the plain wall clock", line)
}
}

// A MOMENT THAT PASSED IN THIS VERY MINUTE IS SAID TO THE SECOND, so the
// refusal does not read as 20:33 passing at 20:33.
func TestAMomentPassedThisMinuteIsSaidToTheSecond(t *testing.T) {
now, loc := commandNow(t)
now = now.Add(5 * time.Second)
base := Automation{ID: "r", Title: "stretch", Workspace: "/work/project", Schedule: Schedule{At: now.Add(time.Hour), Zone: "America/Toronto"}, Action: Action{Say: "stand up"}}
cmd, err := ParseCommand("edit r at " + now.In(loc).Format("15:04"))
if err != nil {
t.Fatal(err)
}
if _, err := cmd.Apply(base, now); err == nil || !strings.Contains(err.Error(), "14:30:00 has already passed") {
t.Fatalf("a moment passed this minute = %v", err)
}
cmd, _ = ParseCommand("edit r at 14:29")
if _, err := cmd.Apply(base, now); err == nil || !strings.HasSuffix(err.Error(), "14:29 has already passed") {
t.Fatalf("a moment passed before this minute = %v", err)
}
}
56 changes: 53 additions & 3 deletions internal/automation/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,9 @@ func (s *Store) List() ([]Automation, error) {
// Update saves the person's change to an automation's own half — its title,
// schedule, look, action, place and limits. Its status is left alone (that is
// [Store.SetStatus]'s door). A changed schedule is worked out again from now,
// and a finished one-time automation given a new moment wakes again. A watch
// a finished one-time automation given a new moment wakes again, and a new
// moment that has already passed is refused ([errPassed]) — a zone changed
// alone too, which moves a one-time moment's instant as much as a new one. A watch
// whose look or condition changed forgets what it had seen, so its first new
// "yes" speaks.
func (s *Store) Update(a Automation) (Automation, error) {
Expand All @@ -339,9 +341,14 @@ func (s *Store) Update(a Automation) (Automation, error) {
a.Seen, a.Memo = "", ""
}
if ScheduleChanged(current.Schedule, a.Schedule) {
if a.Schedule.Repeats() && a.Schedule.Interval() > 0 {
a.Schedule.Anchor = now
// A MOMENT THAT PASSED WHILE THE CARD WAS UP IS REFUSED, not saved as
// the next run: the clock would take it at once and the reminder the
// person had just moved later would be finished again a beat after
// they saved it.
if !a.Schedule.Repeats() && !a.Schedule.At.After(now) {
return errPassed(a.Schedule, now)
}
a.Schedule = rescheduled(a.Schedule, now)
first, err := a.Schedule.First(now)
if err != nil {
return err
Expand Down Expand Up @@ -1006,6 +1013,49 @@ func ScheduleChanged(a, b Schedule) bool {
return !a.At.Equal(b.At) || strings.TrimSpace(a.Every) != strings.TrimSpace(b.Every) || a.Zone != b.Zone
}

// NextAfterChange is the next run [Store.Update] gives stored saved as changed
// at now. It is the ONE reading a change's card says its next run by, through
// either door — the conversation's change (internal/session's
// automation_tool.go) and the typed edit (internal/tui3's automationscmd.go) —
// so a card cannot promise a next run the save does not give: a new schedule
// worked out again from now, the next run it already has for an unchanged one
// (none for a finished one), and none while it is paused, because a paused
// automation does not run whatever its schedule says.
func NextAfterChange(stored, changed Automation, now time.Time) (time.Time, error) {
switch {
case stored.Status == StatusPaused:
return time.Time{}, nil
case !ScheduleChanged(stored.Schedule, changed.Schedule):
return stored.Next, nil
}
return rescheduled(changed.Schedule, now).First(now)
}

// rescheduled is a new schedule as [Store.Update] keeps it: an interval counts
// from the moment it was changed.
func rescheduled(s Schedule, now time.Time) Schedule {
if s.Repeats() && s.Interval() > 0 {
s.Anchor = now
}
return s
}

// errPassed refuses a one-time moment that is not after now, said in the
// schedule's own zone. A moment in now's own minute is said to the second:
// `20:33 has already passed` read at 20:33:05 looks like a clock disagreeing
// with itself.
func errPassed(s Schedule, now time.Time) error {
loc, err := s.location()
if err != nil {
loc = time.Local
}
layout := "Mon 2 Jan 15:04"
if s.At.Truncate(time.Minute).Equal(now.Truncate(time.Minute)) {
layout += ":05"
}
return fmt.Errorf("%s has already passed", s.At.In(loc).Format(layout))
}

func newID() string {
var raw [8]byte
if _, err := rand.Read(raw[:]); err != nil {
Expand Down
52 changes: 52 additions & 0 deletions internal/automation/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
Expand Down Expand Up @@ -539,3 +540,54 @@ func TestAHistoryKeepsItsNewestRunsAndTheirFolders(t *testing.T) {
t.Fatalf("deleting the automation removed a worktree: %v", err)
}
}

// A FINISHED REMINDER MOVED LATER WAKES AGAIN, and a save that comes after the
// new moment has passed is refused. A card answered late used to store the
// passed moment as the next run, so the clock said the reminder at once and the
// row was finished again a beat after the person saved it.
func TestAFinishedReminderMovedLaterWakesAndALateSaveIsRefused(t *testing.T) {
s, now := openStore(t)
start := *now
finished := func() Automation {
t.Helper()
*now = start
r, err := s.Create(reminder(start.Add(2*time.Minute + 27*time.Second)))
if err != nil {
t.Fatal(err)
}
*now = r.Schedule.At.Add(3 * time.Second)
if _, err := s.Take(r, *now); err != nil {
t.Fatal(err)
}
got, _ := s.Get(r.ID)
if got.Status != StatusFinished {
t.Fatalf("a taken reminder is %q", got.Status)
}
return got
}

moved := finished()
moved.Schedule.At = now.Add(10 * time.Minute).Truncate(time.Minute)
saved, err := s.Update(moved)
if err != nil {
t.Fatal(err)
}
if saved.Status != StatusActive || !saved.Next.Equal(moved.Schedule.At) {
t.Fatalf("a finished reminder moved later is %q, next %v, want active at %v", saved.Status, saved.Next, moved.Schedule.At)
}

// The card went up with the next minute — 30 seconds away — and was
// answered 5 seconds after it.
late := finished()
late.Schedule.At = late.Schedule.At.Truncate(time.Minute).Add(time.Minute)
*now = late.Schedule.At.Add(5 * time.Second)
if _, err := s.Update(late); err == nil || !strings.Contains(err.Error(), "already passed") {
t.Fatalf("a moment that passed while the card was up was saved: %v", err)
}
if due, _ := s.Due(*now); len(due) != 0 {
t.Fatalf("a refused save left the reminder due: %+v", due)
}
if got, _ := s.Get(late.ID); got.Status != StatusFinished || got.Revision != late.Revision {
t.Fatalf("a refused save changed the reminder: %+v", got)
}
}
Loading
Loading