nodwyn.

ADR 0049: Scheduled tasks, one task each, and turn limits a task can do without

  • Status: accepted; turn limits (§2), recorded worktree bases (§3), the log index (§10), console history and totals (§11), and scheduler (§1, §4–§9, §12) implemented 2026-10-15; §4's five repeats replaced by rules, said in the daemon's words, with each schedule's recent runs, 2026-10-17 (amended); login startup built 2026-10-17 as ADR 53's slice 0; §2 replaced 2026-10-27: a task's limits are its own, none unless set (amended)
  • Date: 2026-10-15
  • Amends: 18 (a task's turn limits can be off), 34 (a worktree made again records its new base), 9 (windows are built from a suffix of the log)

Context

The person asked for tasks that run on a schedule, and for a design worked through end to end, the console included. Three things in nodwyn shaped it.

  • There is one way to work (ADR 35): every task is a chat, and the console offers no mode for who answers a run's requests. A schedule must not bring that back.
  • A turn's hard limits count time spent waiting for a person (ADR 18: "The hard wall deadline includes model waits, tools, planning and confirmations"). A task that runs at 03:00 and asks at 03:10 is stopped at 07:10, before anyone is up to answer it.
  • Nothing reads a log from its end. Asked to keep every run of a schedule in one task, so that its transcript grows for as long as the schedule lasts, we looked at what reads a log today (2026-10-15, at a8eec10):
  • the model's window is built from every event on every request (loop/model.go:94), and context-tokens builds it again each tool round (loop/prims.go:1326);
  • resuming a task reads its log twice and keeps every event in memory (core/resume.go:26, 41, 109);
  • every log.read page parses the whole file (core/log_read.go:29), and the console opens a task from seq 1 in pages of 500 (web/assets/task.js:143-157), which is about n²/500;
  • listing tasks parses a log again whenever it has grown (core/store.go:248-293), once per turn end per console;
  • with a spend limit, each turn decodes every llm.resp (core/spend.go).

The person's 78 logs come to 93 MB; the largest is 7.0 MB and 6,849 events from a single message. A daily run of that kind adds 0.4 to 2.5 GB a year to one file.

The person's answers to the first draft (a task per run, a schedules file, a wall clock that stops while waiting): limits become a checkbox on every task, on by default, so that a scheduled task can do without them; a schedule picks a worktree or its folder as Home does; and one task holds every run, its transcript growing, with loading worked out by pre-computation and reading from the end.

Decision

1. A schedule belongs to one task, and every run goes into it

At each time a schedule is due, nodwyn resumes its task if the task is closed and sends the schedule's message, as if the person had typed it. A run is that turn and whatever the person says in reply; between runs the task is a chat like any other. A schedule is made from Home's ask bar (a clock beside the send button turns the ask into a schedule, the task opening at once with every choice in the bar) or with Repeat… on any task. Deleting the task deletes its schedule; archiving it pauses the schedule.

A run is a chat's turn in every respect: Settings › Approvals, the guard, the spend limit and the progress guard apply as they would to the same message typed then. A schedule cannot hold Don't ask, --yolo or an unattended setting.

2. Turn limits are a checkbox on every task, ticked by default

A task's turns stop at Settings' turn limits (tool calls, tokens, actions, time) unless this computer turns them off for that task: on Home as it starts (Limits, beside In a worktree) or in the task between turns (Limits beside the model). Off, a turn has none of the four, and may wait for an answer overnight. What stays:

  • the spend limit, which is the task's and not the turn's;
  • the request and command timeouts, which catch a call that hangs, not long work;
  • the progress guard, which stops a turn that repeats itself or fails over and over.

The choice is a session.limits record (on, actor); the latest one counts, and a task without one has its limits on. Unlike Don't ask, it is kept when the task is resumed, since a scheduled task is resumed for every run. A change applies from the next turn: a turn's wall deadline is its context's from its start. A paired device's tasks start with their limits on, and a device cannot change a task's (ADR 30): the limits are what stops a turn nobody is watching, as Settings' limits are this computer's.

In the budget a hard limit of 0 is none, in every dimension, as it already was for spend (budget.Start sets no deadline without a wall limit). config.toml still requires positive turn limits: only a task's choice makes them 0.

3. Worktree or folder is the task's choice

As on Home. In a worktree, a run that left nothing to keep has its worktree removed when the task closes, and the next run makes it again from the base branch's current head (core/worktree.go:290-297), so each run starts from today's main; a run that left unlanded changes keeps its branch, and the next run goes on with it. Making a worktree again records its new base commit, which Review and the close check then use: today they compare with the commit the worktree was first made from.

4. Five repeats, in this computer's local time

Once, every hour, every day, weekdays, every week. The daemon computes the next times (schedule.next) and the console shows them. A minute a change to summer time skips runs at the next valid minute; an hour it repeats runs once.

5. A missed time runs once, late, and says so

The scheduler is in the daemon and ticks every 30 seconds against the wall clock: a long timer does not count time while Linux is suspended. A time that passed while nothing ran (the computer asleep, the app closed) runs once at the next chance, standing for every time missed, and the run says how late it started and how many times it stands for. A lock in the data directory keeps a second daemon from firing the same time.

6. A task busy when its time comes skips that time

When the task's turn is running, an answer is pending, or another nodwyn has it open, the time is skipped and recorded (schedule.skip, with why), as a line in the transcript. A person chatting in an idle task sees the run's message arrive.

7. A scheduled task closes after 15 idle minutes

Closing frees its interpreter, programs and MCP containers and removes a worktree with nothing to keep (§3); the 15 minutes leave time to read a run and reply. It is resumed for the next run.

8. Each run starts from a handover written while the cache is warm

When a turn in a scheduled task ends idle and its conversation since the last handover is over a quarter of the window, nodwyn asks for a note at once, with compact-note focused on what the next run needs: the request reuses the prefix the provider has just cached. The note is recorded (context.handover) and not applied. When the next scheduled message arrives, nodwyn narrows to it (a lasting context.narrow and the handover message) and sends the message. A person who replies to a run has the whole conversation, as in any chat; the next run has the note, not a day-old prefix to read again at full price. A turn that ended with something pending leaves no note, and the run starts as any message does.

9. The log holds the schedule and every run

session.schedule holds the definition (message, repeat, time, paused, spend limit per run, actor; the latest counts), schedule.run marks a run (due, started, how many times missed, whether by hand) and schedule.skip a time skipped or a run that could not start. The next due time is computed from them. There is no schedules file: the records are written by the nodwyn process holding the log's lock, in nodwyn's data directory and never in a repository, as ADR 39 argues for names and archiving. A scheduled task's spend limit counts per run.

10. A derived index makes a long log cheap to read

Each log gets <id>.idx beside it: listing info, running totals, the byte offset where each turn and run starts (and one every 256 events), the latest choices, and where the model's window starts. Whichever nodwyn process reads the log folds in what the log gained beyond the index and writes the index back, as a temporary file renamed into place. (Changed while building it, 2026-10-15: the draft had the process holding the log's lock keep it as it appends, and a mark every 1,000 events. Readers keeping it leave the log's writer as it was, and two that race each wrote an index of the same log; a mark every 256 events keeps a page's read short.) A reader checks the line at the index's end, and at each offset it seeks to, against the log and rebuilds the index from the log on any mismatch. Nothing is recorded only in the index: it is a revocable view (SPEC, invariant 1). Listing, log.read, resume, the window and the spend limit read the index and the suffix they need; resume keeps in memory only the events from where the window starts.

11. The console opens a task at its latest run

It pages back by runs, folds older runs to their last reply and builds their content when opened, and takes totals, spend, cache rate and Changes from the daemon (session.totals) rather than walking every event.

12. Schedules are made on this computer

Setting or changing a schedule and turning a task's limits off are refused to a paired device; pausing a schedule and running it now are not, since they use only choices this computer made. The model has no primitive that schedules: persistence is what an injected instruction wants most.

Consequences

  • A task can run, and wait, without a turn limit. The spend limit is then the fence on cost; nothing sets one by default.
  • session.info's budgets can report a hard limit of 0 for the turn dimensions, meaning none, as cost already could; clients read it so (the console's Details › Budget and Statistics, the TUI's :budget).
  • The index and reading from the end (§10, §11) serve every long task, not only scheduled ones: the 7 MB single-message task opens today in 14 pages that each read 7 MB.
  • A log can still grow without end; splitting one into several files is left until a single log passes a few GB, since every reader of logs would have to learn it.

Not built

Built on 2026-10-15: §3's recorded base (ADR 34, amended the same day); §2, a task's turn limits, with the budget's hard limit of 0 as none; session.open {limits}, session.limits.set and limits in session.info; the session.limits record, kept on resume; Home's Limits, the task's Limits and its No turn limits badge; a line in the conversation for each choice, in the console and the TUI; the TUI's :budget for such a task. Tests: internal/budget, internal/core (limits_test.go), internal/frontend/rpc (limits_test.go), internal/frontend/tui, internal/frontend/web, and the tasks and remote browser checks.

Also built on 2026-10-15, the first part of §10: <id>.idx, with the listing (core.Store.Info, so session.list) and log.read pages read through it, folded in from the end and checked against the log; it does not yet hold totals or turn marks, which nothing reads yet. Tests: internal/core (log_index_test.go, against a whole read for synthetic and recorded logs and stale indexes; log_end_test.go, a 50,000-event log whose first half is unreadable).

Also built on 2026-10-15, the rest of §10 but the console's totals: resuming a task opens its log from the index's end (log.OpenAt, which reads only what follows it and cuts a torn last line there) and holds the events from the earliest point any reader needs, rounded down to a mark: the window's start (window.StartTracker, ADR 9 amended), the latest three requests and the latest that is not a /compact (the planner, the judge, request-context, /compact), the end of the latest turn that finished (an unfinished plan), and a run's start; the whole log when it has a summary. The task's model, judge, helpers and limits come from the index's latest records of their kinds, and the spend limit starts from the index's running sum at the mark. Front ends replaying the conversation (the TUI's own process, nodwyn --resume) read it from the log (Session.History). A session left open keeps every event it records, as before: memory falls back to the end of its log when it next resumes, which a scheduled task does for every run (§7). Tests: internal/window (start_test.go), internal/core (resume_suffix_test.go, a 2,000-event log held from its last compaction), internal/log (OpenAt), and the TUI and line mode replaying a resumed task whole.

Not built, in the order proposed: §1, §4–§9 and §12, the schedules themselves; then a login item that keeps the daemon running when no window is open. Not proposed: the model proposing a schedule, cron expressions, splitting a log into several files.

Amendment 2026-10-15: Index totals and boundaries reach the console

The index now folds model and judge spend, unknown costs, cache-read token sums, request speed, running and working clocks, answered model waits, merged tool intervals minus human waits, the latest allowed preview per path and attachment metadata (never extracted text). Unfinished pairs stay in the fold so extending a saved index gives the same totals as rebuilding it. End and resume records drop abandoned pairs and exclude crash downtime; open nested tools count their completed portion once, including when a result arrives before its outer call ends.

Each submitted turn and each existing CLI driver's run start has its seq, time and byte offset indexed. The driver's run.turn and following user.msg are one turn. log.read uses these boundaries when closer than its sparse mark and checks the sought line's seq as before. session.totals reads the derived sums, counts and latest boundaries without resuming the task or replaying its old prefix; the bridge offers it as a read method. Legacy previews and attachment metadata are redacted for display.

The console consumes it for whole-task totals, judge usage, summary spend and cache rate, approved previews and attachment chips; recorded events refresh it, live clocks extend only while the task is busy, and a reply to a former task cannot overwrite the one now shown. Statistics shows turn and CLI run counts and latest-start tooltips. Older daemons without the method retain the full-log calculation. The index version changed, so old indexes rebuild automatically.

This completes §10 for existing record kinds and the totals part of §11. Not built in this slice: scheduled run records (§9, with the scheduler), paging back by runs, folding and lazily building older conversations (§11), and all scheduling behaviour in §1, §4–§9, §12. The console still loads the whole conversation. The model's window still follows the suffix rules of the earlier §10 implementation.

Checks: core totals with fixed timestamps, growth, rebuild, torn tails, legacy redaction, nested tool waits and crash downtime; totals and boundary pages with the old log prefix unreadable; RPC totals for open and closed tasks and invalid parameters; tasks and transcript browser checks for indexed totals after a reply and reopening, summaries and Changes without their old conversation, and stale responses after switching tasks.

Amendment 2026-10-15: Latest-run conversation and paging earlier runs

The console now selects the latest run when opening a task with run records, using latest_run in session.subscribe or log.read. The selection uses the subscription's fixed watermark while delivery is paused; continuation pages use that same inclusive end. Inherited setup records feed the displayed model and context high water without replaying old choices as new messages.

log.runs pages backwards over the same fixed prefix. Its bounded, redacted previews hold the last non-empty model reply, capped at 2,000 Unicode characters and an ellipsis, with redaction before clipping so a secret straddling the cap cannot expose its prefix. The index keeps those previews and the setup each run inherited; neither the preview list nor opening the latest run replays the old prefix. It remains a revocable view in nodwyn's data directory, written by the same readers as before, with no new repository-controlled authority. The index version changed so older views rebuild. The first run includes startup records; submitted conversation before it is an earlier group. Without any run records the task remains one whole conversation.

Earlier runs fetches five previews at a time, inserted in chronological order without moving the reader's existing anchor. They remain collapsed to their last reply. Expanding one fetches only its fixed range, in bounded event pages, then builds its own transcript; reopening reuses it. Historical choices, receipts and turn records cannot change the current model, prompts, pending operations or gauges. Failed preview reads leave the button available; a failed run read offers Retry. Replies arriving after a task switch are discarded. Whole-task totals, spend, cache rate and Changes still come from session.totals. Log and Plan record inspectors label their loaded latest-run scope. Older daemons without log.runs retain the full-history display.

This completes §11 for existing run records: latest-run opening, backwards paging, collapsed last replies, lazy older contents and whole-task totals. Current runs are CLI driver starts, with all their turns and human follow-ups until the next start. Scheduled run boundaries will join the index with §9; §1, §4–§9 and §12, then the daemon's login item, remain unbuilt. This slice adds no scheduling behaviour.

Checks: core fixed prefixes, redacted bounded previews, index rebuilds, conversation preceding runs and suffix reads with an unreadable old prefix; RPC latest-run subscriptions with a held fake model, fixed catch-up and live sequence delivery; tasks browser checks for closed and resumed history, multiple preview pages, expansion spanning event pages, literal untrusted text and live follow-ups; transcript checks for failed reads, retry and stale responses after switching tasks.

Amendment 2026-10-15: Scheduler and console management

This implements §1, §4–§9 and §12. Home’s clock creates the task with all ordinary choices before recording its schedule, without sending immediately. Repeat… adds a schedule to an existing task; Schedule… reads and edits it, pauses, resumes, runs now or removes it. Home lists schedules. Archive pauses; Unarchive leaves it paused; deletion removes the log and schedule together. Run now works while paused and consumes no automatic time. Unpausing begins after now, skipping the paused interval. Same-time edits preserve progress; retiming starts a new series. Paired devices may pause and run now; unpausing, like other changes, is local-only. The bridge and daemon both enforce this.

Civil recurrences use the daemon’s local timezone. The preview and saved view supply five ISO times with offsets; the console retains those offsets rather than converting them to a paired browser’s timezone. A skipped minute advances to the next valid one, and repeated minutes choose their first occurrence. Startup and 30-second wall-clock ticks coalesce missed times, claim their interval with schedule.run before model work, and record busy, pending-answer and failed-dispatch skips. One scheduler.lock flock in the data directory excludes other daemon dispatchers. A second daemon may serve clients and acquires scheduling if the first releases its lock.

Clarification to §6: a log held by another process cannot also receive a skip. The daemon defers that skip in memory, extending its interval while ownership remains elsewhere, then appends it after release. If this daemon stops before release, that unrecorded skip is lost and restart treats the interval as missed. No separate authority file is introduced to bypass the log lock. The schedule, progress, spend base and handover remain log records written through normal redaction by the process owning the log, in nodwyn’s data directory. Nothing in a repository enables a timer.

A run clears Don’t ask and uses normal chat approvals, refusing unattended or yolo options. Its spend base and limit come from schedule.run and survive resume; human follow-ups share that run’s cost. The next run resets it. Whole-task totals remain whole-task; open tasks also show This run and use its amount for spend warnings. Unknown prices remain uncounted as before. After 15 idle minutes with no pending answer the ordinary close path frees resources and clean worktrees. Successful turns over a quarter-window since the last handover call compact-note under the same budget, with tools skipped. context.handover records the note without narrowing the human conversation; the next scheduled message narrows to it. The index retains the latest schedule, run and handover and treats schedule.run as a history boundary.

Checks: calendar coalescing, all five repeats and summer-time gaps/folds including Lord Howe; core per-run spend across resume, handover versus human context and clearing Don’t ask with an actual confirmation; daemon startup, coalescing, busy/external-log skips, idle closing and scheduler lease transfer; RPC creation/removal, pause/resume authority, manual runs and archive; browser creation without send, daemon preview, persistence, pause/edit/resume, run now with an approved write, removal and phone layout.

No login item is built. The model proposing schedules, cron expressions and log splitting remain outside the proposal.

Amendment 2026-10-16: the handover's exchange stays out of the conversation

The handover note was written by asking the model in the live conversation: the ask (compaction's summary prompt) and the note were recorded as a harness message and an ordinary reply, so a person's follow-up in the scheduled task's chat sent both to the model, and the note became the agent's last answer (request-context, FinalReply), which the plan gate then told its explorer and planner. context.handover now records from, the first event of that exchange, beside through, and window.WithoutHandovers leaves the events between them out of later windows, request-context and the last answer. The next run still starts from the note. The console still shows the note as it is written, after the run's answer; that is not changed here. The regression is in TestScheduledHandoverWaitsForNextRun.

Amendment 2026-10-16: a run starts from today's base even when ignored files stayed

§3 promises that a run that left nothing to keep has its worktree removed, so the next run starts from the base branch's current head. Close keeps a worktree that holds any ignored file, as ADR 34 requires, and almost every real run leaves some (__pycache__, .pytest_cache, node_modules, an attachment), so the worktree stayed and every later run worked on its first run's base. A scheduled run now starts by bringing its worktree up to the local base branch's head by fast-forward when it has nothing changed or untracked and no commit the base lacks; its ignored files stay where they are, and the log says it moved. A worktree with work of its own starts where it was left. The base branch is the local one: the scheduler fetches nothing. The regression is TestScheduledRunStartsFromTodaysBase.

Amendment 2026-10-16: a note is applied only when it covers the conversation; only conversations hand over

§8 narrowed every scheduled message to the last note, though a note is prepared only when more than a quarter of the window has been added since the last one. A run or a human follow-up below that size was in no note, and the next narrowing dropped it from the model's memory: a daily "compare today with yesterday" lost yesterday. A note is now applied only when nothing of the conversation (user and harness messages, replies, tool results) was written after the note's through; otherwise the conversation stays whole until a later note covers it. The cost is that a run that follows a follow-up is not narrowed. Handover preparation also moved from the end of every turn's work to the end of a model turn, so an evaluation, a policy read or a friction read on a scheduled task no longer makes a model request or holds the task busy. The regressions are TestScheduledSmallRunsSurviveTheNextRun and TestAnEvaluationOnAScheduledTaskPreparesNoHandover.

Amendment 2026-10-16: an idle task closes outside the lifecycle lock

§7 closes a scheduled task after 15 idle minutes, and the scheduler did it inside its tick, which holds the lock every request to the daemon takes. Closing stops the task's containers (up to 30 seconds each) and asks git about its worktree (up to two minutes), so a console action, a send or a switch of task waited that long. The task now leaves the daemon's table and is marked as resuming, the lock is released while it closes, and an open or a resume of it meanwhile waits for the close (as it already waits for another resume), so there is never a second session on the same log. The per-tick scan of every stored task is not changed. The regression is TestIdleCloseDoesNotHoldTheLifecycleLock, whose task's MCP server holds the end of its session to make the close slow.

Amendment 2026-10-16: the TUI and line mode still replay a resumed task whole

Resume reads only the tail of the log (§10), but Session.History, which the TUI (tui/backend.go) and line mode (plain/chat.go) call to show the conversation on open, reads and redacts every event. For a daily task whose log has grown to gigabytes that is seconds and memory before the first screen, though the console pages through ReadLatestRun and ReadLog. This is not changed here: starting from the latest run in those two clients means a way to ask for earlier runs in each of them (a command and its rendering in a terminal, with a test in each), which is more than a bounded fix, and docs/clients.md already lists it as a deliberate gap. The way to close it is Store.ReadLatestRun for the first screen and ReadLog pages behind a :earlier command, keeping History for a session whose log is not a file; until then nodwyn replay and the console are how to read a task whose log is that large.

Amendment 2026-10-17: a timetable you can say, in the daemon's words

§4 offered five repeats from one local minute. The person could not say "every 30 minutes", "every 30 minutes in working hours" or "Tuesdays and Thursdays at 15:00", and asked for those, for windows, monthly times and an end date, for the daemon to say when in words, for a schedule's run history, and for the zone the console names "Local" to be named. A review of the console (2026-10-17) also found the same next time written three ways (Home's "Next 2026-10-18 16:51 -07:00 · Local", the dialog's "tomorrow 4:51 PM, then every day" and its raw "daily"), the phrase built in the console's JavaScript, which the TUI would have had to build again.

Decision

  1. A schedule's timing is a rule (schedule.Rule), in the daemon's local civil time. A rule runs once at a minute, or on days and at times of day: the days are every day, chosen weekdays, dates of the month, or the nth (or last) of one weekday in the month; the times are up to 48 set times, or an interval of 5 minutes to 24 hours counted from a window's start (midnight without one) through its end, inclusive. Counting from the window's start each day makes every 30 minutes fall on :00 and :30 and every 45 minutes from 09:00 fall at 09:00 again the next day, which a person can predict; an interval counted from the last run drifts. from is the first minute it may run, filled in with now when it is missing, and until its last day. Five minutes is the shortest interval: each run is a model's turn. A window that crosses midnight is refused rather than given a day it belongs to. Cron expressions stay out, as §4's last paragraph had them: every time the person named fits a rule, and a rule reads as a sentence.
  2. A schedule from before rules keeps its times. Its repeat and at are read as the rule they meant: once, every 60 minutes between the minute's first and last hour of each day for hourly, and set times on every day, weekdays or the first time's weekday. No log is rewritten. TestRepeatsFromBeforeRulesKeepTheirTimes compares 400 times of each against the old arithmetic, kept in the test as its oracle, across summer time in Los Angeles, Lord Howe (half an hour), Santiago (at midnight), London and Kolkata. The old arithmetic gave a skipped hour's minute twice and its search for the next time gave it once; a rule gives it once.
  3. Times are stepped through day by day in civil time, each minute resolved as before (a skipped minute to the next valid one, several becoming one; a repeated minute to its first occurrence). Looking back for a repeated minute is done only when the offset fell in the three hours before it, so a week asleep with a 5-minute interval coalesces in one pass.
  4. A new timing starts after now. A change that keeps the timing (the same rule in normal form: days in the week's order, times and dates sorted, each once) keeps the schedule's progress, as before; a new timing used to start its series at its first minute, and a first minute in the past ran once, late, for every time since. Under the new timing those times were never due. A brand-new schedule still starts at its first minute, which is now unless chosen, so the console no longer sets it a minute ahead and runs a new daily schedule at once.
  5. The daemon says it in words, and every client prints them. schedule.next and schedule.read return the rule's sentence, next_words (each next time as a label in the daemon's zone, the year added when it is not this one) and timezone, the zone's name from TZ or /etc/localtime: Go names the computer's zone "Local". session.schedule records carry words, the sentence when written, so a transcript shows a past record as it was said then. The console prints these and works out no times; the TUI can print the same when it shows schedules. The words are English with a 24-hour clock, whatever the browser's locale.
  6. A schedule's recent runs are folded into the log index beside the schedule: the latest twenty scheduled times, each a run (when, how its own turn ended, what its known requests cost, a person's follow-ups in it included, since they share its spend limit) or a time skipped and why. schedule.read gives them newest first with a label and words ("Finished (Run now)", "Skipped: task is busy", "Running"). The index version moved to 7, so older indexes rebuild. The index stays a revocable view: every field is folded from schedule.run, schedule.skip, turn.end and the requests' costs in the log.
  7. A run is refused only when the schedule changed. executeSchedule compared the definition it was handed with the one read after resuming the task, as values. A rule is held by pointer, so every schedule with a rule would have been called changed and skipped: it now compares the record's sequence. TestSchedulerRunsARule is the regression.

Not built

A zone chosen per schedule (the rule runs in the daemon's zone); schedules in the TUI (it shows neither a session.schedule record nor a run's marker, docs/clients.md); a cost estimate per day in the editor; event triggers and workflows, which the proposal of the same day keeps for later slices.

Checks

internal/schedule (the oracle above; each rule form, windows, the 31st, the last Friday, until, from, both summer-time edges; coalescing; refusals; sentences; normal form), internal/core (TestScheduleHistorySaysWhatEachTimeCameTo, an extended and a rebuilt index agreeing), internal/frontend/rpc (TestScheduleRulesAreSaidInWords, TestSchedulerRunsARule, TestTheZoneIsNamed, words in the busy, running and Run now histories), and the schedule browser check (creation at set days, the preview's words and refusal, the request sent, Home's card, the transcript's record, editing to an interval and a monthly rule, a last day, recent runs after Run now, and the phone layout).

Found by validate's run of every browser check at once: a task's ⋯ menu closed itself when the heading was drawn again between the click that opened it and its toggle event, which comes a task later, so Schedule… could not be chosen until something drew the heading again. The heading now takes the menu's state from the page. The schedule check opens the menu and draws the heading again inside one task; it fails without the change.

Amendment 2026-10-17: a locked keychain holds a scheduled time

A closed task whose model's key is in a locked keychain could not start a run, and the scheduler recorded that as a schedule.skip with its interval, so the time was used up. A server that restarted at night lost every run due until someone unlocked it, and those runs never happened. The time is now held: nothing is recorded and the cursor stays, so the first tick after an unlock runs it once, late, standing for every time it held, as §5 does for a computer that was asleep. schedule.read and the session list say so in held, and Home shows it on the schedule's card. A busy task still skips its time (§6).

§12 also changes: the person's device, signed in with a passkey, sets and changes schedules and turns a task's limits off, as this computer does (ADR 30, amended 2026-10-17); a device paired by a plain invitation still only pauses and runs now. The regression is TestALockedKeychainHoldsAScheduledTime.

Amendment 2026-10-27: Home keeps Limits as it was left

§2's Home Limits is ticked for the first task and no longer ticked again for each one: unticked for a task, it stays unticked for the next on that host, kept by the daemon with the other choices of the New task window (ADR 41, amended 2026-10-27), because the person asked for every choice they make there to be the next task's default. Unticked shows in Options' count and in the label's amber, and is still this computer's choice: a paired device's tasks start with their limits on.

Amendment 2026-10-27: a task's limits are its own, none unless set

The person asked for limits to be optional, unlimited by default and configurable per task, each kind on its own, with no central configuration: set on the task's right panel or on the command line, for the few tasks that want them. §2 is replaced, and with it the amendment above: Home has no Limits box to keep, and the New task window's kept choices (new-task.toml) no longer hold no_limits (one an older file holds loads and is ignored).

  • No central limits. config.toml's [budget] and loop.max_tool_calls no longer set anything; a file that still has them loads, and Settings no longer offers them (budget.* is gone from settings.write, and writing one is refused). Settings keeps the request and command timeouts, under Waiting.
  • A task's limits are five numbers, each none unless set: tool calls, tokens, writes and commands, and time, each a turn's, and spend, the whole task's (budget.Task). Each is one number: a turn stops at it, with no checkpoint before it (soft limits are gone; the person agreed). A scheduled run's spend limit per run still replaces the task's while the run goes (§9).
  • Where they are set. The console's task panel, under the monitor: Limits None in one line, Add a limit offering the kinds not set, each limit a chip to change or remove. Line mode's --max-tool-calls, --budget-tokens, --budget-actions, --budget-wall and --budget-cost, refused since ADR 30's amendment of 2026-10-17, set the new task's, or replace those they name on a resumed one (0 removes one). nodwyn run takes the same, --turn-wall for a turn's time since its --budget-wall is the run's deadline, and its --budget-wall and --max-turns are now optional too. Home's Limits box and the workflow's no_limits are gone: a task starts with none.
  • The record. session.limits holds actor and the limits set; the latest counts and resuming keeps it. A record from before held on, against limits that no longer exist, and reads as none (the person's own tasks, the only ones, may change behaviour). session.open {limits}, session.limits.set {id, limits} (which replaces them all, and is still refused during a run) and run.start {limits} take the object, and session.info's limits is it.
  • Paired devices. A device may open a task with limits, add one or lower one: each only stops a turn sooner. Raising or removing one is refused to it, so the bridge now relays session.limits.set to a device that only answers, and the daemon holds it to Task.Within. §12's "turns a task's limits off" now reads "raises or removes a task's limits".
  • Managed sessions (ADR 56) take their limits from the embedder's Options.Limits, validated against the profile's envelope as [budget] was, and never change them.

What this gives up: a turn nobody watches is no longer bounded by default; the spend limit, the timeouts and the progress guard are what remain unless the task was given limits. The budget package's checkpoint (a soft limit below its hard one) can no longer be reached from configuration; its plumbing in the frontends (the checkpoint prompt, turn.checkpoint) stays, for older logs, and is not removed here.

Built in internal/budget (task.go), internal/config (retired.go), internal/core (limits.go), internal/frontend/rpc, cmd/nodwyn (limits.go), the console (limits.js, task.js) and the TUI's :budget. Tested by TestTaskLimits, TestRetiredLimitsLoad, TestATaskHasNoLimitsUnlessSet, TestTaskLimitsOutliveResume, TestAnOldLimitsRecordReadsAsNone, TestTasksSetTheirOwnLimits, TestARunStartsWithoutLimitsOfItsOwn, TestARunWithoutLimits, TestLineModeSetsTheTasksLimits, TestCLITurnStopsAtItsLimit, and the tasks and remote browser checks.