Work planning

Schedule recurring work with the right clock

Recurrence is elapsed days from completion, and it reopens the same node instead of making a new one.

Two rules look alike on a whiteboard and behave nothing alike in production. One says the check happens seven days after the last one was finished. The other says it happens every Monday at nine. TopDo implements the first and only the first: one integer of elapsed days, measured from the moment the work closed, in absolute instants, with no timezone anywhere in the path. Knowing which of the two you actually configured is the difference between a schedule that holds and one that drifts a week without anyone touching it.

A team lead scheduling recurring work on her phone, a laptop on her knees
The interval runs from completion.

Name the rule before you name the field

Write the requirement as a sentence before you open the panel. If the sentence is “re-run the reconciliation seven days after the last completed run”, elapsed time is the rule and TopDo carries it exactly. If the sentence is “re-run it on the first business day of every month”, the rule is a calendar, and nothing in TopDo evaluates calendars.

The distinction is not pedantry. An elapsed-time rule guarantees a gap and never a date. A calendar rule guarantees a date and lets the gap float. Teams that state the requirement as a date and configure it as a gap find out around the fourth cycle, when the two have separated by a week and the configuration has not changed once.

The interface names the rule honestly, which is the cheapest place to catch the mistake. The field on the node panel is labeled “Repeat after completion”, it takes a number of days, and its help line says that each completed occurrence reopens after that many days. Read that label as the specification, because it is one.

The whole schedule is one integer

set_recurrence takes one field beyond the node id: every_days, an integer from 1 to 3650, or null to switch it off. The request schema enforces the bounds and a CHECK constraint on nodes.repeat_every_days mirrors them in the database, so an out-of-range value is refused on whichever path tries to write it.

There is nothing else. No cron expression, no RRULE — a repo-wide grep for rrule returns nothing — no weekday rule, no monthly or nth-weekday rule, no end date, no count limit, and no interval unit other than days. Every question a calendar library exists to answer is simply unanswered here, and that is a design decision rather than a gap waiting to be filled.

Two shapes of node are refused outright. A template, and a node whose state is null — a page rather than work — both fail with “Repeating work must be a task, outside a template”. Recurrence is a property of something that closes, so a thing that never closes cannot carry it.

Setting the interval, and the fields a close changes
PUT /api/v1/nodes/nd_7t9k.../recurrence
{ "every_days": 7 }

-- BEFORE UPDATE trigger, on any state change to closed:
next_due_at = now() + make_interval(days => repeat_every_days)
-- any other state:
next_due_at = NULL

-- what recur-due writes when next_due_at has passed:
state = 'open', occurrence = occurrence + 1, next_due_at = NULL,
claimed_by = NULL, claim_hash = NULL, claim_expires_at = NULL

The clock starts at completion, not on a date

The schedule is not computed when you configure it. It is computed by a database trigger that fires on any state change: when the new state is closed and repeat_every_days is set, next_due_at becomes now() + make_interval(days => repeat_every_days); for every other state the trigger sets it to NULL. set_recurrence applies the same rule at once when the node is already closed.

That anchoring is the load-bearing fact of the whole feature. Close on time and the next occurrence lands on the interval. Close four days late and the next occurrence lands four days later than it otherwise would, and stays shifted. The lateness is absorbed into the schedule rather than corrected out of it, and no catch-up pass exists to pull it back.

For genuine elapsed-time work that is exactly right. A filter replaced 40 days after the last replacement is due 40 days after the last replacement, whenever that happened. For work with an external deadline it is the behavior that will embarrass you in front of an auditor, because the schedule slides with your worst week and keeps the new position.

the ruleClosing sets the next duenow() + every_daysThe same node reopensoccurrence + 1
Two steps, one node, no calendar.

There is no timezone in this path

next_due_at is timestamptz and the arithmetic runs on Postgres now(), so everything in the recurrence path is an absolute instant. There is no per-workspace, per-user or per-node timezone field anywhere in it, and no way to anchor an occurrence to a local time of day.

The practical effect is easy to state. Close a weekly task at 17:40 on a Friday and it comes back at 17:40 UTC the following Friday, whatever local hour that is for each person reading it. Across a daylight-saving boundary the instant is stable and the wall clock moves. The only timezone in the scheduling layer at all is the cron timezone handed to pg-boss, hardcoded to UTC.

If the requirement contains a local hour — “before the 9 a.m. stand-up” — that requirement is not expressible, and no combination of every_days values approximates it. Keep the hour in whatever tool owns appointments, keep the work record and its evidence in TopDo, and link the two by hand rather than encoding the hour into an elapsed-day interval.

Recurrence reopens the node you already have

Earlier copy on this site implied that a completed occurrence produces a new one, so state the correction plainly: recurDue creates nothing. It selects the closed, non-template, live nodes whose next_due_at has passed and, on each one, sets state='open', increments occurrence by one, clears next_due_at, and nulls all three claim columns. Then it writes a node.recurred audit row.

One node, one identifier, one comment thread, one set of edges, indefinitely. The title, the body, the labels, the implements edges into the requirement it serves and the blocks edges that gate it all survive the cycle untouched, because nothing was copied and nothing was replaced. An agent holding the node id from three occurrences ago is still holding a valid id.

Recurrence is a state transition on one node, not a generator of new ones.

Clearing claimed_by, claim_hash and claim_expires_at matters more than it looks. A recurring task that was claimed and completed comes back unclaimed and claimable, so the next cycle never inherits a lease from the last one. claim_work refuses anything that is not open, and reopening is precisely what makes the node claimable again.

If you need a record per cycle, this is the wrong instrument

Because the node is reused, there is no per-occurrence row to attach anything to. The history of cycles lives in exactly two places: the occurrence counter on the node, which starts at 1 and rises by one each time the job reopens it, and the node.recurred audit rows, which the workspace event feed at GET /api/v1/events returns in sequence order.

That is enough to answer “how many times has this run” and “when did each cycle start”. It is not enough to answer “what did we find in March”, because March's comments, attachments in the body and metadata sit on the same node as every other month's, in one stream that later cycles append to and overwrite.

When a cycle needs its own record, build the cycle as a template and call run_process on it instead. That copies the template subtree into a fresh tree joined to the template by a single instance_of edge, and GET /processes/:template_id/runs lists those copies newest first. Price the difference honestly before you choose it: nothing in TopDo schedules that call, a person or one of your agents has to make it on time, and recurrence cannot be set on a template to make it for you.

A one-minute cron drives it, and downtime does not backfill

The driver is a job named recur-due on cron * * * * *, registered with a singleton policy so two ticks never overlap, iterating every workspace on each tick. The resolution is therefore one minute: an occurrence becomes open within a minute of next_due_at rather than at the instant itself, which is worth knowing if anything downstream polls on a shorter period.

When workers are not running — WORKERS off, or the CLI entrypoint, which forces it off — nothing consumes the queues and no cron fires. Rows with a passed next_due_at accumulate quietly. The first tick after workers return reopens every overdue node exactly once, and occurrence rises by one however many intervals elapsed while nothing was listening.

So a week of downtime on a daily task produces one reopening, not seven. If your process needs the missed cycles on the record, write them yourself from the gap in the event feed. The scheduler will not reconstruct them, and no part of the system pretends otherwise.

A changed requirement stops the rollover

Closing is gated before recurrence ever sees the node. When the node's pending_changes map is non-empty, checkClose rejects the close with 409 changes_pending and the message “Review changed requirements before completing this work”. The same guard runs for set_state and for complete_work, so neither a person nor an agent routes around it.

That map is filled by change propagation. When a node that something implements has its title or body edited, content_version bumps and every downstream node records {source_id: revision}. A recurring task whose underlying requirement moved therefore stops rolling over until somebody reads the change and calls acknowledge_changes with the map exactly as stored — a partial map, an extra key or a stale number each fail with 409 requirements_changed.

Treat the block as the feature it is. A weekly control that keeps closing against a requirement that changed underneath it is worse than one that stops and says so. Budget for the second step, though: acknowledge_changes clears the whole map and does not close the node, so the caller still has to call set_state afterwards, and an agent loop that ignores the 409 stalls on the cycle rather than on the work.

Checks to run before you trust the schedule

Run one full cycle through the real path before anything depends on it. Choose an interval you are willing to wait out, close the task, then read the node back over the API and look at the columns rather than at the summary line in the panel.

  1. Set every_days to 1, close the task, and read next_due_at back. Confirm it sits roughly 24 hours after the close, not 24 hours after you saved the interval.
  2. Wait for the tick and read the node again. Confirm the same id came back open, that occurrence rose by one, and that next_due_at is now NULL.
  3. Confirm claimed_by and claim_expires_at are clear — claim_hash is stripped from every API response and is not yours to inspect — then have the agent that does this work call claim_work and succeed on the first attempt.
  4. Close the task four days late on purpose and read next_due_at once more. If the permanent shift is unacceptable to whoever owns the process, the requirement is a calendar rule and does not belong in this field.
  5. Edit the requirement the task implements, then try to close the task. You should get 409 changes_pending; make sure whatever closes this task on a normal cycle handles that code rather than retrying into it.

Every one of those checks reads a column or an error code, not a description of behavior. That is the point. The recurrence path is small enough to verify completely in an afternoon, and small enough that what it does not do — calendars, timezones, end dates, backfill, a record per cycle — belongs on the same page as what it does.