Repository navigation
AI-1630: onboarding docs for scheduled tasks - #1108
Conversation
Check the feature doc claim by claim against the code, add the scheduling package's AGENTS.md (code map, terms, rules with the test behind each, how to run the suites) and a scheduled-tasks skill (turn it on, schedule one locally or on staging, what to check when nothing arrived). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kanat
left a comment
There was a problem hiding this comment.
Reviewed 17ed773cd merged onto origin/accelerate 63e674ae7 (base moved; #1106 touched configs.go). CI: labeler pass, CodeRabbit skipped (no Go job for docs).
What I ran:
- I checked the claims: 74 in the feature doc (26 refs, 18 constants), 42 in AGENTS.md, and the skill.
- The 74 test names exist under their suites, with the right build tags.
- Linear: all 28 listed tickets are open and their titles match. None in the ranges is missing.
- I ran every "Run the tests" command verbatim on my own
_testDB: SchedulerSuite ok (153 s), ScheduledTasksSuite ok (86 s), DrainSuite ok, Store+Migration timed out at 600 s (R1.8), and that rerun with a longer timeout passed. The two unit commands pass. - I ran the local walkthrough on the docker router: config create OK; respond fails (R1.1); REST create OK ("Every weekday at 09:00 (Europe/Amsterdam)"); step 4 fails (R1.2). With both fixed by hand, the tool-made one-shot fired on time:
deliveredtoconversation, taskcompleted,last_run_atUTC,scheduled_for+02:00. - I did not run the staging section (not allowed). I checked the JWT recipe offline with a dummy secret: the HS256 signature matches and the claims are
{server,iat,exp}. Sourcing.envprints nothing.
[Should fix] R1.10 .factory/features/scheduled-tasks.md:164 (unchanged line, so not inline): "devices naming their own zone" is listed as not done. But createSession is x-client-accessible and takes timezone, and the router remembers it as the user's zone (api/schemas.go:288, api/session_create.go:76). Remove it, or name what is still missing.
Nits (for tickets, not this PR):
- R1.11: feature doc :135-140 and AGENTS.md :102: the pause/edit settle writes every running scheduled run
missed, in flight or held. Only an edit that leaves a one-shot active keeps its claim (store/scheduled_tasks.go:294,316-347). - R1.12: feature doc :57: "a pause, edit or delete takes the claim" contradicts that exception.
- R1.13: feature doc :81:
CheckMovealso refuses a move when no run is pending or the current run is due (scheduling.go:59-68). - R1.14: feature doc :107 and :165 say "tools don't take
deliver_to/quiet_hours" twice. - R1.15: feature doc :52-66 and S4/S5 repeat the AGENTS.md Rules and constants: mechanism inside the what/why file.
- R1.16: style. The paragraphs at :20-37, :52-66 and :93-108 chain 4-8 claims per sentence (STE). "run" is both the record and the action ("run by hand"). :79 "anybody else's answer 404".
- R1.17: AGENTS.md :54:
missedalso covers a held run settled by a pause or edit. - R1.18: AGENTS.md :60: the prompt's
late=counts fromScheduledFor, not from the hold's end (scheduled_delivery.go:470, AI-1597). - R1.19: AGENTS.md :83, feature doc :61 and skill :156:
maxScheduledTaskRunAttemptscounts claims, including the take-up after a hold, not only dead workers. - R1.20: AGENTS.md :65: says three suites, but the Rules also cite the Migration, Drain and DataMove suites. :157
TestDataMoveSuiteexists in both store and api (this one isinternal/store), and no command runs it. - R1.21: AGENTS.md :190: AI-1566 is a panic on a fresh DB, not a flake, and step 1 tells readers to use a fresh DB.
- R1.22: AGENTS.md :57: an end equal to the start also wraps (
quiet.go:42). - R1.23: AGENTS.md :138 and :146:
TestACallersOwnScheduleTaskKeepsTheNametests name precedence. The WhatsApp and opt-out tests prove fallback and refusal, not "written once". - R1.24: AGENTS.md :4: "the two pure parts" names one group.
- R1.25: skill log table: it lacks
could not record the session a scheduled task opened(scheduled_delivery.go:690),could not give back a scheduled task's call(:745),...rang into(:786),could not end a scheduled task's call(:793),could not record a scheduled task run by hand(scheduled_tasks.go:578) andcould not read when a scheduled task last ran(:637).grep -i 'scheduled task'also matches asynq'sFailed to forward scheduled tasksnoise. - R1.26: skill :57-59: the commands use
X-Customer-Id, so they work inproxymode only, not inapi_keymode. - R1.27: skill :131:
?api_key=on a URL that already has?state=activemust be&api_key=. - R1.28: skill :124:
openssl -hmac "$STREAM_API_SECRET"puts the secret in argv, wherepsshows it.
Author's code finding 2 (resume of a failed one-shot differs between the API and the tool): ticket, out of scope for docs.
Root AGENTS.md +5 lines fit the Connectors/Security pattern.
Verdict: request changes, for the R1.1 blocker (the copyable respond command fails) and R1.2-R1.10.
| U=(-H 'X-Stream-User-Id: alice') | ||
| SID=$(curl -s "$R/v1/agents/sessions" "${H[@]}" "${U[@]}" \ | ||
| -d '{"agent": "reminders", "timezone": "Europe/Amsterdam"}' | jq -r .id) | ||
| curl -s "$R/v1/agents/sessions/$SID/respond" "${H[@]}" "${U[@]}" \ |
There was a problem hiding this comment.
[Blocker] R1.1 This respond call fails as written. I ran it on the local router (proxy mode) and got 400 personal conversations require a command ID (internal/session/session.go:507): a text session with a named user needs a durable command. With "request_id": "remind-1" it returns 200, a receipt with state, and the model calls schedule_task. Fix: add request_id to the body, and say how to read the reply (GET /v1/agents/sessions/$SID/commands/remind-1).
There was a problem hiding this comment.
Fixed in 2826f16: respond now sends "request_id": "remind-1", and the next line reads GET .../commands/remind-1. That endpoint answers only state (thinking, then completed), not the reply text, so the skill says to read the reply in the conversation. Ran the extracted step 2-4 commands on the local docker router (proxy mode): config 201, respond receipt state thinking then completed, task created.
| 4. Watch it. Fire it now without moving the schedule, then read its runs: | ||
|
|
||
| ```bash | ||
| curl -s "$R/v1/agents/scheduled-tasks?state=active" "${H[@]}" "${U[@]}" | jq '.items[] | {id, title, next_run_at, last_run_at}' |
There was a problem hiding this comment.
[Should fix] R1.2 $TASK is never set, so POST .../scheduled-tasks//run returns 400 validation_failed (path.id) and the runs jq fails with Cannot iterate over null. Fix: capture it from the create in step 3, e.g. TASK=$(curl ... | jq -r .id). With TASK set, I checked that the run answers 202 and becomes delivered to conversation.
There was a problem hiding this comment.
Fixed in 2826f16: step 3 captures TASK=$(curl ... | jq -r .id) from the REST create; for a tool-made task it says to take the id from the step 4 list. Ran as written: run answered 202 trigger manual, then the run row read delivered, delivered_to conversation.
| the zone differs, except for an anonymous caller or an incognito session | ||
| (`internal/api/session_create.go:76`). Each user may hold 25 active tasks per agent config | ||
| (`scheduling.MaxActivePerUser`, `scheduling.go:72`), or the config's | ||
| `scheduling.max_tasks_per_user` (1 to 100, `internal/api/configs.go:2030`); the count is |
There was a problem hiding this comment.
[Should fix] R1.3 The configs.go refs went stale when #1106 merged into the base: :2030 is now :2105 and :298 (line 48) is now :308. The same applies in SKILL.md: :2028 → :2103 (line 34), :298 → :308 (line 141) and :299 → :309 (line 191). Fix: merge the base and re-check every configs.go ref.
There was a problem hiding this comment.
| - **A paused one-shot resumed after its time is closed, not run (AI-1602).** Resume over | ||
| the API or the `update_scheduled_task` `resume` action writes one `missed` run with | ||
| error `task_paused` and completes the task (`scheduling.SleptThrough`, | ||
| `scheduler.go:129`). A second resume writes no second run: the occurrence's unique key |
There was a problem hiding this comment.
[Should fix] R1.4 A second resume does not just write no second run. The first resume completes the task, so the second resume gets 409 a completed task cannot be resumed (internal/api/scheduled_tasks.go:490), and the tool refuses it too (schedule_tools.go:365). TestResumingALateOneShotTwiceRecordsItsOccurrenceOnce asserts the 409. ON CONFLICT DO NOTHING covers only two resumes that race. Fix: say the second resume is refused (409).
There was a problem hiding this comment.
Fixed in 2826f16: S-AI-1602 now says a second resume is refused with 409 (api/scheduled_tasks.go:490; the tool refuses it at schedule_tools.go:365). The unique key only meets two resumes that race.
| delete does not settle a held run yet (AI-1600). | ||
| - **A call more than 15 minutes late is `missed`, and an unanswered call is not retried | ||
| (S5).** The call is not placed (`scheduledCallGrace`, `scheduled_delivery.go:65`). | ||
| Nobody picking up within the 90 s ring is `not_answered`, final: the next channel in |
There was a problem hiding this comment.
[Should fix] R1.5 "the occurrence is not retried" is wrong when a later channel in deliver_to fails with a reason that is not final. The chain is final only when every channel's failure is final (scheduled_delivery.go:244). Otherwise the occurrence is retried and the phone step answers already_called (:734). Fix: say the call is not placed again. The occurrence is retried only when another channel's failure is not final.
There was a problem hiding this comment.
Fixed in 2826f16: S5 says the call is not placed again, the next channel is still tried, and the occurrence is retried only when another channel fails with a non-final reason (scheduled_delivery.go:245); the phone step then answers already_called (:734). I cite :245 for the final = final && ... line; it is at :245 on the merged tree, not :244.
| | run | The record of one occurrence that came due, or of one run by hand: `running`, `delivered`, `failed` or `missed`, with `error` a reason code. Its `prompt` is the instructions as they were at the claim | `store.ScheduledTaskRun`, table `scheduled_task_runs`, unique per (`customer_id`, `task_id`, `scheduled_for`) for `trigger = schedule` | run `scheduled_for` 2026-10-26T09:00:00+01:00, `state` `delivered`, `delivered_to` `conversation` | | ||
| | claim, lease | A worker's hold on a task: `claim_id` fences every later write of that worker, `lease_until` says when another worker may take it. Lease 1 min, renewed every 10 s | `ClaimDueScheduledTasks`, `RenewScheduledTaskLease`, `scheduledLease`, `renewLeaseEvery` (`scheduler.go:19`, `:22`) | A pause clears `claim_id`; the next renewal finds no row and stops the delivery | | ||
| | held | An occurrence that came due in quiet hours: the claim stays, the lease moves to the hold's end, the run stays `running` with `postponed_until` | `Postponed`, `PostponeScheduledTask`, `ScheduledTaskRun.PostponedUntil` | Due 23:30 with quiet hours 22:00–07:00: held until 07:00, delivered then | | ||
| | missed | A run not delivered because it was too late or the task slept through it. Not a failure: the failure streak is left as it is | `ScheduledTaskRunMissed`, `Missed`, `Scheduler.missed`, `SleptThrough` | A daily task the router could not reach for 3 h: the two oldest occurrences are `missed`, the latest is delivered | |
There was a problem hiding this comment.
[Should fix] R1.6 This example does not hold. A daily task with the router down for 3 h has one occurrence. It is 3 h late, past repeatGrace (1 h), so it is missed and nothing is delivered (TestARecurringOccurrenceTwoHoursLateIsMissed). Skip-then-deliver needs more than a day of downtime (TestARecurringTaskSkipsOccurrencesOlderThanAnHour: one missed, the latest delivered). Fix: use, e.g., an hourly-free example: "a daily task the router could not reach for 25 h: yesterday's occurrence is missed, today's (59 min old) is delivered".
There was a problem hiding this comment.
Fixed in 2826f16: the example is now a daily task the router could not reach for 25 h (yesterday missed, today 59 min old delivered), with the 3 h case stated as one occurrence past the 1 h grace, so missed and nothing delivered. Tests: TestARecurringTaskSkipsOccurrencesOlderThanAnHour, TestARecurringOccurrenceTwoHoursLateIsMissed.
| so `testing.yaml` wins over `ROUTER_POSTGRES_DSN` (`TestTheTestingFileWinsOverTheEnvironment`): | ||
|
|
||
| 1. Point `testing.yaml` at a database of your own whose name ends in `_test`, such as | ||
| `model_router_test_<slug>_test`. Never commit that edit. `StoreSuite` drops the schema of |
There was a problem hiding this comment.
[Should fix] R1.7 StoreSuite does not drop the schema of the database it gets. It drops testenv.Database(dsn, "store") (internal/store/store_test.go:96). My run created model_router_store_test_ai1630r_test, next to ..._stmig_... and ..._drain_.... Fix: say each suite derives its own _test database from that DSN, and StoreSuite drops the schema of its own.
There was a problem hiding this comment.
Fixed in 2826f16: step 1 says some suites derive a database of their own with testenv.Database (model_router_test_<slug>_test becomes model_router_store_test_<slug>_test), and StoreSuite drops the schema of its own (store_test.go:96), never yours. My run created model_router_store_test_ai1630f_test.
| cd acceleration | ||
| grep dsn internal/config/testing.yaml # your own *_test database | ||
| go test -tags integration -p 1 -count=1 -run TestSchedulerSuite ./internal/scheduling/ | ||
| go test -tags integration -p 1 -count=1 -run 'TestStoreSuite|TestScheduledTasksMigrationSuite' ./internal/store/ |
There was a problem hiding this comment.
[Should fix] R1.8 This command can fail as written. It runs all of StoreSuite (516 tests, not only scheduling), and the default go test timeout is 10 min. My run at load ~40 hit it: FAIL internal/store 600.410s. A rerun with -timeout 40m passed in 458 s at load ~20. Fix: add -timeout 30m, or narrow -run to the scheduled-task tests.
There was a problem hiding this comment.
Fixed in 2826f16: the store command now has -timeout 40m, and the text says it runs all of StoreSuite (more than 500 tests). Ran the command as written: ok internal/store 717.183s (12 min, over the 10 min default). I did not add TestDataMoveSuite to it: on this head five other DataMoveSuite tests (change capture) fail on my DB, unrelated to scheduling. The scheduled-task one has its own command and passes.
|
|
||
| | Code | Meaning | Retried | | ||
| | --- | --- | --- | | ||
| | `delivery_failed` | Any other failure, or its workers died three times | yes, unless the workers died | |
There was a problem hiding this comment.
[Should fix] R1.9 delivery_failed is also final, not retried, once a call was placed: when the call's session cannot open (scheduled_delivery.go:783), or when the task cannot be sent as the call's first turn (:857). Both use undeliveredFinally. Fix the Retried cell, e.g. "yes, unless its workers died or a call was already placed".
There was a problem hiding this comment.
Fixed in 2826f16: the Retried cell reads: yes, unless it was claimed three times or a call was already placed (scheduled_delivery.go:783, :857). The cause cell now says the attempt cap counts claims.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
kanat
left a comment
There was a problem hiding this comment.
Delta review of 2826f161d (merge 04c60feb3 + 2 fix commits), scratch-merged onto origin/accelerate 51b7dc6b7 with no conflicts. Verdict: GO.
Prior findings (all fixed, checked on the merged tree):
- R1.1 fixed. I extracted the skill's step 2-4 bash blocks with
sedand ran them against the local docker router (proxy mode).respondwithrequest_id: remind-1returns a receipt, andGET .../commands/remind-1goesthinkingthencompleted. Without it the router still answers 400 (session.go:507). - R1.2 fixed.
TASK=$(... | jq -r .id)is set.POST .../runanswers 202 triggermanual, and the run readsdelivered,delivered_toconversation. - R1.3 fixed:
configs.go:2103/2105/308/309match the code. - R1.4-R1.10 fixed. The refs (
scheduled_tasks.go:490,schedule_tools.go:365,scheduled_delivery.go:245/734/783/843/857,store_test.go:96,schemas.go:288) and the 25 h example (scheduler_test.go,TestARecurringTaskSkipsOccurrencesOlderThanAnHour) are right. - R1.11-R1.28 fixed.
SaveScheduledTask(store/scheduled_tasks.go:270-347) matches S4.CheckMove(scheduling.go:59-68) andQuietEndmatch. All 27 log-line refs are right. The node JWT snippet, run with a dummy secret, matches an independent Python HMAC: signature true, claims{server,iat,exp}, no secret in argv. All 74 test names exist.
Suites, run as written on my own fresh DB, -p 1:
- scheduling unit,
SchedulerSuite,ScheduledTasksSuite,DrainSuite: ok. - Store and Migration with
-timeout 40m: ok, 430 s. TestDataMoveSuite/TestScheduledTasksMoveWithTheirRunsas documented: ok. (WholeTestDataMoveSuiteon the same merged tree: ok, 24 s. The fixer's 5 failures do not reproduce.)
New findings, both Nits and not blocking:
.claude/skills/scheduled-tasks/SKILL.md:52and:218citemanager.go:333. On the current base (#1110 added an import and lines) it ismanager.go:340. This is base drift: the fixer was correct against63e674ae7. Fix it in place when the base is merged before the PR merge. No other cited file changed between63e674ae7and51b7dc6b7..factory/features/scheduled-tasks.md:123citeschannels/service.go:202forNotify, but that line iswhatsappWindow. This is old text and not blocking.
CI: labeler pass, CodeRabbit skipped. Staging was not touched.
…heduled-tasks-onboarding
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Why
Scheduled tasks shipped in #1050 (AI-1516) and #1098 (AI-1602). A new developer has one feature doc that had drifted from the code and no package guide or skill. AI-1630 asks for one way in. Docs only: no Go, SQL or generated file changes.
How it works
Three files, no overlap:
Repo-root
AGENTS.mdgets one line that points to the skill, as it does forconnectors.Claims fixed in the feature doc
47 claims checked against
origin/accelerate52dee65. 2 were wrong, 4 were incomplete, and 1 cannot be checked from code. The full claim-by-claim table is in the author ledger.internal/api/scheduled_delivery.go:536not_answeredis final: the next channel is tried, the occurrence is not retried (S5)scheduled_delivery.go:836,:843,:245internal/session/schedule_tools.go:106,connector_tools.go:569users.timezone, defaultinternal/api/scheduled_tasks.go:219users.timezonewriteinternal/api/session_create.go:76cmd/router/main.go:1264unverifiedAdded because the code does it and the doc said nothing:
missedrun (task_paused) and completes it. This holds for the API and the tool. The tool names the time in the task's zoneinternal/scheduling/scheduler.go:129,schedule_tools.go:435missed. A config delete does not do this yet (AI-1600)internal/store/scheduled_tasks.go:270vs:938missed.not_answeredandalready_calledare finalscheduled_delivery.go:65,:733Describecannot say is refused. A stored one still runsinternal/scheduling/describe.go:34max_tasks_per_user(1–100)scheduling.go:72,configs.go:2030,store/scheduled_tasks.go:19420261105130000_scheduled_tasks_wake.sql(wake index)store/scheduled_tasks.go:873last_run_atis the settle time (max(finished_at), UTC). A run'sscheduled_foris the due time in the task's zone. Includes a worked examplestore/scheduled_tasks.go:845,api/scheduled_tasks.go:712scheduler.go:19,22,25,57,62,store/scheduled_tasks.go:59Checks
internal/scheduling/AGENTS.mdexists under the suite it is listed with (grep offunc (s *<Suite>) <Test>().go test ./internal/scheduling/and the fiveinternal/apiunit tests named in "Run the tests" pass.go test -tags integration -listcompiles and lists all five suites.Code findings, not fixed here
runningand keeps itslease_until(store/scheduled_tasks.go:938). This is tracked in AI-1600.failedone-shot that has no occurrence left differs by surface. The API runs the config and limit checks, then completes the task (api/scheduled_tasks.go:508). The tool refuses withschedule_in_past(schedule_tools.go:378).🤖 Generated with Claude Code