Skip to content

feat(sdk): flows tick start — keep a scheduled relayflow firing - #157

Merged
kjgbot merged 4 commits into
mainfrom
feat/tick-runner-0903
Sep 4, 2026
Merged

feat(sdk): flows tick start — keep a scheduled relayflow firing#157
kjgbot merged 4 commits into
mainfrom
feat/tick-runner-0903

Conversation

@kjgbot

@kjgbot kjgbot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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

emitDueTicks starts a fresh cursor at currentSlot. 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:

  1. a restart emits exactly one tick per due slot — no duplicates, no skips;
  2. slots missed while down are reported, not lost;
  3. a submit failure leaves the slot due, so the next poll retries it rather than marking it delivered.

Mutation check on bound 1 — ignoring the persisted cursor fails 4 tests with exactly the right shape:

expected [ 6 ] to deeply equal [ 4, 5, 6 ]

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

parseInt silently truncates. --interval-ms 1.5 became a 1ms schedule — parseInt('1.5') is 1. Replaced with an exact-integer round-trip that refuses anything whose string form does not survive Number:

const parsed = Number(value);
if (!Number.isInteger(parsed) || String(parsed) !== value) return undefined;

A synchronous injection seam hid half a handshake. A real JournalClient needs connect() before hello(). The tests' fake client has no transport, so it connected vacuously and all 20 tests passed; the gap only appeared against a live daemon as journal 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 reports TICK_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 onto 3725025)

tick-runner tests:  20 passed
SDK full suite:     484 passed, 3 skipped, 0 failed
kernel:             130 passed, 0 failed

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 on main from #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 current Test kernel step fails on a runner at rustup could not choose a version of cargo, my bug from #153.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown

Review Change Stack

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: fa2aed37-06ac-44d8-85c2-ec5136d0c29e

📥 Commits

Reviewing files that changed from the base of the PR and between 3725025 and 9f512a8.

📒 Files selected for processing (5)
  • sdk/src/cli.ts
  • sdk/src/cli/interruptible-sleep.ts
  • sdk/src/cli/tick-runner.ts
  • sdk/src/tick-source.ts
  • sdk/tests/tick-runner.test.ts

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Adds tick start CLI support and a scheduled runner with strict argument and schedule validation. The runner connects to the journal, resumes from durable cursor state, emits due ticks, reports skipped slots, supports cancellation, and persists state atomically.

Changes

Scheduled Tick Runner

Layer / File(s) Summary
CLI command and schedule validation
sdk/src/cli.ts, sdk/src/tick-source.ts
The CLI parses tick start options, rejects invalid or duplicate values, handles termination signals, and delegates shared schedule validation to assertTickScheduleValid.
Durable runner and polling lifecycle
sdk/src/cli/tick-runner.ts, sdk/src/cli/interruptible-sleep.ts, sdk/tests/tick-runner.test.ts
The runner loads and atomically saves cursor state, connects and handshakes with the journal, emits due ticks, reports skipped slots and partial failures, supports bounded polling and cancellation, and closes the client. Tests cover state recovery, validation, persistence, failures, and CLI parsing.

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
Loading

Poem

I hop through schedules under moonlit skies
A steady cursor keeps each tick wise
The journal hums, the slots align
Signals fade at the proper sign
State rests safely, neat and bright
A rabbit applauds the runner’s flight


Note

🎁 Summarized by CodeRabbit Free

Your 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 @coderabbitai help to get the list of available commands.

@kjgbot

kjgbot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Self-audit found one real bug; head is now 9f512a8

I could not get an independent signoff commissioned this tick — two fleet spawn attempts both returned Node not found while node status reported the node CONNECTED (the known registration flake). I will not certify my own PR, so instead I attacked it: finding bugs in your own code is fair, approving it is not. The signoff is still owed.

Finding: a malformed state file silently claimed nothing was skipped

ACCEPTED  skippedSlots a string    -> lastEmittedSlot=5 skipped=[]

skippedSlots: Array.isArray(x) ? x : [] coerced a malformed value into "no slots were skipped." That is precisely the claim this runner exists to make trustworthy, and it is the same silent-fallback shape the runner was written to prevent in emitDueTicks. A state file that cannot say what it missed must stop the runner.

Now refused, consistent with how loadTickState already refuses a non-integer lastEmittedSlot and a foreign scheduleId. Mutation-verified:

before   db5aa8bc6941756235f7aa297c6e8f8dd0166e34f7ad5c6f62221a6ac1fd5fd2
mutated  d5c3d20f8bf2805ce90e1685cb06c093e627107acb73a7d1223642c6b3f0d3b2   MUTATION APPLIED
  × refuses a non-array skippedSlots instead of coercing it to []
  × refuses a non-integer entry inside skippedSlots
  Tests  2 failed | 20 passed (22)
restored db5aa8bc6941756235f7aa297c6e8f8dd0166e34f7ad5c6f62221a6ac1fd5fd2   HASH MATCHES
  Tests  22 passed (22)

A suspected bug that turned out not to be one

I thought a negative lastEmittedSlot would be accepted and cause an unbounded backfill, since emitDueTicks emits every slot from lastEmittedSlot + 1 to now. It is accepted — but the catch-up bound already handles it and fails loudly:

cursor={"lastEmittedSlot":-1000000}  THREW tick emit failed after 0 slot(s), with 1000000 slot(s) skipped by the catch-up bound
cursor={"lastEmittedSlot":-20}       THREW tick emit failed after 0 slot(s), with 20 slot(s) skipped by the catch-up bound

Correct behaviour, so no change. Recording it because "accepted by the loader" looked like a bug and wasn't, and the next reviewer will notice the same thing.

Also probed, no findings

  • Argument parsing — the exact-integer round-trip that replaced parseInt rejects 1e3, 0x10, +5, 01, 5, Infinity, NaN, and 9007199254740993 (which Number silently rounds to …992).
  • State loader — refuses invalid JSON, a non-object, a foreign scheduleId, null/float lastEmittedSlot, and an empty file. Only ENOENT returns fresh state, which is the correct first-run path.

tests/tick-runner.test.ts: 20 → 22 passing.

Still blocked on #154 for green CI, and still needs an independent signoff at 9f512a8.

kjgbot pushed a commit that referenced this pull request Sep 4, 2026
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
@kjgbot
kjgbot force-pushed the feat/tick-runner-0903 branch from 9f512a8 to 26a3639 Compare September 4, 2026 06:12
kjgbot added 4 commits September 4, 2026 10:13
…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
@kjgbot
kjgbot force-pushed the feat/tick-runner-0903 branch from 26a3639 to 77e2b9b Compare September 4, 2026 08:14
kjgbot pushed a commit that referenced this pull request Sep 4, 2026
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
kjgbot pushed a commit that referenced this pull request Sep 4, 2026
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
kjgbot pushed a commit that referenced this pull request Sep 4, 2026
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
@kjgbot

kjgbot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

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 agent-relay fleet spawn attempts all returned Node not found while node status reported the node CONNECTED. Merging is on Khaliq's authorisation, not because the bar was met.

What stands in for it:

1. Green CI at 77e2b9blinux-x64-artifact pass 6m54s, running the full kernel suite and all 26 SDK files.

2. A self-audit that found a real bug rather than confirming my own work. loadTickState did skippedSlots: Array.isArray(x) ? x : [], silently turning a malformed value into "no slots were skipped" — the exact claim this runner exists to make trustworthy. Now fails closed, mutation-verified:

before   db5aa8bc…   mutated  d5c3d20f…   MUTATION APPLIED
  × refuses a non-array skippedSlots instead of coercing it to []
  × refuses a non-integer entry inside skippedSlots
  Tests  2 failed | 20 passed (22)
restored db5aa8bc…   HASH MATCHES     Tests  22 passed (22)

The audit also cleared a suspected unbounded-backfill bug: a negative lastEmittedSlot is caught by the catch-up bound and fails loudly, so no change was warranted.

3. The central claim demonstrated live, not just asserted. I ran this runner against a real local relayflowd and restarted it mid-schedule:

TICK_RUNNER schedule=heartbeat-1m resuming from slot 59617369; current slot 59617369

It resumed from the persisted cursor rather than starting fresh at currentSlot — precisely the silent-skip this PR exists to prevent.

4. The whole loop, end to end, on a live daemon:

1 run.spawned  2 subscription.registered  3 event.received  4 subscription.matched
5 step.attempt.started report-slot  6 step.completed  7 run.completed

completionReason: success
output: {"lag_ms": 979, "schedule_id": "heartbeat-1m",
         "scheduled_for_ms": 1788521100000, "slot": 59617370}
verification: {"gate":"json_schema","verdict":"pass","detail":"all gates passed"}

Ticks emitted → one dedupe claim each → one run each, no duplicates.

5. It closes a live gap in main. testdata/tick-heartbeat.flow.yaml is on main today with no command able to drive it — a scheduled flow that nothing schedules, which is the "silently zero" failure RFC-0001 names, sitting in the repo. This is what fixes it.

One thing a reviewer should still look at: the shipped tick-heartbeat spec is not runnable as-is — its step declares no cli, so it falls back to the project default and fails the schema gate. live-kernel.test.ts:1398 injects tick-slot-report-cli. That is a worked-example wiring gap, not a runner bug, and it is worth a follow-up.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

@kjgbot
kjgbot merged commit 066ef24 into main Sep 4, 2026
3 checks passed
kjgbot added a commit that referenced this pull request Sep 4, 2026
* 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
kjgbot pushed a commit that referenced this pull request Sep 4, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant