feat(sdk): flows tick start — keep a scheduled relayflow firing - #157
Conversation
ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Free Run ID: 📒 Files selected for processing (5)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughAdds ChangesScheduled Tick Runner
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant CLI
participant runTickRunner
participant StateFile
participant JournalClient
CLI->>runTickRunner: start with schedule and cancellation signal
runTickRunner->>StateFile: load cursor state
runTickRunner->>JournalClient: connect and handshake
runTickRunner->>JournalClient: emit due ticks
runTickRunner->>StateFile: atomically persist cursor and skipped slots
runTickRunner->>JournalClient: close client
Poem
Note 🎁 Summarized by CodeRabbit FreeYour organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing. Comment |
Self-audit found one real bug; head is now
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
9f512a8 to
26a3639
Compare
…them early No behaviour change. The six checks emitDueTicks applied inline move to an exported assertTickScheduleValid, and emitDueTicks calls it -- same checks, same order, same messages, not a second copy. A runner that accepts a schedule from an operator has to refuse a bad grid at declaration rather than at the first poll: connecting, attaching a worker and only then discovering --interval-ms was 1.5 has already told the operator it started. Sharing the function is what keeps the CLI's refusal and the emit path's refusal from drifting -- a bound added here is enforced at both ends by construction. sleepInterruptible is extracted from hn-monitor.ts for the same reason: a source that sleeps on a bare setTimeout observes SIGINT only after the sleep elapses, so a once-a-minute schedule hangs for a minute on every Ctrl-C. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
…ow firing #151 shipped the scheduled-trigger primitive; nothing drove it. `agent-relay cloud schedules` reported "No workflow schedules found", the CLI had no reference to tick-source, and `grep -niE "tick|schedule" sdk/src/cli.ts` returned nothing. RFC-0001 names that state: a flow that is never triggered is silently zero. The runner is a sibling of hn-monitor, not a new species: connect journal -> hello -> loop emitDueTicks -> drain on abort -> close. ## The cursor is persisted, and that is the point emitDueTicks starts a FRESH cursor at the current slot, which is right for a schedule's first poll -- a new hourly schedule must not backfill from the epoch. But it means an in-memory-only cursor makes a restart SKIP every slot between shutdown and restart, silently. The dedupe key makes re-delivery harmless, so a lost cursor cannot double-fire; nothing in the primitive protects against the skip. So the runner persists the cursor and reloads it, keeping the distinction: no state on disk means first run, start at the current slot; state behind the grid means catch up and report the arrears. Written temp-then-rename, so a crash mid-write leaves the previous good state. A malformed or foreign-schedule state file is a hard error, not a silent reset -- resetting would convert a corrupted file into a silent skip of everything since the last good emit. ## A skip reaches the operator skippedSlots and TickEmitError exist because a skip that is only a return value is a skip a throw can discard. The runner normalises both shapes into one accounting path, so the success case and the failure case cannot be handled separately and one of them forgotten. Each skipped slot is logged individually with its scheduled instant -- a count lets a reader skim past "3 skipped"; an instant is something an operator can look for and fail to find. ## Bounds, not mechanisms The tests pin the three things that can go wrong, each mutation-verified: restart emits exactly one tick per due slot (M1: ignore the persisted cursor -> 4 tests fail, restart yields [6] instead of [4,5,6]); a slot past maxCatchUp is reported and persisted, including when the same poll then fails; a submit failure leaves the unfired slot due, proven by retrying and seeing 4 and 5 fire while 3 is not re-emitted. A test asserting "the runner fired" would pass under all three mutations. ## One defect found in my own parser parseInt('1.5') is 1, so --interval-ms 1.5 silently became a 1ms schedule. takeNumber now requires an exact integer round-trip, refusing 1.5, 1e3, 0x10, '60000ms' and '' as invocation errors. Bounds themselves are not re-derived here: runTickRunner calls assertTickScheduleValid, the same function emitDueTicks uses, so the CLI's refusal cannot drift from the emit path's. Gates: three tsc configs clean; full SDK suite 461 passed / 3 skipped / 0 failed with RELAYFLOWD_BIN pinned to this worktree's build; kernel unchanged and green. Known limit: this makes a relayflow schedulable and runnable locally. Whether `agent-relay cloud schedules` needs a separate registration step is not established here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
…before hello The injection surface was synchronous, which hid a missing step. A real JournalClient needs connect() before hello(); the unit tests' fake client has no transport, so it "connected" vacuously and every test passed. The gap only appeared against a live daemon, as: journal client: not connected (hello) Making the seam async and giving the default path ownership of connect+hello means no caller can supply one half of the handshake. A connect failure now reports TICK_RUNNER_FAILED with the error class and message and exits 1, rather than surfacing as an unhandled rejection from inside the poll loop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
`skippedSlots: Array.isArray(x) ? x : []` silently turned a malformed value into "no slots were skipped". That is the exact claim this runner exists to make trustworthy: a state file that cannot say what it missed must stop the runner, not quietly report that it missed nothing. It is also the same silent- fallback shape the runner was written to prevent in emitDueTicks. Refuse a non-array skippedSlots and a non-integer entry within it, matching how loadTickState already refuses a non-integer lastEmittedSlot and a scheduleId that belongs to another schedule. Found by probing my own state loader with hostile-but-valid JSON. The same probe cleared a suspected unbounded-backfill bug: a negative lastEmittedSlot is already caught by the catch-up bound and fails loudly rather than emitting a million ticks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
26a3639 to
77e2b9b
Compare
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Merging on @khaliqgant's explicit "use best judgement on merges"Stating the gap plainly: this has no independent signoff. I wrote it, so I could not sign it, and three What stands in for it: 1. Green CI at 2. A self-audit that found a real bug rather than confirming my own work. The audit also cleared a suspected unbounded-backfill bug: a negative 3. The central claim demonstrated live, not just asserted. I ran this runner against a real local It resumed from the persisted cursor rather than starting fresh at 4. The whole loop, end to end, on a live daemon: Ticks emitted → one dedupe claim each → one run each, no duplicates. 5. It closes a live gap in One thing a reviewer should still look at: the shipped 🤖 Generated with Claude Code |
* refactor(sdk): share the tick bounds checks with a caller that needs them early No behaviour change. The six checks emitDueTicks applied inline move to an exported assertTickScheduleValid, and emitDueTicks calls it -- same checks, same order, same messages, not a second copy. A runner that accepts a schedule from an operator has to refuse a bad grid at declaration rather than at the first poll: connecting, attaching a worker and only then discovering --interval-ms was 1.5 has already told the operator it started. Sharing the function is what keeps the CLI's refusal and the emit path's refusal from drifting -- a bound added here is enforced at both ends by construction. sleepInterruptible is extracted from hn-monitor.ts for the same reason: a source that sleeps on a bare setTimeout observes SIGINT only after the sleep elapses, so a once-a-minute schedule hangs for a minute on every Ctrl-C. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 * feat(sdk): flows tick start — a runner that keeps a scheduled relayflow firing #151 shipped the scheduled-trigger primitive; nothing drove it. `agent-relay cloud schedules` reported "No workflow schedules found", the CLI had no reference to tick-source, and `grep -niE "tick|schedule" sdk/src/cli.ts` returned nothing. RFC-0001 names that state: a flow that is never triggered is silently zero. The runner is a sibling of hn-monitor, not a new species: connect journal -> hello -> loop emitDueTicks -> drain on abort -> close. ## The cursor is persisted, and that is the point emitDueTicks starts a FRESH cursor at the current slot, which is right for a schedule's first poll -- a new hourly schedule must not backfill from the epoch. But it means an in-memory-only cursor makes a restart SKIP every slot between shutdown and restart, silently. The dedupe key makes re-delivery harmless, so a lost cursor cannot double-fire; nothing in the primitive protects against the skip. So the runner persists the cursor and reloads it, keeping the distinction: no state on disk means first run, start at the current slot; state behind the grid means catch up and report the arrears. Written temp-then-rename, so a crash mid-write leaves the previous good state. A malformed or foreign-schedule state file is a hard error, not a silent reset -- resetting would convert a corrupted file into a silent skip of everything since the last good emit. ## A skip reaches the operator skippedSlots and TickEmitError exist because a skip that is only a return value is a skip a throw can discard. The runner normalises both shapes into one accounting path, so the success case and the failure case cannot be handled separately and one of them forgotten. Each skipped slot is logged individually with its scheduled instant -- a count lets a reader skim past "3 skipped"; an instant is something an operator can look for and fail to find. ## Bounds, not mechanisms The tests pin the three things that can go wrong, each mutation-verified: restart emits exactly one tick per due slot (M1: ignore the persisted cursor -> 4 tests fail, restart yields [6] instead of [4,5,6]); a slot past maxCatchUp is reported and persisted, including when the same poll then fails; a submit failure leaves the unfired slot due, proven by retrying and seeing 4 and 5 fire while 3 is not re-emitted. A test asserting "the runner fired" would pass under all three mutations. ## One defect found in my own parser parseInt('1.5') is 1, so --interval-ms 1.5 silently became a 1ms schedule. takeNumber now requires an exact integer round-trip, refusing 1.5, 1e3, 0x10, '60000ms' and '' as invocation errors. Bounds themselves are not re-derived here: runTickRunner calls assertTickScheduleValid, the same function emitDueTicks uses, so the CLI's refusal cannot drift from the emit path's. Gates: three tsc configs clean; full SDK suite 461 passed / 3 skipped / 0 failed with RELAYFLOWD_BIN pinned to this worktree's build; kernel unchanged and green. Known limit: this makes a relayflow schedulable and runnable locally. Whether `agent-relay cloud schedules` needs a separate registration step is not established here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 * fix(sdk): make the tick runner's client injection async, and connect before hello The injection surface was synchronous, which hid a missing step. A real JournalClient needs connect() before hello(); the unit tests' fake client has no transport, so it "connected" vacuously and every test passed. The gap only appeared against a live daemon, as: journal client: not connected (hello) Making the seam async and giving the default path ownership of connect+hello means no caller can supply one half of the handshake. A connect failure now reports TICK_RUNNER_FAILED with the error class and message and exits 1, rather than surfacing as an unhandled rejection from inside the poll loop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 * fix(sdk): fail closed on a malformed tick state file `skippedSlots: Array.isArray(x) ? x : []` silently turned a malformed value into "no slots were skipped". That is the exact claim this runner exists to make trustworthy: a state file that cannot say what it missed must stop the runner, not quietly report that it missed nothing. It is also the same silent- fallback shape the runner was written to prevent in emitDueTicks. Refuse a non-array skippedSlots and a non-integer entry within it, matching how loadTickState already refuses a non-integer lastEmittedSlot and a scheduleId that belongs to another schedule. Found by probing my own state loader with hostile-but-valid JSON. The same probe cleared a suspected unbounded-backfill bug: a negative lastEmittedSlot is already caught by the catch-up bound and fails loudly rather than emitting a million ticks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 --------- Co-authored-by: kjgbot <kjgbot@agentrelay.dev> Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Closes the gap RFC-0001 names as Native's silent-death problem: "triggers are entry conditions, not schedulers", and "a flow that is never triggered is silently zero." #151 added the scheduled trigger; nothing turned slots into runs. This is that runner.
The bound that shaped the design
emitDueTicksstarts a fresh cursor atcurrentSlot. So an in-memory-only cursor means a restart silently skips every slot that fell during the downtime — no error, no gap report, just a schedule that quietly missed an hour. The runner therefore persists the cursor and resumes from it.Three bounds are pinned by tests, all written to fail before the code existed:
Mutation check on bound 1 — ignoring the persisted cursor fails 4 tests with exactly the right shape:
That is the whole point of the file in one assertion: without persistence the runner reports only the current slot and the two it slept through vanish.
Two bugs found in my own work, both worth naming
parseIntsilently truncates.--interval-ms 1.5became a 1ms schedule —parseInt('1.5')is1. Replaced with an exact-integer round-trip that refuses anything whose string form does not surviveNumber:A synchronous injection seam hid half a handshake. A real
JournalClientneedsconnect()beforehello(). The tests' fake client has no transport, so it connected vacuously and all 20 tests passed; the gap only appeared against a live daemon asjournal client: not connected (hello). The seam is now async and the default path owns both steps, so no caller can supply one half. A connect failure reportsTICK_RUNNER_FAILED <ErrorClass>: <message>and exits 1 rather than escaping as an unhandled rejection from inside the poll loop.The second is the more interesting failure: a test double that is more capable than the real thing turns a green suite into evidence of nothing. It is the same shape as the CI gap in #153 — a gate that passes because it never exercised the path.
Verification at
e388a5e(rebased onto3725025)One kernel run in two showed a single failure —
worker_capacity::default_capacity_one_reopens_only_after_durable_completion_or_crash. That is #155, a pre-existing ~15% flake onmainfrom #137, not this branch; the second run was clean. Flagging it because it will make this PR's CI intermittently red for a reason that has nothing to do with it.State on disk
Cursor state is written temp-file-and-rename, so a crash mid-write cannot leave a truncated cursor that reads as slot 0 and re-fires the whole schedule.
Note
Cannot go green until #154 lands —
main's currentTest kernelstep fails on a runner atrustup could not choose a version of cargo, my bug from #153.🤖 Generated with Claude Code
https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR