Skip to content

feat(scheduler): support durable schedule control plane#108

Draft
yordis wants to merge 16 commits into
mainfrom
CRON
Draft

feat(scheduler): support durable schedule control plane#108
yordis wants to merge 16 commits into
mainfrom
CRON

Conversation

@yordis

@yordis yordis commented Apr 9, 2026

Copy link
Copy Markdown
Member
  • Schedule writes need the same event-stream boundary consumed by the execution worker so command handling and reconciliation stay consistent.
  • Clients need a durable query view that survives process restarts without coupling reads to execution internals.
  • Local and test environments need the scheduler storage contract available before broader runtime wiring lands.

@cursor

cursor Bot commented Apr 9, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
New distributed scheduling path depends on JetStream/KV correctness, projection catch-up, and OCC semantics; mistakes could cause lost or stale schedule state, though coverage is mitigated by mocks and integration tests.

Overview
Adds trogon-scheduler, a NATS JetStream event-sourced scheduling control plane: per-schedule event streams, KV read-model projections (with catch-up/replay), optimistic concurrency on writes, and create/pause/resume/remove commands wired through trogon-decider-runtime / trogon-decider-nats.

Exposes typed errors, read models (cron/every/RRULE, NATS delivery, message headers), queries (get/list), a MockSchedulerStore for tests, and connect_store provisioning JetStream streams/KV buckets. Adds unit and integration tests plus Docker Compose (trogon-scheduler profile) and a multi-stage Dockerfile; documents event context vs payload in UNDERSTANDING_CONTEXT.md (migration guidance, no runtime enforcement in this PR). Minor workspace/telemetry/coverage-regex updates and lockfile bumps.

Reviewed by Cursor Bugbot for commit 8123459. Bugbot is set up for automated code reviews on this repo. Configure here.

@coderabbitai

coderabbitai Bot commented Apr 9, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a new crate trogon-cron: a distributed CRON scheduler using NATS JetStream/KV for config, leader election, and tick publishing. Provides serde job configs, domain validation, execution (publish/spawn) with retries and DLQ, a scheduler runtime with hot-reload and leader election, CLI tooling, mocks/tests, and Docker compose + Dockerfile.

Changes

Cohort / File(s) Summary
Crate manifest
rsworkspace/crates/trogon-cron/Cargo.toml
New crate manifest, workspace lint settings, test-support feature, dependencies, binary and explicit test targets.
Public surface & CLI / binary
rsworkspace/crates/trogon-cron/src/lib.rs, rsworkspace/crates/trogon-cron/src/cli.rs, rsworkspace/crates/trogon-cron/src/main.rs
Crate root and re-exports; Clap CLI types; Tokio binary entrypoint and job-management command handlers and CLI error mapping.
Client & traits
rsworkspace/crates/trogon-cron/src/client.rs, rsworkspace/crates/trogon-cron/src/traits.rs
CronClient generic over ConfigStore with CRUD methods; trait interfaces for ConfigStore, LeaderLock, TickPublisher, and JobConfigChange stream types.
Config, domain & errors
rsworkspace/crates/trogon-cron/src/config.rs, rsworkspace/crates/trogon-cron/src/domain.rs, rsworkspace/crates/trogon-cron/src/error.rs
Serde job types (Schedule, Action, RetryConfig, JobConfig, TickPayload), domain conversions/validation to Registered/Runtime jobs (cron parsing, spawn validation), and CronError/JobConfigError/TimeoutError with Display/Error and From impls.
Execution & runtime
rsworkspace/crates/trogon-cron/src/executor.rs, rsworkspace/crates/trogon-cron/src/scheduler.rs, rsworkspace/crates/trogon-cron/src/leader.rs
JobState and firing logic; publish and spawn execution with retry/backoff, timeouts and DLQ; Scheduler that hot-reloads configs, enforces leader-only execution, preserves runtime flags; leader-election helper with renew/release semantics.
NATS / JetStream implementations & KV helpers
rsworkspace/crates/trogon-cron/src/kv.rs, rsworkspace/crates/trogon-cron/src/nats_impls.rs
KV/stream create-or-get helpers, job load/watch logic; NatsConfigStore (KV-backed), JetStream TickPublisher adapter with ack/timeout handling, and NatsLeaderLock.
Mocks & tests
rsworkspace/crates/trogon-cron/src/mocks.rs, rsworkspace/crates/trogon-cron/tests/cron_unit.rs
In-memory mocks for TickPublisher, LeaderLock, ConfigStore and unit/integration tests exercising client, executor, scheduler initialization and validation cases.
Packaging & infra
devops/docker/compose/compose.yml, devops/docker/compose/services/trogon-cron/Dockerfile
Docker Compose service entry (profile cron) and multi-stage Dockerfile using cargo-chef and a slim runtime image.
Telemetry enum update
rsworkspace/crates/acp-telemetry/src/service_name.rs
Added ServiceName::TrogonCron variant and updated tests.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant Scheduler as Scheduler
    participant KV as NATS KV (configs/leader)
    participant Watch as Config Watcher
    participant Leader as LeaderElection
    participant Publisher as TickPublisher (JetStream)
    participant Executor as Executor

    Scheduler->>KV: load_jobs_and_watch()
    KV-->>Scheduler: (snapshot, watch_stream)
    loop main tick
        Scheduler->>Leader: ensure_leader()
        alt is leader
            Scheduler->>Scheduler: compute due jobs(now)
            Scheduler->>Executor: execute(job_state)
            Executor->>Publisher: publish_tick(...) / spawn process
            Publisher-->>Executor: publish result / ack
        else not leader
            Scheduler-->>Scheduler: skip execution
        end
    end
    Watch-->>Scheduler: JobConfigChange (Put/Delete)
    Scheduler->>Scheduler: apply config change (hot-reload)
Loading
sequenceDiagram
    autonumber
    participant Executor as Executor
    participant Retry as RetryLoop
    participant Jet as JetStream
    participant DLQ as cron.errors

    Executor->>Retry: prepare TickPayload & headers
    Retry->>Jet: publish(payload)
    alt success
        Jet-->>Retry: ack
    else transient failure
        Retry->>Retry: backoff + jitter (respect max duration)
        Retry->>Jet: publish(retry)
        alt retries exhausted
            Retry->>DLQ: publish dead-letter(details)
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Poem

🐇 I nudged the nodes at break of dawn,

jobs in rows, their timers drawn,
NATS hums ticks across the stream,
leader hops and runs the dream,
retries twirl — the scheduler's song.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 72.83% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'feat(scheduler): support durable schedule control plane' is directly related to the main objective of adding a distributed CRON scheduler with a durable control plane backed by NATS/JetStream, job storage, and client APIs.
Description check ✅ Passed The PR description adequately explains the motivation: schedule writes need event-stream boundaries for consistency, clients need durable query views for process restarts, and local environments need storage contracts. This directly relates to the changeset introducing the trogon-cron scheduler infrastructure.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch CRON

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Apr 9, 2026

Copy link
Copy Markdown

badge

Code Coverage Summary

Details
Filename                                                                              Stmts    Miss  Cover    Missing
----------------------------------------------------------------------------------  -------  ------  -------  ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
crates/trogon-decider-runtime/src/stream/mod.rs                                          38       0  100.00%
crates/trogon-decider-runtime/src/stream/append_stream.rs                                 5       0  100.00%
crates/trogon-decider-runtime/src/stream/read_stream.rs                                  10       0  100.00%
crates/trogon-decider-runtime/src/stream/stream_position.rs                              29       0  100.00%
crates/trogon-decider/src/decision.rs                                                    37       0  100.00%
crates/trogon-decider/src/testing.rs                                                    660       0  100.00%
crates/trogon-decider/src/act.rs                                                         62       0  100.00%
crates/trogon-decider/src/events.rs                                                      49       0  100.00%
crates/trogon-decider/src/lib.rs                                                        143       0  100.00%
crates/mcp-nats-stdio/src/config.rs                                                     160       0  100.00%
crates/mcp-nats-stdio/src/main.rs                                                       212       0  100.00%
crates/mcp-nats/src/nats/subjects/subscriptions/one_server.rs                             9       0  100.00%
crates/mcp-nats/src/nats/subjects/subscriptions/all_client.rs                             6       0  100.00%
crates/mcp-nats/src/nats/subjects/subscriptions/one_client.rs                             9       0  100.00%
crates/mcp-nats/src/nats/subjects/subscriptions/all_server.rs                             6       0  100.00%
crates/trogon-std/src/args.rs                                                            19       9  52.63%   11-28
crates/trogon-std/src/json.rs                                                            30       0  100.00%
crates/trogon-std/src/signal.rs                                                          26      12  53.85%   6-11, 18-25, 34
crates/trogon-std/src/uuid.rs                                                             7       0  100.00%
crates/trogon-std/src/secret_string.rs                                                   35       0  100.00%
crates/trogon-std/src/duration.rs                                                        45       0  100.00%
crates/trogon-std/src/http.rs                                                            19       0  100.00%
crates/trogon-gateway/src/source/standard_webhooks.rs                                   172       0  100.00%
crates/trogon-gateway/src/source/twitter/server.rs                                      525       0  100.00%
crates/trogon-gateway/src/source/twitter/config.rs                                       17       0  100.00%
crates/trogon-gateway/src/source/twitter/signature.rs                                    69       0  100.00%
crates/trogon-std/src/dirs/fixed.rs                                                      80       0  100.00%
crates/trogon-std/src/dirs/system.rs                                                     71       0  100.00%
crates/trogon-std/src/time/mock.rs                                                      125       0  100.00%
crates/trogon-std/src/time/system.rs                                                     31       0  100.00%
crates/mcp-nats/src/config.rs                                                           110       0  100.00%
crates/mcp-nats/src/client.rs                                                            31       0  100.00%
crates/mcp-nats/src/jsonrpc.rs                                                           22       0  100.00%
crates/mcp-nats/src/mcp_prefix.rs                                                        36       0  100.00%
crates/mcp-nats/src/transport.rs                                                        722       0  100.00%
crates/mcp-nats/src/mcp_peer_id.rs                                                       33       0  100.00%
crates/mcp-nats/src/server.rs                                                            31       0  100.00%
crates/trogon-gateway/src/source/incidentio/config.rs                                    16       0  100.00%
crates/trogon-gateway/src/source/incidentio/incidentio_signing_secret.rs                 67       0  100.00%
crates/trogon-gateway/src/source/incidentio/server.rs                                   343       0  100.00%
crates/trogon-gateway/src/source/incidentio/incidentio_event_type.rs                     62       0  100.00%
crates/trogon-gateway/src/source/incidentio/signature.rs                                206       0  100.00%
crates/trogon-cron/src/projections/cron_jobs.rs                                         699     414  40.77%   79-81, 96-98, 110-112, 118-129, 154, 162, 166-172, 183-185, 189-200, 216, 228-240, 246-248, 255-259, 266-532, 548-585, 597-599, 620-622, 626-741, 757-759, 765-781, 903
crates/trogon-gateway/src/source/notion/notion_event_type.rs                             46       3  93.48%   47-49
crates/trogon-gateway/src/source/notion/server.rs                                       318       8  97.48%   93-97, 130-131, 150-151
crates/trogon-gateway/src/source/notion/signature.rs                                     56       1  98.21%   32
crates/trogon-gateway/src/source/notion/verification_token.rs                           240       0  100.00%
crates/trogon-gateway/src/source/notion/notion_verification_token.rs                     17       0  100.00%
crates/acp-nats-agent/src/connection.rs                                                1270       1  99.92%   607
crates/trogon-nats/src/jetstream/publish.rs                                              64       0  100.00%
crates/trogon-nats/src/jetstream/create_conflicts.rs                                     24       0  100.00%
crates/trogon-nats/src/jetstream/stream_max_age.rs                                       18       0  100.00%
crates/trogon-nats/src/jetstream/mocks.rs                                               748      32  95.72%   367-381, 387-395, 410-416, 430-433, 497-499
crates/trogon-nats/src/jetstream/claim_check.rs                                         346       0  100.00%
crates/acp-nats/src/nats/subjects/commands/load.rs                                       15       0  100.00%
crates/acp-nats/src/nats/subjects/commands/set_model.rs                                  15       0  100.00%
crates/acp-nats/src/nats/subjects/commands/cancel.rs                                     15       0  100.00%
crates/acp-nats/src/nats/subjects/commands/set_config_option.rs                          15       0  100.00%
crates/acp-nats/src/nats/subjects/commands/set_mode.rs                                   15       0  100.00%
crates/acp-nats/src/nats/subjects/commands/fork.rs                                       15       0  100.00%
crates/acp-nats/src/nats/subjects/commands/close.rs                                      15       0  100.00%
crates/acp-nats/src/nats/subjects/commands/prompt.rs                                     15       0  100.00%
crates/acp-nats/src/nats/subjects/commands/resume.rs                                     15       0  100.00%
crates/trogon-decider-runtime/src/headers/header_value.rs                                37       0  100.00%
crates/trogon-decider-runtime/src/headers/mod.rs                                         74       0  100.00%
crates/trogon-decider-runtime/src/headers/header_map.rs                                  54       3  94.44%   20-22
crates/trogon-decider-runtime/src/headers/from_entries_error.rs                          11       0  100.00%
crates/trogon-decider-runtime/src/headers/header_name.rs                                 33       0  100.00%
crates/trogon-cron/src/read_model/cron_job.rs                                            12      12  0.00%    16-30
crates/trogon-cron/src/read_model/message.rs                                             96      37  61.46%   13, 43, 64-66, 72-101, 127-151
crates/trogon-gateway/src/source/gitlab/gitlab_signing_token.rs                          74       0  100.00%
crates/trogon-gateway/src/source/gitlab/server.rs                                       460       0  100.00%
crates/trogon-gateway/src/source/gitlab/signature.rs                                    165       0  100.00%
crates/trogon-telemetry/src/service_name.rs                                              50       0  100.00%
crates/trogon-telemetry/src/trace.rs                                                     23       1  95.65%   22
crates/trogon-telemetry/src/lib.rs                                                      197      23  88.32%   94, 99, 104, 114-115, 121-139, 175, 178, 181, 187
crates/trogon-telemetry/src/resource_attribute.rs                                        23       0  100.00%
crates/trogon-telemetry/src/log.rs                                                       68       1  98.53%   33
crates/trogon-telemetry/src/metric.rs                                                    26       1  96.15%   29
crates/acp-nats/src/client/rpc_reply.rs                                                  64       0  100.00%
crates/acp-nats/src/client/terminal_wait_for_exit.rs                                    378       0  100.00%
crates/acp-nats/src/client/fs_write_text_file.rs                                        418       0  100.00%
crates/acp-nats/src/client/ext.rs                                                       308       8  97.40%   163-172, 189-198
crates/acp-nats/src/client/mod.rs                                                      2851       0  100.00%
crates/acp-nats/src/client/session_update.rs                                             55       0  100.00%
crates/acp-nats/src/client/fs_read_text_file.rs                                         356       0  100.00%
crates/acp-nats/src/client/terminal_create.rs                                           274       0  100.00%
crates/acp-nats/src/client/terminal_output.rs                                           206       0  100.00%
crates/acp-nats/src/client/ext_session_prompt_response.rs                               135       0  100.00%
crates/acp-nats/src/client/terminal_release.rs                                          347       0  100.00%
crates/acp-nats/src/client/terminal_kill.rs                                             290       0  100.00%
crates/acp-nats/src/client/request_permission.rs                                        308       0  100.00%
crates/acp-nats/src/session_id.rs                                                        71       0  100.00%
crates/acp-nats/src/in_flight_slot_guard.rs                                              32       0  100.00%
crates/acp-nats/src/acp_prefix.rs                                                        50       0  100.00%
crates/acp-nats/src/config.rs                                                           203       0  100.00%
crates/acp-nats/src/lib.rs                                                               69       0  100.00%
crates/acp-nats/src/ext_method_name.rs                                                   68       0  100.00%
crates/acp-nats/src/jsonrpc.rs                                                            6       0  100.00%
crates/acp-nats/src/client_proxy.rs                                                     181       0  100.00%
crates/acp-nats/src/pending_prompt_waiters.rs                                           134       0  100.00%
crates/acp-nats/src/error.rs                                                             82       0  100.00%
crates/acp-nats/src/req_id.rs                                                            39       0  100.00%
crates/trogon-cron/src/queries/job_id.rs                                                 48      26  45.83%   27-62, 75-83
crates/trogon-cron/src/queries/list.rs                                                   20      20  0.00%    10-34
crates/trogon-cron/src/queries/get.rs                                                    11       8  27.27%   18-28
crates/acp-nats/src/nats/subjects/mod.rs                                                362       0  100.00%
crates/acp-nats/src/nats/subjects/stream.rs                                              56       0  100.00%
crates/acp-nats-server/src/config.rs                                                    137       9  93.43%   41, 50-61
crates/acp-nats-server/src/main.rs                                                      896      10  98.88%   100, 231-238, 437
crates/acp-nats-server/src/transport.rs                                                1852     110  94.06%   277, 452, 536, 554, 581, 635, 640, 659, 671, 790, 813-815, 867, 884-887, 982-985, 1059, 1062, 1065, 1074, 1078, 1081, 1084-1087, 1106, 1138-1141, 1149-1154, 1166-1170, 1174-1183, 1195-1196, 1214-1215, 1225, 1241-1245, 1273-1279, 1290, 1293-1300, 1305-1309, 1312-1317, 1334, 1336-1337, 1419-1420, 1432-1433, 1453-1454, 1506-1522, 2218, 2261, 2313, 2368, 2380
crates/acp-nats-server/src/connection.rs                                                171      32  81.29%   76-83, 88-99, 115, 117-118, 123, 132-133, 138, 142, 146, 149, 157, 161, 164, 167-171, 207
crates/acp-nats-server/src/acp_connection_id.rs                                          45       0  100.00%
crates/acp-nats/src/nats/mod.rs                                                          23       0  100.00%
crates/acp-nats/src/nats/parsing.rs                                                     278       1  99.64%   151
crates/acp-nats/src/nats/extensions.rs                                                    3       0  100.00%
crates/mcp-nats-server/src/allowed_host.rs                                               90       0  100.00%
crates/mcp-nats-server/src/config.rs                                                    276       0  100.00%
crates/mcp-nats-server/src/main.rs                                                      357     127  64.43%   149-166, 202-204, 214, 220-221, 228-231, 255-257, 261-270, 292-305, 310-358, 489, 492, 500-542
crates/acp-nats/src/telemetry/metrics.rs                                                 53       0  100.00%
crates/mcp-nats/src/nats/subjects/mod.rs                                                 89       0  100.00%
crates/acp-nats/src/nats/subjects/responses/prompt_response.rs                           27       0  100.00%
crates/acp-nats/src/nats/subjects/responses/response.rs                                  20       0  100.00%
crates/acp-nats/src/nats/subjects/responses/update.rs                                    27       0  100.00%
crates/acp-nats/src/nats/subjects/responses/cancelled.rs                                 15       0  100.00%
crates/acp-nats/src/nats/subjects/responses/ext_ready.rs                                 12       0  100.00%
crates/trogon-gateway/src/http.rs                                                       192       1  99.48%   119
crates/trogon-gateway/src/source_integration_id.rs                                       61       3  95.08%   55, 57, 65
crates/trogon-gateway/src/main.rs                                                       116       0  100.00%
crates/trogon-gateway/src/config.rs                                                    2559      48  98.12%   91, 110, 328-329, 332, 712, 715, 875, 878, 881, 885, 960, 963, 966, 970, 1054-1061, 1138, 1141, 1144, 1149, 1207, 1210, 1213, 1292, 1295, 1298, 1302, 1366, 1369, 1372, 1435, 1438, 1441, 1446, 1521, 1524, 1527, 1532, 1590, 1593, 1596, 1809-1811
crates/trogon-gateway/src/source_status.rs                                               28       0  100.00%
crates/trogon-gateway/src/streams.rs                                                    169      10  94.08%   11, 23, 31, 39, 47, 55, 63, 71, 79, 87
crates/trogon-std/src/env/in_memory.rs                                                   73       0  100.00%
crates/trogon-std/src/env/system.rs                                                      17       0  100.00%
crates/trogon-std/src/telemetry/http.rs                                                 217       0  100.00%
crates/acp-nats-stdio/src/main.rs                                                       135      25  81.48%   65, 113-120, 126-128, 145, 174-193
crates/acp-nats-stdio/src/config.rs                                                      66       0  100.00%
crates/acp-nats/src/nats/subjects/global/ext_notify.rs                                    9       0  100.00%
crates/acp-nats/src/nats/subjects/global/authenticate.rs                                  6       0  100.00%
crates/acp-nats/src/nats/subjects/global/logout.rs                                        6       0  100.00%
crates/acp-nats/src/nats/subjects/global/session_list.rs                                  6       0  100.00%
crates/acp-nats/src/nats/subjects/global/session_new.rs                                   6       0  100.00%
crates/acp-nats/src/nats/subjects/global/ext.rs                                           9       0  100.00%
crates/acp-nats/src/nats/subjects/global/initialize.rs                                    6       0  100.00%
crates/trogon-decider-runtime/src/snapshot/read_snapshot.rs                              11       0  100.00%
crates/trogon-decider-runtime/src/snapshot/mod.rs                                         3       0  100.00%
crates/trogon-cron/src/schedule/resolved_job.rs                                         315      35  88.89%   119, 138-142, 144-148, 150-154, 184-188, 212-215, 225-229, 234-237, 244-247, 253, 259-260
crates/trogon-cron/src/commands/domain/job.rs                                           492     106  78.46%   94-96, 135-153, 194-212, 256-266, 327-345, 389-399, 439-441, 476-480, 530, 536-556, 562, 566-568, 575-603, 619-626, 641-646
crates/trogon-cron/src/commands/domain/job_event_delivery.rs                             17       0  100.00%
crates/trogon-cron/src/commands/domain/job_id.rs                                         48      12  75.00%   27-43, 81-83
crates/trogon-cron/src/commands/domain/job_details.rs                                    11       0  100.00%
crates/trogon-cron/src/commands/domain/job_event_sampling_source.rs                      11      11  0.00%    9-20
crates/trogon-cron/src/commands/domain/message.rs                                        98      27  72.45%   58-68, 101-103, 113-137, 149-151
crates/trogon-cron/src/commands/domain/job_event_schedule.rs                             11       6  45.45%   13, 15-19
crates/trogon-cron/src/commands/domain/job_event_status.rs                                5       1  80.00%   14
crates/mcp-nats/src/nats/subjects/server/resource_list_changed.rs                        12       0  100.00%
crates/mcp-nats/src/nats/subjects/server/list_tasks.rs                                   12       0  100.00%
crates/mcp-nats/src/nats/subjects/server/logging_message.rs                              12       0  100.00%
crates/mcp-nats/src/nats/subjects/server/resource_updated.rs                             12       0  100.00%
crates/mcp-nats/src/nats/subjects/server/list_prompts.rs                                 12       0  100.00%
crates/mcp-nats/src/nats/subjects/server/set_logging_level.rs                            12       0  100.00%
crates/mcp-nats/src/nats/subjects/server/subscribe_resource.rs                           12       0  100.00%
crates/mcp-nats/src/nats/subjects/server/tool_list_changed.rs                            12       0  100.00%
crates/mcp-nats/src/nats/subjects/server/list_resources.rs                               12       0  100.00%
crates/mcp-nats/src/nats/subjects/server/unsubscribe_resource.rs                         12       0  100.00%
crates/mcp-nats/src/nats/subjects/server/get_task.rs                                     12       0  100.00%
crates/mcp-nats/src/nats/subjects/server/list_tools.rs                                   12       0  100.00%
crates/mcp-nats/src/nats/subjects/server/list_resource_templates.rs                      12       0  100.00%
crates/mcp-nats/src/nats/subjects/server/initialize.rs                                   12       0  100.00%
crates/mcp-nats/src/nats/subjects/server/call_tool.rs                                    12       0  100.00%
crates/mcp-nats/src/nats/subjects/server/ping.rs                                          9       0  100.00%
crates/mcp-nats/src/nats/subjects/server/cancelled.rs                                    12       0  100.00%
crates/mcp-nats/src/nats/subjects/server/elicitation_completed.rs                        12       0  100.00%
crates/mcp-nats/src/nats/subjects/server/get_prompt.rs                                   12       0  100.00%
crates/mcp-nats/src/nats/subjects/server/complete.rs                                     12       0  100.00%
crates/mcp-nats/src/nats/subjects/server/get_task_result.rs                              12       0  100.00%
crates/mcp-nats/src/nats/subjects/server/progress.rs                                     12       0  100.00%
crates/mcp-nats/src/nats/subjects/server/cancel_task.rs                                  12       0  100.00%
crates/mcp-nats/src/nats/subjects/server/prompt_list_changed.rs                          12       0  100.00%
crates/mcp-nats/src/nats/subjects/server/read_resource.rs                                12       0  100.00%
crates/trogon-gateway/src/source/github/server.rs                                       328       0  100.00%
crates/trogon-gateway/src/source/github/config.rs                                        17       0  100.00%
crates/trogon-gateway/src/source/github/signature.rs                                     61       0  100.00%
crates/trogon-nats/src/lease/provision.rs                                               187      10  94.65%   82-92
crates/trogon-nats/src/lease/mod.rs                                                     561      13  97.68%   180-193
crates/trogon-nats/src/lease/release.rs                                                   5       5  0.00%    8-12
crates/trogon-nats/src/lease/acquire.rs                                                   5       5  0.00%    9-14
crates/trogon-nats/src/lease/renew_interval.rs                                           61       0  100.00%
crates/trogon-nats/src/lease/lease_timing.rs                                             15       0  100.00%
crates/trogon-nats/src/lease/nats_kv_lease_config.rs                                     26       0  100.00%
crates/trogon-nats/src/lease/lease_config_error.rs                                       11       0  100.00%
crates/trogon-nats/src/lease/lease_key.rs                                                19       0  100.00%
crates/trogon-nats/src/lease/lease_bucket.rs                                             19       0  100.00%
crates/trogon-nats/src/lease/renew.rs                                                   246      19  92.28%   23-29, 48-59
crates/trogon-nats/src/lease/ttl.rs                                                      73       0  100.00%
crates/trogon-std/src/fs/system.rs                                                       92       0  100.00%
crates/trogon-std/src/fs/mem.rs                                                         216      10  95.37%   61-63, 77-79, 132-134, 157
crates/trogon-cron/src/processors/scheduler.rs                                          703     334  52.49%   108-133, 158-286, 299-406, 414-418, 429-433, 450-453, 458-462, 468-471, 478-481, 485-505, 513-515, 561, 594-662, 688-716, 794-800, 815-817, 919, 957
crates/trogon-decider-runtime/src/event/mod.rs                                          162       0  100.00%
crates/trogon-decider-runtime/src/event/stream_event.rs                                   8       0  100.00%
crates/trogon-decider-runtime/src/event/event_identity.rs                                 3       0  100.00%
crates/trogon-decider-runtime/src/event/event_id.rs                                      32       0  100.00%
crates/acp-nats/src/jetstream/provision.rs                                               53       0  100.00%
crates/acp-nats/src/jetstream/streams.rs                                                163       4  97.55%   206-208, 218
crates/acp-nats/src/jetstream/ext_policy.rs                                              26       0  100.00%
crates/acp-nats/src/jetstream/consumers.rs                                               91       0  100.00%
crates/trogon-decider-nats/src/snapshot_store.rs                                        415     256  38.31%   43-45, 75-93, 99-100, 112-117, 185-477
crates/trogon-decider-nats/src/stream_store.rs                                          322     187  41.93%   28-135, 163-278, 297-300, 308-319
crates/trogon-decider-nats/src/lib.rs                                                   166     166  0.00%    63-316
crates/trogon-service-config/src/lib.rs                                                  92       0  100.00%
crates/acp-nats/src/nats/subjects/client_ops/terminal_output.rs                          12       0  100.00%
crates/acp-nats/src/nats/subjects/client_ops/terminal_release.rs                         12       0  100.00%
crates/acp-nats/src/nats/subjects/client_ops/session_request_permission.rs               12       0  100.00%
crates/acp-nats/src/nats/subjects/client_ops/terminal_wait_for_exit.rs                   12       0  100.00%
crates/acp-nats/src/nats/subjects/client_ops/session_update.rs                           12       0  100.00%
crates/acp-nats/src/nats/subjects/client_ops/fs_read_text_file.rs                        12       0  100.00%
crates/acp-nats/src/nats/subjects/client_ops/fs_write_text_file.rs                       12       0  100.00%
crates/acp-nats/src/nats/subjects/client_ops/terminal_kill.rs                            12       0  100.00%
crates/acp-nats/src/nats/subjects/client_ops/terminal_create.rs                          12       0  100.00%
crates/mcp-nats/src/telemetry/transport.rs                                                6       0  100.00%
crates/acp-nats/src/nats/subjects/subscriptions/all_session.rs                            9       0  100.00%
crates/acp-nats/src/nats/subjects/subscriptions/one_client.rs                            15       0  100.00%
crates/acp-nats/src/nats/subjects/subscriptions/one_agent.rs                             15       0  100.00%
crates/acp-nats/src/nats/subjects/subscriptions/one_session.rs                           12       0  100.00%
crates/acp-nats/src/nats/subjects/subscriptions/prompt_wildcard.rs                        9       0  100.00%
crates/acp-nats/src/nats/subjects/subscriptions/global_all.rs                             9       0  100.00%
crates/acp-nats/src/nats/subjects/subscriptions/all_agent_ext.rs                          9       0  100.00%
crates/acp-nats/src/nats/subjects/subscriptions/all_client.rs                             9       0  100.00%
crates/acp-nats/src/nats/subjects/subscriptions/all_agent.rs                              9       0  100.00%
crates/trogon-cron/src/commands/add_job.rs                                              173       6  96.53%   21-23, 57, 60, 79
crates/trogon-cron/src/commands/pause_job.rs                                            124       6  95.16%   21-23, 55, 58, 73
crates/trogon-cron/src/commands/resume_job.rs                                           135       6  95.56%   21-23, 55, 58, 73
crates/trogon-cron/src/commands/remove_job.rs                                           108       6  94.44%   20-22, 54, 57, 71
crates/trogon-cron/src/commands/state.rs                                                112      16  85.71%   12-18, 31, 34, 42-44, 50, 54, 61, 68, 74
crates/mcp-nats/src/nats/subjects/client/initialized.rs                                  12       0  100.00%
crates/mcp-nats/src/nats/subjects/client/create_elicitation.rs                           12       0  100.00%
crates/mcp-nats/src/nats/subjects/client/list_roots.rs                                   12       0  100.00%
crates/mcp-nats/src/nats/subjects/client/create_message.rs                               12       0  100.00%
crates/mcp-nats/src/nats/subjects/client/roots_list_changed.rs                           12       0  100.00%
crates/mcp-nats/src/nats/subjects/client/progress.rs                                     12       0  100.00%
crates/mcp-nats/src/nats/subjects/client/cancelled.rs                                    12       0  100.00%
crates/mcp-nats/src/nats/subjects/client/ping.rs                                          9       0  100.00%
crates/trogon-gateway/src/source/discord/gateway.rs                                     426       1  99.77%   137
crates/trogon-gateway/src/source/discord/config.rs                                      108       0  100.00%
crates/trogon-decider-runtime/src/event/codec/event_decode.rs                             3       0  100.00%
crates/trogon-cron/src/main.rs                                                            4       0  100.00%
crates/trogon-cron/src/error.rs                                                         228      35  84.65%   116, 223-273
crates/trogon-cron/src/mocks.rs                                                         558     102  81.72%   103-109, 222-225, 247, 257, 259-263, 287-298, 308-310, 319, 333-336, 342-344, 350, 354-358, 369-373, 377-386, 407, 416, 423-427, 455-463, 485-489, 494-498, 504-508, 516-519, 560-579
crates/trogon-cron/src/config.rs                                                         50       0  100.00%
crates/trogon-decider-runtime/src/execution.rs                                         1202       0  100.00%
crates/trogon-gateway/src/source/linear/server.rs                                       386       0  100.00%
crates/trogon-gateway/src/source/linear/signature.rs                                     54       1  98.15%   16
crates/trogon-gateway/src/source/linear/config.rs                                        17       0  100.00%
crates/trogon-gateway/src/source/microsoft_graph/client_state.rs                         30       0  100.00%
crates/trogon-gateway/src/source/microsoft_graph/server.rs                              325       0  100.00%
crates/trogon-gateway/src/source/sentry/server.rs                                       311       0  100.00%
crates/trogon-gateway/src/source/sentry/signature.rs                                     54       0  100.00%
crates/trogon-gateway/src/source/sentry/sentry_client_secret.rs                          17       0  100.00%
crates/trogon-nats/src/telemetry/messaging.rs                                            82       0  100.00%
crates/mcp-nats/src/nats/parsing.rs                                                     191       0  100.00%
crates/mcp-nats/src/nats/mod.rs                                                          99       0  100.00%
crates/trogon-nats/src/auth.rs                                                          114       0  100.00%
crates/trogon-nats/src/nats_token.rs                                                    157       0  100.00%
crates/trogon-nats/src/token.rs                                                           6       0  100.00%
crates/trogon-nats/src/messaging.rs                                                     561       2  99.64%   144, 154
crates/trogon-nats/src/mocks.rs                                                         317       0  100.00%
crates/trogon-nats/src/subject_token_violation.rs                                        17       0  100.00%
crates/trogon-nats/src/connect.rs                                                        94       9  90.43%   22-23, 33, 60-65
crates/trogon-decider-runtime/src/snapshot/codec/encoded_snapshot.rs                     59       0  100.00%
crates/trogon-decider-runtime/src/snapshot/codec/snapshot_encode_error.rs                23       0  100.00%
crates/trogon-decider-runtime/src/snapshot/codec/snapshot_envelope_decode_error.rs       38       0  100.00%
crates/trogon-decider-runtime/src/snapshot/codec/snapshot_payload_decode.rs               3       0  100.00%
crates/trogon-decider-runtime/src/snapshot/codec/snapshot_decode_error.rs                23       0  100.00%
crates/trogon-decider-runtime/src/snapshot/codec/snapshot_envelope_encode_error.rs       20       0  100.00%
crates/trogon-gateway/src/source/telegram/server.rs                                     339       0  100.00%
crates/trogon-gateway/src/source/telegram/signature.rs                                   32       0  100.00%
crates/trogon-gateway/src/source/telegram/registration.rs                               327       0  100.00%
crates/trogon-gateway/src/source/telegram/config.rs                                     109       0  100.00%
crates/acp-nats/src/agent/resume_session.rs                                              90       0  100.00%
crates/acp-nats/src/agent/set_session_config_option.rs                                   67       0  100.00%
crates/acp-nats/src/agent/set_session_mode.rs                                            67       0  100.00%
crates/acp-nats/src/agent/set_session_model.rs                                           67       0  100.00%
crates/acp-nats/src/agent/test_support.rs                                               267       0  100.00%
crates/acp-nats/src/agent/cancel.rs                                                     101       0  100.00%
crates/acp-nats/src/agent/ext_method.rs                                                  82       0  100.00%
crates/acp-nats/src/agent/js_request.rs                                                 283       0  100.00%
crates/acp-nats/src/agent/load_session.rs                                                89       0  100.00%
crates/acp-nats/src/agent/initialize.rs                                                  79       0  100.00%
crates/acp-nats/src/agent/ext_notification.rs                                            82       0  100.00%
crates/acp-nats/src/agent/logout.rs                                                      49       0  100.00%
crates/acp-nats/src/agent/prompt.rs                                                     471       0  100.00%
crates/acp-nats/src/agent/mod.rs                                                         65       0  100.00%
crates/acp-nats/src/agent/close_session.rs                                               63       0  100.00%
crates/acp-nats/src/agent/authenticate.rs                                                49       0  100.00%
crates/acp-nats/src/agent/fork_session.rs                                                94       0  100.00%
crates/acp-nats/src/agent/bridge.rs                                                     123       4  96.75%   108-111
crates/acp-nats/src/agent/new_session.rs                                                 82       0  100.00%
crates/acp-nats/src/agent/list_sessions.rs                                               47       0  100.00%
crates/trogon-cron-jobs-proto/src/lib.rs                                                178      19  89.33%   34-61, 73, 116
crates/trogon-gateway/src/source/slack/server.rs                                        863       0  100.00%
crates/trogon-gateway/src/source/slack/config.rs                                         17       0  100.00%
crates/trogon-gateway/src/source/slack/signature.rs                                      77       0  100.00%
TOTAL                                                                                 41531    2416  94.18%

Diff against main

Filename                                                               Stmts    Miss  Cover
-------------------------------------------------------------------  -------  ------  --------
crates/trogon-cron/src/projections/cron_jobs.rs                         +699    +414  +40.77%
crates/trogon-cron/src/read_model/cron_job.rs                            +12     +12  +100.00%
crates/trogon-cron/src/read_model/message.rs                             +96     +37  +61.46%
crates/trogon-telemetry/src/service_name.rs                               +6       0  +100.00%
crates/trogon-cron/src/queries/job_id.rs                                 +48     +26  +45.83%
crates/trogon-cron/src/queries/list.rs                                   +20     +20  +100.00%
crates/trogon-cron/src/queries/get.rs                                    +11      +8  +27.27%
crates/acp-nats-server/src/transport.rs                                    0      +4  -0.22%
crates/trogon-cron/src/schedule/resolved_job.rs                         +315     +35  +88.89%
crates/trogon-cron/src/commands/domain/job.rs                           +492    +106  +78.46%
crates/trogon-cron/src/commands/domain/job_event_delivery.rs             +17       0  +100.00%
crates/trogon-cron/src/commands/domain/job_id.rs                         +48     +12  +75.00%
crates/trogon-cron/src/commands/domain/job_details.rs                    +11       0  +100.00%
crates/trogon-cron/src/commands/domain/job_event_sampling_source.rs      +11     +11  +100.00%
crates/trogon-cron/src/commands/domain/message.rs                        +98     +27  +72.45%
crates/trogon-cron/src/commands/domain/job_event_schedule.rs             +11      +6  +45.45%
crates/trogon-cron/src/commands/domain/job_event_status.rs                +5      +1  +80.00%
crates/trogon-cron/src/processors/scheduler.rs                          +703    +334  +52.49%
crates/trogon-decider-nats/src/snapshot_store.rs                        +415    +256  +38.31%
crates/trogon-decider-nats/src/stream_store.rs                          +322    +187  +41.93%
crates/trogon-decider-nats/src/lib.rs                                   +166    +166  +100.00%
crates/trogon-cron/src/commands/add_job.rs                              +173      +6  +96.53%
crates/trogon-cron/src/commands/pause_job.rs                            +124      +6  +95.16%
crates/trogon-cron/src/commands/resume_job.rs                           +135      +6  +95.56%
crates/trogon-cron/src/commands/remove_job.rs                           +108      +6  +94.44%
crates/trogon-cron/src/commands/state.rs                                +112     +16  +85.71%
crates/trogon-cron/src/main.rs                                            +4       0  +100.00%
crates/trogon-cron/src/error.rs                                         +228     +35  +84.65%
crates/trogon-cron/src/mocks.rs                                         +558    +102  +81.72%
crates/trogon-cron/src/config.rs                                         +50       0  +100.00%
crates/trogon-cron-jobs-proto/src/lib.rs                                +178     +19  +89.33%
TOTAL                                                                  +5176   +1858  -4.10%

Results for commit: 5d02745

Minimum allowed coverage is 95%

♻️ This comment has been updated with latest results

@yordis yordis changed the title feat: add trogon-cron scheduler, tooling, and coverage feat: add trogon-cron scheduler Apr 9, 2026
@yordis yordis force-pushed the CRON branch 4 times, most recently from 1d1431c to 8652b66 Compare April 9, 2026 19:38
Comment thread rsworkspace/crates/trogon-cron/src/executor.rs Outdated
Comment thread rsworkspace/crates/trogon-cron/src/scheduler.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🧹 Nitpick comments (5)
rsworkspace/crates/trogon-cron/Dockerfile (1)

16-16: Add --no-install-recommends to reduce image size.

The apt-get install commands should include --no-install-recommends to avoid pulling unnecessary packages.

📦 Proposed fix
-RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
+RUN apt-get update && apt-get install -y --no-install-recommends pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rsworkspace/crates/trogon-cron/Dockerfile` at line 16, Modify the Dockerfile
RUN line that installs system packages so apt-get install uses
--no-install-recommends to avoid pulling unnecessary packages; update the RUN
command that currently calls "apt-get install -y pkg-config libssl-dev" to
include "--no-install-recommends" and keep the apt-get update and rm -rf
/var/lib/apt/lists/* steps intact.
rsworkspace/crates/trogon-cron/src/error.rs (1)

3-4: Kv and Publish variants discard error context by storing strings.

Per coding guidelines, errors should wrap the source error as a field or variant instead of converting to a string. The Kv(String) and Publish(String) variants likely originate from NATS KV/publish errors and should preserve the original error type for proper error chaining.

Consider wrapping the underlying async_nats error types:

 pub enum CronError {
-    Kv(String),
-    Publish(String),
+    Kv(async_nats::jetstream::kv::CreateError), // or appropriate error type
+    Publish(async_nats::PublishError),          // or appropriate error type

Then update source() to return Some(e) for these variants as well.

As per coding guidelines: "Never discard error context by converting a typed error into a string; wrap the source error as a field or variant instead."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rsworkspace/crates/trogon-cron/src/error.rs` around lines 3 - 4, The
Kv(String) and Publish(String) enum variants in the Error type discard original
error context; change them to hold the underlying async_nats error (e.g.,
Kv(async_nats::Error) and Publish(async_nats::Error) or a boxed source like
Kv(Box<dyn std::error::Error + Send + Sync>)) so the original error is
preserved, update any constructors/From impls that currently convert to String
to wrap the original error instead, and modify the Error::source(&self)
implementation to return Some(inner_error) for the Kv and Publish variants so
proper error chaining is maintained.
rsworkspace/crates/trogon-cron/src/config.rs (1)

325-334: Consider moving TickPayload before the tests module.

TickPayload is defined after #[cfg(test)] mod tests, which is unconventional. While valid Rust, it may confuse readers who expect all public types to be defined before tests.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rsworkspace/crates/trogon-cron/src/config.rs` around lines 325 - 334, The
TickPayload struct is defined after the #[cfg(test)] mod tests block which is
unconventional and can confuse readers; move the TickPayload definition (the pub
struct TickPayload with fields job_id, fired_at, execution_id, payload and its
derives Serialize/Deserialize) so it appears before the tests module (i.e.,
place TickPayload above the tests mod) and ensure any test code referencing
TickPayload still compiles after the reorder.
rsworkspace/crates/trogon-cron/src/kv.rs (2)

96-127: The 500ms timeout for empty-bucket detection may be fragile.

The hardcoded 500ms deadline assumes the initial watcher snapshot arrives within that window. On high-latency networks or under load, this could cause the scheduler to start with an incomplete job set, silently missing jobs that arrive after the timeout but before the first delta == 0 entry.

Consider either:

  1. Making the timeout configurable
  2. Using the NATS pending_count or similar metadata to detect completion
  3. Documenting this limitation prominently
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rsworkspace/crates/trogon-cron/src/kv.rs` around lines 96 - 127, The
hardcoded 500ms deadline used via tokio::time::sleep (pinned as deadline) to
decide the watcher initial snapshot is fragile and can drop jobs; replace it
with a robust approach: either make the timeout configurable (expose a
parameter/const used where deadline is created) or remove the timeout and use
watcher metadata (e.g., NATS pending_count or another completion signal) to
detect the end-of-snapshot instead of breaking on timeout; update the loop
around watcher.next()/delta checks (and error path returning CronError::Kv) to
rely on the chosen mechanism so JobConfig deserialization and the is_last
(e.delta == 0) logic drive completion reliably.

55-62: Swallowing the create_stream error loses diagnostic information.

If create_stream fails for a reason other than "stream already exists" (e.g., permission denied, invalid config), the code falls through to get_stream, which may fail with a misleading error. Consider matching on the specific "already exists" error variant rather than catching all errors.

match js.create_stream(config).await {
    Ok(_) => Ok(()),
    Err(e) if e.kind() == CreateStreamErrorKind::StreamNameExists => {
        // Already exists, verify it's accessible
        js.get_stream(TICKS_STREAM).await.map(|_| ()).map_err(...)
    }
    Err(e) => Err(CronError::Stream(e)), // Propagate actual creation failures
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rsworkspace/crates/trogon-cron/src/kv.rs` around lines 55 - 62, The current
match on js.create_stream(config).await swallows all errors and falls back to
js.get_stream(TICKS_STREAM), losing the original failure details; update the
match to pattern-match the specific "stream already exists" error (e.g., Err(e)
if e.kind() == CreateStreamErrorKind::StreamNameExists) and only in that case
call js.get_stream(TICKS_STREAM).await.map(|_| ()).map_err(|e|
CronError::Kv(e.to_string())); for any other Err(e) from js.create_stream return
Err(CronError::Stream(e.to_string())) (or the appropriate CronError variant) so
creation failures are propagated instead of masked.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@rsworkspace/crates/trogon-cron/Dockerfile`:
- Around line 22-31: The runtime Dockerfile currently runs the container as
root; modify the runtime stage to create a dedicated non-root user and group
(e.g., trogon), ensure the copied binary /usr/local/bin/trogon-cron is owned by
that user (chown) and has executable permissions, set a sensible HOME if needed,
and add a USER trogon directive before ENTRYPOINT so the container runs as the
non-root user; reference the runtime stage, the COPY of
/usr/local/bin/trogon-cron, ENTRYPOINT ["trogon-cron"], and CMD ["serve"] when
making these changes.

In `@rsworkspace/crates/trogon-cron/src/client.rs`:
- Around line 73-80: The code in set_enabled uses CronError::Kv(format!(...))
for a missing job; add a typed error variant like CronError::JobNotFound { id:
String } in your error enum (e.g., in error.rs) and replace the ok_or_else
mapping in set_enabled to return CronError::JobNotFound { id: id.to_string() }
instead of constructing a formatted string; update any other sites that
currently create "job not found" Kv strings to use the new JobNotFound variant
so callers can match on the typed error.

In `@rsworkspace/crates/trogon-cron/src/executor.rs`:
- Around line 279-281: The current guard around publish_dead_letter (the if
max_retries > 0 check) prevents publishing a DLQ event when an execution
permanently fails with zero retries; update the failure path so
publish_dead_letter(&publisher, &tick, "publish", actual_attempts,
last_error).await is invoked whenever execution ultimately fails (i.e., when
last_error indicates a terminal failure), regardless of max_retries or retry:
None. Remove or replace the max_retries conditional in the block that handles
final failures (referencing publish_dead_letter, publisher, tick,
actual_attempts, last_error) and make the same change in the analogous block
around lines 442-444 so dead-letter events are always emitted for permanent
failures.
- Around line 224-280: The retry loop is converting errors to String (last_error
= e.to_string()) and losing typed context; instead introduce a small internal
error enum (e.g., PublishError with variants for NatsPublish, Spawn, etc.) and
change last_error from String to that enum (or Option<PublishError>), assign
Err(e) into the appropriate variant when publish_tick fails, and propagate that
typed error through the loop; only convert to String when calling
publish_dead_letter and in the final tracing::error (i.e., stringify inside
publish_dead_letter or right at the logging call). Update publish_dead_letter
(or its callsite) to accept the typed error or perform the to_string there, and
ensure the enum implements Display/Debug/From for the original error types so no
source context is lost.
- Around line 184-201: The concurrent check in RuntimeAction::Spawn uses
state.is_running.load(...) then later sets it, which is racy; replace the
load+separate store with an atomic compare_exchange on state.is_running to
change false -> true (using appropriate Orderings, e.g., SeqCst or
Acquire/Release) and only call spawn_process when compare_exchange succeeds;
apply the same compare_exchange replacement for the other occurrence around
lines 297-303 so the guard for non-concurrent jobs is claimed atomically before
invoking spawn_process.

In `@rsworkspace/crates/trogon-cron/src/nats_impls.rs`:
- Around line 195-218: The TickPublisher::publish_tick implementation is
discarding typed error context by converting PublishOutcome errors to strings;
update the error handling to preserve and wrap the original error types instead
of calling to_string(). Add variants to CronError (e.g.,
CronError::PublishError(PublishError) and CronError::AckError(AckError) or a
single CronError::PublishSource(Box<dyn Error + Send + Sync>)) and map
PublishOutcome::PublishFailed(e), ::AckFailed(e), and ::StoreFailed(e) to those
variants (or use .source() wrapping) when returning Err; ensure publish_tick
(and any call sites of publish_event / JetStreamContextPublisher) return the new
CronError variants so the original PublishError/AckError types and context are
preserved rather than being stringified.
- Around line 36-49: The NatsConfigStore::put_job implementation wraps SDK
errors into CronError::Kv by converting them to strings and uses map_err,
violating the zero-cost passthrough rule; change the implementation of
ConfigStore for NatsConfigStore to return the underlying SDK errors directly
(i.e., remove map_err calls around self.js.get_key_value(...) and kv.put(...)
and avoid converting errors to strings) or adjust CronError::Kv to carry the
original SDK error type so you can use the ? operator without losing context;
ensure serde_json::to_vec errors are propagated similarly (e.g., via ?), so the
body of put_job is a direct await/return passthrough to js.get_key_value and
kv.put with no map_err conversions.
- Around line 234-259: The code in NatsLeaderLock's try_acquire, renew, and
release is converting underlying store errors to strings (map_err(|e|
CronError::Kv(e.to_string()))) which discards original error types; change this
by making CronError::Kv carry the original error (or a boxed dyn Error) and/or
implement From for the store error types, then replace map_err calls with
map_err(Into::into) or map_err(CronError::from) so create/update/delete errors
are preserved; update the CronError enum definition to accept the concrete
source types (or Box<dyn std::error::Error + Send + Sync>) and adjust
try_acquire, renew, and release to forward the original
CreateError/UpdateError/DeleteError instead of to_string.

In `@rsworkspace/crates/trogon-cron/src/scheduler.rs`:
- Around line 180-201: The helper reestablish_config_watch currently blocks
retries and is not cancellation-aware, which can prevent run() from observing
shutdown signals; modify reestablish_config_watch to accept a cancellation
trigger (e.g., a ShutdownReceiver, a CancellationToken, or an async
shutdown_signal future) or return early on cancellation, then inside the loop
use tokio::select! to race ConfigStore::load_and_watch().await and the shutdown
trigger (and the sleep retry against the shutdown), returning an Err/early exit
when cancelled so the outer run() can handle graceful leader-lock release;
alternatively move the retry sleep/select logic up into run() and keep
reestablish_config_watch as a single-attempt helper that returns its
watcher/error to the caller.
- Around line 153-156: preserve_runtime_state currently only copies is_running
and publish_in_flight, causing last_fired to reset and interval jobs to fire
immediately after reload; update preserve_runtime_state (and the build_job_state
path that calls it) to also carry forward the prior firing state by copying
old_state.last_fired into new_state.last_fired when the schedule is unchanged,
or, if the schedule has changed, recompute new_state.last_fired from
old_state.last_fired (e.g., advance/snap it through the new schedule) so reloads
do not treat every reload as a first load; refer to preserve_runtime_state,
build_job_state, and JobState.last_fired to locate where to copy or recompute
the value.

---

Nitpick comments:
In `@rsworkspace/crates/trogon-cron/Dockerfile`:
- Line 16: Modify the Dockerfile RUN line that installs system packages so
apt-get install uses --no-install-recommends to avoid pulling unnecessary
packages; update the RUN command that currently calls "apt-get install -y
pkg-config libssl-dev" to include "--no-install-recommends" and keep the apt-get
update and rm -rf /var/lib/apt/lists/* steps intact.

In `@rsworkspace/crates/trogon-cron/src/config.rs`:
- Around line 325-334: The TickPayload struct is defined after the #[cfg(test)]
mod tests block which is unconventional and can confuse readers; move the
TickPayload definition (the pub struct TickPayload with fields job_id, fired_at,
execution_id, payload and its derives Serialize/Deserialize) so it appears
before the tests module (i.e., place TickPayload above the tests mod) and ensure
any test code referencing TickPayload still compiles after the reorder.

In `@rsworkspace/crates/trogon-cron/src/error.rs`:
- Around line 3-4: The Kv(String) and Publish(String) enum variants in the Error
type discard original error context; change them to hold the underlying
async_nats error (e.g., Kv(async_nats::Error) and Publish(async_nats::Error) or
a boxed source like Kv(Box<dyn std::error::Error + Send + Sync>)) so the
original error is preserved, update any constructors/From impls that currently
convert to String to wrap the original error instead, and modify the
Error::source(&self) implementation to return Some(inner_error) for the Kv and
Publish variants so proper error chaining is maintained.

In `@rsworkspace/crates/trogon-cron/src/kv.rs`:
- Around line 96-127: The hardcoded 500ms deadline used via tokio::time::sleep
(pinned as deadline) to decide the watcher initial snapshot is fragile and can
drop jobs; replace it with a robust approach: either make the timeout
configurable (expose a parameter/const used where deadline is created) or remove
the timeout and use watcher metadata (e.g., NATS pending_count or another
completion signal) to detect the end-of-snapshot instead of breaking on timeout;
update the loop around watcher.next()/delta checks (and error path returning
CronError::Kv) to rely on the chosen mechanism so JobConfig deserialization and
the is_last (e.delta == 0) logic drive completion reliably.
- Around line 55-62: The current match on js.create_stream(config).await
swallows all errors and falls back to js.get_stream(TICKS_STREAM), losing the
original failure details; update the match to pattern-match the specific "stream
already exists" error (e.g., Err(e) if e.kind() ==
CreateStreamErrorKind::StreamNameExists) and only in that case call
js.get_stream(TICKS_STREAM).await.map(|_| ()).map_err(|e|
CronError::Kv(e.to_string())); for any other Err(e) from js.create_stream return
Err(CronError::Stream(e.to_string())) (or the appropriate CronError variant) so
creation failures are propagated instead of masked.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 824714aa-1b17-40de-8d8e-1d77a4a2f5c4

📥 Commits

Reviewing files that changed from the base of the PR and between 290461a and 8652b66.

⛔ Files ignored due to path filters (1)
  • rsworkspace/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (19)
  • rsworkspace/crates/AGENTS.md
  • rsworkspace/crates/trogon-cron/Cargo.toml
  • rsworkspace/crates/trogon-cron/Dockerfile
  • rsworkspace/crates/trogon-cron/docker-compose.yml
  • rsworkspace/crates/trogon-cron/src/client.rs
  • rsworkspace/crates/trogon-cron/src/config.rs
  • rsworkspace/crates/trogon-cron/src/domain.rs
  • rsworkspace/crates/trogon-cron/src/error.rs
  • rsworkspace/crates/trogon-cron/src/executor.rs
  • rsworkspace/crates/trogon-cron/src/kv.rs
  • rsworkspace/crates/trogon-cron/src/leader.rs
  • rsworkspace/crates/trogon-cron/src/lib.rs
  • rsworkspace/crates/trogon-cron/src/main.rs
  • rsworkspace/crates/trogon-cron/src/mocks.rs
  • rsworkspace/crates/trogon-cron/src/nats_impls.rs
  • rsworkspace/crates/trogon-cron/src/scheduler.rs
  • rsworkspace/crates/trogon-cron/src/traits.rs
  • rsworkspace/crates/trogon-cron/tests/cron_unit.rs
  • rsworkspace/crates/trogon-cron/tests/integration.rs

Comment thread rsworkspace/crates/trogon-cron/Dockerfile Outdated
Comment thread rsworkspace/crates/trogon-cron/src/client.rs Outdated
Comment thread rsworkspace/crates/trogon-cron/src/executor.rs Outdated
Comment thread rsworkspace/crates/trogon-cron/src/executor.rs Outdated
Comment thread rsworkspace/crates/trogon-cron/src/executor.rs Outdated
Comment thread rsworkspace/crates/trogon-cron/src/nats_impls.rs Outdated
Comment thread rsworkspace/crates/trogon-cron/src/nats_impls.rs Outdated
Comment thread rsworkspace/crates/trogon-cron/src/nats_impls.rs Outdated
Comment thread rsworkspace/crates/trogon-cron/src/scheduler.rs Outdated
Comment thread rsworkspace/crates/trogon-cron/src/scheduler.rs Outdated
@yordis yordis added the rust:coverage-baseline-reset Relax Rust coverage gate to establish a new baseline label Apr 9, 2026
@yordis yordis force-pushed the CRON branch 2 times, most recently from f0d1276 to 6728775 Compare April 9, 2026 19:53
Comment thread rsworkspace/crates/trogon-cron/src/executor.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (6)
rsworkspace/crates/trogon-cron/src/nats_impls.rs (3)

36-120: ⚠️ Potential issue | 🟠 Major

Stop stringifying JetStream errors in the ConfigStore impl.

These map_err(|e| CronError::Kv(e.to_string())) calls erase the concrete async-nats error types on every KV operation. Either let CronError::Kv wrap the SDK errors directly or give ConfigStore an associated error type so these methods can stay passthroughs and use ?.

As per coding guidelines: "Production implementations of infrastructure traits must be zero-cost passthroughs to the underlying SDK. No error wrapping (use the SDK's error types directly via associated type Error), no return type conversion, no map_err, no map(|_| ())." and "Never discard error context by converting a typed error into a string; wrap the source error as a field or variant instead."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rsworkspace/crates/trogon-cron/src/nats_impls.rs` around lines 36 - 120, The
current NatsConfigStore implementation for ConfigStore (methods put_job,
get_job, delete_job, list_jobs, load_and_watch) is converting
async-nats/jetstream SDK errors to strings via map_err(|e|
CronError::Kv(e.to_string())), losing typed error context; fix by making these
methods passthrough zero-cost: either change CronError::Kv to hold the original
SDK error type (wrap the error instead of to_string) or update the ConfigStore
signature to use an associated Error type and return the SDK errors directly
(remove map_err and the e.to_string() conversions) so all KV calls
(get_key_value, entry, delete, keys, get, etc.) propagate the concrete SDK
errors via ?.

194-215: ⚠️ Potential issue | 🟠 Major

Preserve the original PublishOutcome failure sources here.

PublishFailed, AckFailed, AckTimedOut, and StoreFailed all get collapsed into CronError::Publish(String), so the caller loses the concrete publish/ack/store failure type and nested context. This should stay typed through the TickPublisher boundary.

As per coding guidelines: "Never discard error context by converting a typed error into a string; wrap the source error as a field or variant instead."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rsworkspace/crates/trogon-cron/src/nats_impls.rs` around lines 194 - 215, The
publish_tick implementation on jetstream::Context currently collapses
PublishOutcome variants into CronError::Publish(String); change CronError to
preserve source error types (e.g., add variants
CronError::PublishFailed(PublishError), CronError::AckFailed(AckError),
CronError::AckTimedOut(Duration) and CronError::StoreFailed(StoreError) or a
single CronError::Publish{source: PublishOutcomeError} that wraps the original
error), then update publish_tick to map each PublishOutcome variant to the
corresponding typed CronError (use .map_err(|e| CronError::PublishFailed(e)) or
construct the appropriate variant for AckTimedOut with the timeout), ensuring
you propagate the original error values instead of converting them to strings;
touch the publish_tick function, the PublishOutcome match arms, and the
CronError enum to implement this typed error propagation.

233-258: ⚠️ Potential issue | 🟠 Major

Leader-lock KV operations still erase SDK error types.

try_acquire, renew, and release are still doing CronError::Kv(e.to_string()), which drops the original create/update/delete errors and breaks the zero-cost passthrough rule for infrastructure code.

As per coding guidelines: "Production implementations of infrastructure traits must be zero-cost passthroughs to the underlying SDK. No error wrapping (use the SDK's error types directly via associated type Error), no return type conversion, no map_err, no map(|_| ())." and "Errors must be typed—use structs or enums, never String or format!()."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rsworkspace/crates/trogon-cron/src/nats_impls.rs` around lines 233 - 258, The
impl for NatsLeaderLock currently converts SDK errors into CronError via
map_err(e.to_string()) in try_acquire, renew and release; change it to be a
zero-cost passthrough by making the impl's associated type Error the underlying
KV/store error type (the concrete error returned by
self.store.create/update/delete, e.g., crate::kv::Error or the store client's
error type) and remove all map_err/string conversions so each method returns the
store's Result directly (call self.store.create(LEADER_KEY, value).await,
self.store.update(LEADER_KEY, value, revision).await, and
self.store.delete(LEADER_KEY).await without mapping errors). Ensure references
to NatsLeaderLock, try_acquire, renew, release, CronError and
crate::kv::LEADER_KEY are used to locate and update the code.
rsworkspace/crates/trogon-cron/src/executor.rs (3)

279-281: ⚠️ Potential issue | 🟠 Major

Dead-letter terminal failures even when retries are disabled.

Both branches only publish to cron.errors when max_retries > 0, so a permanent first-attempt failure with retry: None or max_retries: 0 never reaches the DLQ path. Emit the dead-letter event whenever execution ends in failure.

Suggested fix
-    if max_retries > 0 {
-        publish_dead_letter(&publisher, &tick, "publish", actual_attempts, last_error).await;
-    }
+    publish_dead_letter(&publisher, &tick, "publish", actual_attempts, last_error).await;
-            if max_retries > 0 {
-                publish_dead_letter(&publisher, &tick, "spawn", actual_attempts, last_error).await;
-            }
+            publish_dead_letter(&publisher, &tick, "spawn", actual_attempts, last_error).await;

Also applies to: 442-444

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rsworkspace/crates/trogon-cron/src/executor.rs` around lines 279 - 281, The
code only calls publish_dead_letter(&publisher, &tick, "publish",
actual_attempts, last_error).await when max_retries > 0, so permanent failures
with retry: None or max_retries == 0 never get sent to the DLQ; remove or change
the max_retries guard so publish_dead_letter is invoked whenever execution
finishes in a failure path regardless of max_retries (i.e., call
publish_dead_letter in the failure handling branch that currently handles
terminal errors), and mirror this same fix for the other failure branch that
currently has the same max_retries check (the similar publish_dead_letter call
later in the file).

184-201: ⚠️ Potential issue | 🔴 Critical

Claim is_running atomically before spawning.

load() in execute() and the later store(true) in spawn_process() are separate operations, so two callers can both observe false and both spawn when concurrent == false. Use compare_exchange(false, true, ...) in execute() and remove the unconditional store in spawn_process().

Suggested fix
-            let is_run = state.is_running.load(Ordering::SeqCst);
-            if !command.concurrent() && is_run {
+            if !command.concurrent()
+                && state
+                    .is_running
+                    .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
+                    .is_err()
+            {
                 tracing::debug!(job_id = %state.job.id(), "Skipping tick — previous invocation still running");
                 return;
             }
-    is_running.store(true, Ordering::SeqCst);

Also applies to: 297-303

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rsworkspace/crates/trogon-cron/src/executor.rs` around lines 184 - 201, The
check of state.is_running in RuntimeAction::Spawn is racy because load()
followed by spawn_process()’s store(true) allows two threads to both spawn when
concurrent() == false; change the logic in execute() (where RuntimeAction::Spawn
is handled) to attempt an atomic compare_exchange(false, true, Ordering::SeqCst,
Ordering::SeqCst) on state.is_running and only proceed to call spawn_process if
the exchange succeeds, and remove the unconditional store(true) inside
spawn_process(); ensure spawn_process (or its completion path) still clears
is_running back to false when the job finishes or fails, and apply the same
compare_exchange-based claim to the other similar invocation that currently
mirrors this pattern.

223-278: ⚠️ Potential issue | 🟠 Major

Keep retry failures typed until the DLQ/log boundary.

last_error = e.to_string(), Err(e.to_string()), and the format!(...)-based retry failures flatten everything into String long before the retry loop finishes. That drops source context and makes the DLQ/log path lossy. Please keep an internal typed failure enum here and stringify only when you finally serialize DeadLetterPayload.

Based on learnings: "Errors must be typed—use structs or enums, never String or format!()." and "Never discard error context by converting a typed error into a string; wrap the source error as a field or variant instead."

Also applies to: 305-440

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rsworkspace/crates/trogon-cron/src/executor.rs` around lines 223 - 278, The
retry loop currently collapses all errors to String early (last_error =
e.to_string()), losing source/context; introduce a small internal typed error
enum/struct (e.g., enum RetryError { Publish(trogon_nats::Error), Timeout,
BackoffExceeded, ... }) and change last_error from String to Option<RetryError>,
store Err variants as last_error = Some(RetryError::Publish(e)) inside the loop
(refer to publisher.publish_tick and the for attempt loop), keep all context
until the DLQ/log boundary, and only stringify or serialize the error when
constructing DeadLetterPayload or when emitting the final
tracing::error/tracing::warn (use %last_error.to_string() or implement Display
on RetryError at that boundary). Ensure other retry-related logic
(retry_backoff_with_jitter, max_retry_duration checks, and places around lines
305-440) uses the typed error similarly.
🧹 Nitpick comments (2)
devops/docker/compose/compose.yml (1)

85-87: Add optional NATS auth env pass-through for secured deployments.

This service currently injects only NATS_URL; when NATS auth is enabled, trogon-cron supports env-based auth but won’t receive credentials from Compose.

Suggested patch
   trogon-cron:
     build:
       context: ../../../rsworkspace
       dockerfile: ../devops/docker/compose/services/trogon-cron/Dockerfile
     environment:
       NATS_URL: "nats://nats:4222"
+      NATS_CREDS: "${NATS_CREDS:-}"
+      NATS_NKEY: "${NATS_NKEY:-}"
+      NATS_USER: "${NATS_USER:-}"
+      NATS_PASSWORD: "${NATS_PASSWORD:-}"
+      NATS_TOKEN: "${NATS_TOKEN:-}"
       RUST_LOG: "${RUST_LOG:-info}"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@devops/docker/compose/compose.yml` around lines 85 - 87, Add optional NATS
auth env passthrough to the trogon-cron service by extending its environment
block (the same block that currently contains NATS_URL and RUST_LOG) to include
optional variables such as NATS_USER, NATS_PASS and NATS_TOKEN (or other auth
vars your deployment uses) and map them to host environment values (e.g.,
${NATS_USER:-} style) so credentials are forwarded only when provided; update
the environment section alongside NATS_URL and RUST_LOG to include these
optional keys.
rsworkspace/crates/trogon-cron/src/executor.rs (1)

61-65: Use saturating multiplication to guard against u64 overflow in the backoff calculation.

When base_sec is very large, the multiplication base_sec * multiplier can theoretically overflow (panic in debug builds, wraparound in release). While practical configurations use small values (1–40 seconds, all well below the u64::MAX / 32 threshold), using saturating_mul provides defensive correctness without cost.

Suggested fix
-    Duration::from_secs(base_sec * multiplier)
+    Duration::from_secs(base_sec.saturating_mul(multiplier))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rsworkspace/crates/trogon-cron/src/executor.rs` around lines 61 - 65, The
backoff multiplier computation in retry_backoff can overflow when multiplying a
large base_sec by the multiplier; change the multiplication to a saturating
multiplication (use base_sec.saturating_mul(multiplier)) before creating the
Duration so it never overflows. Update the retry_backoff function to compute
multiplier as currently done and pass base_sec.saturating_mul(multiplier) into
Duration::from_secs to defensively guard against u64 overflow.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@rsworkspace/crates/trogon-cron/src/domain.rs`:
- Around line 293-300: PublishSubject::new currently only checks the "cron."
prefix and allows invalid NATS subjects (wildcards, whitespace, empty tokens);
update the constructor to validate the full subject using the existing
trogon-nats token validation (e.g., trogon_nats::token::validate or the SDK
subject wrapper) and return CronError::InvalidJobConfig with a clear reason on
failure; specifically, inside PublishSubject::new replace the simple starts_with
check with a call to the trogon-nats validation function (or construct the
trogon-nats Subject type) for the entire subject string and propagate validation
errors into the same InvalidJobConfig variant so invalid subjects cannot be
constructed.

In `@rsworkspace/crates/trogon-cron/src/error.rs`:
- Around line 2-9: Replace the String payloads in the CronError enum
(Kv(String), Publish(String), InvalidJobConfig { reason: String }) with typed
error wrappers so callers can preserve source chains: define concrete error
types (e.g., KvError, PublishError, JobConfigError) or use wrappers carrying the
original error (e.g., Kv(Box<dyn std::error::Error + Send + Sync>),
Publish(Box<dyn std::error::Error + Send + Sync>), InvalidJobConfig { source:
Box<...> }), update the CronError declaration to use those types, and adjust any
Display/impl std::error::Error for CronError to produce human-readable messages
while returning the original sources from source(). Ensure code that currently
calls to_string() on sources is updated to pass the original error into the new
Kv/Publish/InvalidJobConfig variants.

---

Duplicate comments:
In `@rsworkspace/crates/trogon-cron/src/executor.rs`:
- Around line 279-281: The code only calls publish_dead_letter(&publisher,
&tick, "publish", actual_attempts, last_error).await when max_retries > 0, so
permanent failures with retry: None or max_retries == 0 never get sent to the
DLQ; remove or change the max_retries guard so publish_dead_letter is invoked
whenever execution finishes in a failure path regardless of max_retries (i.e.,
call publish_dead_letter in the failure handling branch that currently handles
terminal errors), and mirror this same fix for the other failure branch that
currently has the same max_retries check (the similar publish_dead_letter call
later in the file).
- Around line 184-201: The check of state.is_running in RuntimeAction::Spawn is
racy because load() followed by spawn_process()’s store(true) allows two threads
to both spawn when concurrent() == false; change the logic in execute() (where
RuntimeAction::Spawn is handled) to attempt an atomic compare_exchange(false,
true, Ordering::SeqCst, Ordering::SeqCst) on state.is_running and only proceed
to call spawn_process if the exchange succeeds, and remove the unconditional
store(true) inside spawn_process(); ensure spawn_process (or its completion
path) still clears is_running back to false when the job finishes or fails, and
apply the same compare_exchange-based claim to the other similar invocation that
currently mirrors this pattern.
- Around line 223-278: The retry loop currently collapses all errors to String
early (last_error = e.to_string()), losing source/context; introduce a small
internal typed error enum/struct (e.g., enum RetryError {
Publish(trogon_nats::Error), Timeout, BackoffExceeded, ... }) and change
last_error from String to Option<RetryError>, store Err variants as last_error =
Some(RetryError::Publish(e)) inside the loop (refer to publisher.publish_tick
and the for attempt loop), keep all context until the DLQ/log boundary, and only
stringify or serialize the error when constructing DeadLetterPayload or when
emitting the final tracing::error/tracing::warn (use %last_error.to_string() or
implement Display on RetryError at that boundary). Ensure other retry-related
logic (retry_backoff_with_jitter, max_retry_duration checks, and places around
lines 305-440) uses the typed error similarly.

In `@rsworkspace/crates/trogon-cron/src/nats_impls.rs`:
- Around line 36-120: The current NatsConfigStore implementation for ConfigStore
(methods put_job, get_job, delete_job, list_jobs, load_and_watch) is converting
async-nats/jetstream SDK errors to strings via map_err(|e|
CronError::Kv(e.to_string())), losing typed error context; fix by making these
methods passthrough zero-cost: either change CronError::Kv to hold the original
SDK error type (wrap the error instead of to_string) or update the ConfigStore
signature to use an associated Error type and return the SDK errors directly
(remove map_err and the e.to_string() conversions) so all KV calls
(get_key_value, entry, delete, keys, get, etc.) propagate the concrete SDK
errors via ?.
- Around line 194-215: The publish_tick implementation on jetstream::Context
currently collapses PublishOutcome variants into CronError::Publish(String);
change CronError to preserve source error types (e.g., add variants
CronError::PublishFailed(PublishError), CronError::AckFailed(AckError),
CronError::AckTimedOut(Duration) and CronError::StoreFailed(StoreError) or a
single CronError::Publish{source: PublishOutcomeError} that wraps the original
error), then update publish_tick to map each PublishOutcome variant to the
corresponding typed CronError (use .map_err(|e| CronError::PublishFailed(e)) or
construct the appropriate variant for AckTimedOut with the timeout), ensuring
you propagate the original error values instead of converting them to strings;
touch the publish_tick function, the PublishOutcome match arms, and the
CronError enum to implement this typed error propagation.
- Around line 233-258: The impl for NatsLeaderLock currently converts SDK errors
into CronError via map_err(e.to_string()) in try_acquire, renew and release;
change it to be a zero-cost passthrough by making the impl's associated type
Error the underlying KV/store error type (the concrete error returned by
self.store.create/update/delete, e.g., crate::kv::Error or the store client's
error type) and remove all map_err/string conversions so each method returns the
store's Result directly (call self.store.create(LEADER_KEY, value).await,
self.store.update(LEADER_KEY, value, revision).await, and
self.store.delete(LEADER_KEY).await without mapping errors). Ensure references
to NatsLeaderLock, try_acquire, renew, release, CronError and
crate::kv::LEADER_KEY are used to locate and update the code.

---

Nitpick comments:
In `@devops/docker/compose/compose.yml`:
- Around line 85-87: Add optional NATS auth env passthrough to the trogon-cron
service by extending its environment block (the same block that currently
contains NATS_URL and RUST_LOG) to include optional variables such as NATS_USER,
NATS_PASS and NATS_TOKEN (or other auth vars your deployment uses) and map them
to host environment values (e.g., ${NATS_USER:-} style) so credentials are
forwarded only when provided; update the environment section alongside NATS_URL
and RUST_LOG to include these optional keys.

In `@rsworkspace/crates/trogon-cron/src/executor.rs`:
- Around line 61-65: The backoff multiplier computation in retry_backoff can
overflow when multiplying a large base_sec by the multiplier; change the
multiplication to a saturating multiplication (use
base_sec.saturating_mul(multiplier)) before creating the Duration so it never
overflows. Update the retry_backoff function to compute multiplier as currently
done and pass base_sec.saturating_mul(multiplier) into Duration::from_secs to
defensively guard against u64 overflow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0b160d0f-18b6-4b16-99ce-1ce2a1741a29

📥 Commits

Reviewing files that changed from the base of the PR and between 8652b66 and df02750.

⛔ Files ignored due to path filters (1)
  • rsworkspace/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (18)
  • devops/docker/compose/compose.yml
  • devops/docker/compose/services/trogon-cron/Dockerfile
  • rsworkspace/crates/trogon-cron/Cargo.toml
  • rsworkspace/crates/trogon-cron/src/client.rs
  • rsworkspace/crates/trogon-cron/src/config.rs
  • rsworkspace/crates/trogon-cron/src/domain.rs
  • rsworkspace/crates/trogon-cron/src/error.rs
  • rsworkspace/crates/trogon-cron/src/executor.rs
  • rsworkspace/crates/trogon-cron/src/kv.rs
  • rsworkspace/crates/trogon-cron/src/leader.rs
  • rsworkspace/crates/trogon-cron/src/lib.rs
  • rsworkspace/crates/trogon-cron/src/main.rs
  • rsworkspace/crates/trogon-cron/src/mocks.rs
  • rsworkspace/crates/trogon-cron/src/nats_impls.rs
  • rsworkspace/crates/trogon-cron/src/scheduler.rs
  • rsworkspace/crates/trogon-cron/src/traits.rs
  • rsworkspace/crates/trogon-cron/tests/cron_unit.rs
  • rsworkspace/crates/trogon-cron/tests/integration.rs
✅ Files skipped from review due to trivial changes (3)
  • rsworkspace/crates/trogon-cron/tests/cron_unit.rs
  • rsworkspace/crates/trogon-cron/Cargo.toml
  • rsworkspace/crates/trogon-cron/src/main.rs
🚧 Files skipped from review as they are similar to previous changes (7)
  • rsworkspace/crates/trogon-cron/src/leader.rs
  • rsworkspace/crates/trogon-cron/src/client.rs
  • rsworkspace/crates/trogon-cron/src/mocks.rs
  • rsworkspace/crates/trogon-cron/src/kv.rs
  • rsworkspace/crates/trogon-cron/src/lib.rs
  • rsworkspace/crates/trogon-cron/src/traits.rs
  • rsworkspace/crates/trogon-cron/src/scheduler.rs

Comment thread rsworkspace/crates/trogon-cron/src/domain.rs Outdated
Comment thread rsworkspace/crates/trogon-cron/src/error.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (7)
rsworkspace/crates/trogon-cron/src/error.rs (1)

2-33: ⚠️ Potential issue | 🟠 Major

Keep CronError variants typed instead of storing strings.

Kv(String), Publish(String), and the string reason payloads force callers to flatten failures before they reach this enum, which is why most variants cannot expose a source() chain. Please wrap concrete source errors or typed validation errors here and keep the human-readable rendering in Display.

Based on learnings: "Errors must be typed—use structs or enums, never String or format!()." and "Never discard error context by converting a typed error into a string; wrap the source error as a field or variant instead."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rsworkspace/crates/trogon-cron/src/error.rs` around lines 2 - 33, CronError
currently stores String payloads (Kv(String), Publish(String), InvalidJobConfig
{ reason: String }) which discards typed error context; change these variants to
hold concrete error types or boxed error trait objects (e.g., Kv(Box<dyn
std::error::Error + Send + Sync>), Publish(Box<dyn std::error::Error + Send +
Sync>), and InvalidJobConfig(ValidationError) where ValidationError is a new
struct/enum describing validation failure), update the Display impl to format
human-readable messages from those types, and extend std::error::Error::source()
to return the wrapped error for Kv, Publish and InvalidJobConfig so callers can
access the original error chain (leave Serde and Io handling as-is).
rsworkspace/crates/trogon-cron/src/nats_impls.rs (3)

233-258: ⚠️ Potential issue | 🟠 Major

Leader-lock operations are also discarding the KV source errors.

create, update, and delete all end in CronError::Kv(e.to_string()), so CAS mismatches and backend failures lose their concrete types and source() chain. Please preserve the underlying KV errors here too.

Based on learnings: "Never discard error context by converting a typed error into a string; wrap the source error as a field or variant instead." and "Errors must be typed—use structs or enums, never String or format!()."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rsworkspace/crates/trogon-cron/src/nats_impls.rs` around lines 233 - 258, The
leader-lock methods (NatsLeaderLock::try_acquire, ::renew, ::release) currently
map KV errors to CronError::Kv(e.to_string()), discarding the original typed
error and its source chain; change the CronError::Kv variant to hold the
original error type (or a boxed dyn Error) and map errors directly (e.g.,
.map_err(|e| CronError::Kv(e)) or .map_err(|e| CronError::Kv { source: e })
instead of e.to_string()), so the errors returned from
self.store.create/update/delete preserve their concrete type and source.

194-215: ⚠️ Potential issue | 🟠 Major

Preserve PublishOutcome sources in TickPublisher::Error.

PublishFailed, AckFailed, AckTimedOut, and StoreFailed are all collapsed into CronError::Publish(String), so downstream retry and DLQ handling cannot inspect or chain the real JetStream failure. This needs the same typed passthrough/wrapping treatment as the KV paths.

Based on learnings: "Never discard error context by converting a typed error into a string; wrap the source error as a field or variant instead." and "Errors must be typed—use structs or enums, never String or format!()."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rsworkspace/crates/trogon-cron/src/nats_impls.rs` around lines 194 - 215, The
TickPublisher impl for jetstream::Context currently collapses PublishOutcome
variants into CronError::Publish(String); instead preserve and wrap the original
typed errors instead of converting to strings—update CronError to include
variants/fields for PublishFailed, AckFailed, AckTimedOut (with duration), and
StoreFailed (or a single Publish variant that contains the original
PublishOutcome::... error type), then change the matching in publish_tick (which
calls publish_event and uses JetStreamContextPublisher and PublishOutcome) to
return Err(CronError::PublishFailed(e)) / Err(CronError::AckFailed(e)) /
Err(CronError::AckTimedOut(timeout)) / Err(CronError::StoreFailed(e)) (or the
equivalent wrapper) so the original error types are preserved and chainable
instead of using to_string()/format!.

36-157: ⚠️ Potential issue | 🟠 Major

ConfigStore should pass through SDK errors instead of stringifying them.

Every map_err(|e| CronError::Kv(e.to_string())) here loses the original JetStream/KV error type and breaks the zero-cost passthrough rule for production infrastructure traits. Either let the trait surface the SDK errors directly, or make CronError wrap the concrete sources so these calls can use ? without flattening.

As per coding guidelines, "Production implementations of infrastructure traits must be zero-cost passthroughs to the underlying SDK. No error wrapping (use the SDK's error types directly via associated type Error), no return type conversion (add associated types like type Info to match the SDK's return), no map_err, no map(|_| ())." and "Never discard error context by converting a typed error into a string; wrap the source error as a field or variant instead."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rsworkspace/crates/trogon-cron/src/nats_impls.rs` around lines 36 - 157, The
code currently stringifies SDK errors via map_err(|e|
CronError::Kv(e.to_string())) in NatsConfigStore (see put_job, get_job,
delete_job, list_jobs, load_and_watch), which loses error types; change
CronError::Kv to hold the concrete SDK error type (e.g., kv::Error or the
appropriate JetStream error) or add a new variant wrapping Box<dyn
std::error::Error + Send + Sync>, implement From<kv::Error> for CronError, then
remove the map_err closures and use the ? operator directly (e.g., let kv =
self.js.get_key_value(CONFIG_BUCKET).await?; and similarly for
kv.put(...).await? etc.), so SDK errors are propagated without being
stringified. Ensure any places that previously used CronError::Kv(String) are
updated to handle the new variant.
rsworkspace/crates/trogon-cron/src/executor.rs (3)

223-281: ⚠️ Potential issue | 🟠 Major

Don't erase retry failures to String before the terminal path.

last_error = e.to_string(), Err(format!(...)), and the String-based spawn/publish retry loops drop the original failure type long before logging or DLQ publication. Keep an internal typed error through the retry path and stringify only at the final log / dead-letter boundary.

Based on learnings: "Errors must be typed—use structs or enums, never String or format!()." and "Never discard error context by converting a typed error into a string; wrap the source error as a field or variant instead."

Also applies to: 306-444

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rsworkspace/crates/trogon-cron/src/executor.rs` around lines 223 - 281, The
retry loop currently converts errors to String early (last_error =
e.to_string()), losing typed error context; change last_error to hold the
original typed error (e.g., Option<anyhow::Error> or Option<Box<dyn
std::error::Error + Send + Sync>>) and assign Some(e.into()) when
publisher.publish_tick(subject.clone(), headers, payload.clone().into()).await
returns Err(e), keep using that typed error for all logic, and only call
.to_string() (or format!) when emitting the final tracing::error and when
calling publish_dead_letter(&publisher, &tick, "publish", actual_attempts,
last_error_string). Ensure references to publisher.publish_tick, last_error,
actual_attempts, and publish_dead_letter are updated so the retry warn logs use
the typed error (or its display) without dropping error type until the terminal
path.

279-281: ⚠️ Potential issue | 🟠 Major

Emit DLQ events for terminal failures even when retries are disabled.

Right now a first-attempt permanent failure disappears from cron.errors whenever retry: None or max_retries == 0. If dead-lettering is part of the scheduler contract, the DLQ publish should happen whenever execution ends in failure, not only when retries were configured.

Also applies to: 442-444

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rsworkspace/crates/trogon-cron/src/executor.rs` around lines 279 - 281, The
DLQ publish is gated by max_retries > 0 so terminal failures when retries are
disabled never get emitted; remove that condition and always call
publish_dead_letter(...) when execution ends in failure. Locate the two guarded
calls (the one using publish_dead_letter(&publisher, &tick, "publish",
actual_attempts, last_error).await and the duplicate at the later block) and
change the control flow so that whenever a terminal failure path is taken (use
the existing failure context variables: publisher, tick, actual_attempts,
last_error) you await publish_dead_letter(...) unconditionally instead of only
when max_retries > 0.

184-201: ⚠️ Potential issue | 🟠 Major

Claim is_running atomically for concurrent: false jobs.

Line 185 does a load(), and Line 300 later does a separate store(true). Two ticks can both observe false before either store happens, so the non-concurrent guard can still launch duplicate processes. This needs a single atomic claim, e.g. compare_exchange(false, true, ...), before spawn_process() is called.

🔧 Suggested change
-            let is_run = state.is_running.load(Ordering::SeqCst);
-            if !command.concurrent() && is_run {
+            if !command.concurrent()
+                && state
+                    .is_running
+                    .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
+                    .is_err()
+            {
                 tracing::debug!(job_id = %state.job.id(), "Skipping tick — previous invocation still running");
                 return;
             }
@@
-    is_running.store(true, Ordering::SeqCst);
-
     tokio::spawn(async move {
         let _reset = AtomicFlagReset::new(is_running);

Also applies to: 297-300

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rsworkspace/crates/trogon-cron/src/executor.rs` around lines 184 - 201, The
non-concurrent guard currently does a separate load() then later store(true),
which allows a race where two ticks both see false and both spawn; change the
logic in the RuntimeAction::Spawn branch to atomically claim state.is_running
using compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) when
!command.concurrent(), and only call spawn_process() if the compare_exchange
succeeds (otherwise log/debug skip). Also remove or adapt the separate
state.is_running.store(true) in the spawn_process / completion path so that only
the atomic claim sets the flag (and ensure the flag is cleared on process end as
before).
🧹 Nitpick comments (2)
rsworkspace/crates/trogon-cron/src/config.rs (1)

7-31: Make invalid config states unrepresentable in the public API.

These exported config types still model validated concepts as raw primitives (expr, subject, bin, id, interval_sec, timeout_sec), so malformed jobs deserialize successfully and only fail later in aggregate conversion. Prefer per-field value objects/newtypes with serde support here, so invalid cron expressions, subjects, paths, and positive-second values are rejected at construction time instead of after JobConfig already exists.

As per coding guidelines, "Prefer domain-specific value objects over primitives (e.g., AcpPrefix not String). Each type's factory must guarantee correctness at construction—invalid instances should be unrepresentable." and "Validate per-type, not per-aggregate: avoid validating unrelated fields together in a single constructor."

Also applies to: 79-90

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rsworkspace/crates/trogon-cron/src/config.rs` around lines 7 - 31, Replace
raw primitive fields (expr, subject, bin, interval_sec, timeout_sec) in the
public config enums/structs (Schedule, Action, and JobConfig) with
domain-specific value objects/newtypes that perform validation at construction
and implement Serde (e.g., CronExpr, NatsSubject, ExecutablePath,
PositiveSeconds); update Schedule::Interval to use PositiveSeconds.interval_sec
and Schedule::Cron to use CronExpr.expr, and Action::Publish/Spawn to use
NatsSubject.subject and ExecutablePath.bin, plus PositiveSeconds.timeout_sec;
ensure each newtype exposes a fallible constructor that enforces invariants and
Serde deserializes by invoking that constructor so malformed configs fail at
deserialization rather than later during JobConfig conversion.
rsworkspace/crates/trogon-cron/src/main.rs (1)

136-150: Consider using a typed error instead of format!()-based io::Error.

The crate already defines CronError variants. Using a typed error (e.g., CronError::NotFound { id }) instead of constructing an io::Error with format!() would align better with the crate's error handling patterns.

♻️ Suggested approach
 async fn cmd_get(client: CronClient, id: &str) -> Result<(), Box<dyn std::error::Error>> {
     match client.get_job(id).await? {
         Some(job) => println!("{}", serde_json::to_string_pretty(&job).unwrap()),
         None => {
-            return Err(std::io::Error::new(
-                std::io::ErrorKind::NotFound,
-                format!("job '{id}' not found"),
-            )
-            .into());
+            return Err(trogon_cron::CronError::NotFound { id: id.to_string() }.into());
         }
     }
 
     Ok(())
 }

This requires adding a NotFound variant to CronError if it doesn't already exist.

As per coding guidelines: "Errors must be typed—use structs or enums, never String or format!()."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rsworkspace/crates/trogon-cron/src/main.rs` around lines 136 - 150, Replace
the ad-hoc io::Error in cmd_get with the crate's typed CronError: update/ensure
CronError has a NotFound { id: String } (or similar) variant, then return
Err(CronError::NotFound { id: id.to_string() }.into()) when
client.get_job(id).await? yields None; modify the cmd_get function to construct
and return that CronError instead of using format!() so error handling is
consistent with the rest of the crate.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@rsworkspace/crates/trogon-cron/src/main.rs`:
- Around line 159-168: The current map_err closures for
std::fs::read_to_string(file) and serde_json::from_str(&json) throw away the
original error by formatting it to a string; instead, add a CLI-specific error
type (e.g., an enum like CliError with variants ReadFile { path: String, source:
std::io::Error } and InvalidJobConfig { source: serde_json::Error }) or switch
the function to return a boxed error (Box<dyn std::error::Error>) / use
anyhow/thiserror, and change the map_err calls to wrap the original source error
into those variants (preserving the source) rather than calling format!;
reference the read_to_string(file) map_err closure and the
serde_json::from_str(&json) -> JobConfig mapping to locate and update the
conversions.

---

Duplicate comments:
In `@rsworkspace/crates/trogon-cron/src/error.rs`:
- Around line 2-33: CronError currently stores String payloads (Kv(String),
Publish(String), InvalidJobConfig { reason: String }) which discards typed error
context; change these variants to hold concrete error types or boxed error trait
objects (e.g., Kv(Box<dyn std::error::Error + Send + Sync>), Publish(Box<dyn
std::error::Error + Send + Sync>), and InvalidJobConfig(ValidationError) where
ValidationError is a new struct/enum describing validation failure), update the
Display impl to format human-readable messages from those types, and extend
std::error::Error::source() to return the wrapped error for Kv, Publish and
InvalidJobConfig so callers can access the original error chain (leave Serde and
Io handling as-is).

In `@rsworkspace/crates/trogon-cron/src/executor.rs`:
- Around line 223-281: The retry loop currently converts errors to String early
(last_error = e.to_string()), losing typed error context; change last_error to
hold the original typed error (e.g., Option<anyhow::Error> or Option<Box<dyn
std::error::Error + Send + Sync>>) and assign Some(e.into()) when
publisher.publish_tick(subject.clone(), headers, payload.clone().into()).await
returns Err(e), keep using that typed error for all logic, and only call
.to_string() (or format!) when emitting the final tracing::error and when
calling publish_dead_letter(&publisher, &tick, "publish", actual_attempts,
last_error_string). Ensure references to publisher.publish_tick, last_error,
actual_attempts, and publish_dead_letter are updated so the retry warn logs use
the typed error (or its display) without dropping error type until the terminal
path.
- Around line 279-281: The DLQ publish is gated by max_retries > 0 so terminal
failures when retries are disabled never get emitted; remove that condition and
always call publish_dead_letter(...) when execution ends in failure. Locate the
two guarded calls (the one using publish_dead_letter(&publisher, &tick,
"publish", actual_attempts, last_error).await and the duplicate at the later
block) and change the control flow so that whenever a terminal failure path is
taken (use the existing failure context variables: publisher, tick,
actual_attempts, last_error) you await publish_dead_letter(...) unconditionally
instead of only when max_retries > 0.
- Around line 184-201: The non-concurrent guard currently does a separate load()
then later store(true), which allows a race where two ticks both see false and
both spawn; change the logic in the RuntimeAction::Spawn branch to atomically
claim state.is_running using compare_exchange(false, true, Ordering::SeqCst,
Ordering::SeqCst) when !command.concurrent(), and only call spawn_process() if
the compare_exchange succeeds (otherwise log/debug skip). Also remove or adapt
the separate state.is_running.store(true) in the spawn_process / completion path
so that only the atomic claim sets the flag (and ensure the flag is cleared on
process end as before).

In `@rsworkspace/crates/trogon-cron/src/nats_impls.rs`:
- Around line 233-258: The leader-lock methods (NatsLeaderLock::try_acquire,
::renew, ::release) currently map KV errors to CronError::Kv(e.to_string()),
discarding the original typed error and its source chain; change the
CronError::Kv variant to hold the original error type (or a boxed dyn Error) and
map errors directly (e.g., .map_err(|e| CronError::Kv(e)) or .map_err(|e|
CronError::Kv { source: e }) instead of e.to_string()), so the errors returned
from self.store.create/update/delete preserve their concrete type and source.
- Around line 194-215: The TickPublisher impl for jetstream::Context currently
collapses PublishOutcome variants into CronError::Publish(String); instead
preserve and wrap the original typed errors instead of converting to
strings—update CronError to include variants/fields for PublishFailed,
AckFailed, AckTimedOut (with duration), and StoreFailed (or a single Publish
variant that contains the original PublishOutcome::... error type), then change
the matching in publish_tick (which calls publish_event and uses
JetStreamContextPublisher and PublishOutcome) to return
Err(CronError::PublishFailed(e)) / Err(CronError::AckFailed(e)) /
Err(CronError::AckTimedOut(timeout)) / Err(CronError::StoreFailed(e)) (or the
equivalent wrapper) so the original error types are preserved and chainable
instead of using to_string()/format!.
- Around line 36-157: The code currently stringifies SDK errors via map_err(|e|
CronError::Kv(e.to_string())) in NatsConfigStore (see put_job, get_job,
delete_job, list_jobs, load_and_watch), which loses error types; change
CronError::Kv to hold the concrete SDK error type (e.g., kv::Error or the
appropriate JetStream error) or add a new variant wrapping Box<dyn
std::error::Error + Send + Sync>, implement From<kv::Error> for CronError, then
remove the map_err closures and use the ? operator directly (e.g., let kv =
self.js.get_key_value(CONFIG_BUCKET).await?; and similarly for
kv.put(...).await? etc.), so SDK errors are propagated without being
stringified. Ensure any places that previously used CronError::Kv(String) are
updated to handle the new variant.

---

Nitpick comments:
In `@rsworkspace/crates/trogon-cron/src/config.rs`:
- Around line 7-31: Replace raw primitive fields (expr, subject, bin,
interval_sec, timeout_sec) in the public config enums/structs (Schedule, Action,
and JobConfig) with domain-specific value objects/newtypes that perform
validation at construction and implement Serde (e.g., CronExpr, NatsSubject,
ExecutablePath, PositiveSeconds); update Schedule::Interval to use
PositiveSeconds.interval_sec and Schedule::Cron to use CronExpr.expr, and
Action::Publish/Spawn to use NatsSubject.subject and ExecutablePath.bin, plus
PositiveSeconds.timeout_sec; ensure each newtype exposes a fallible constructor
that enforces invariants and Serde deserializes by invoking that constructor so
malformed configs fail at deserialization rather than later during JobConfig
conversion.

In `@rsworkspace/crates/trogon-cron/src/main.rs`:
- Around line 136-150: Replace the ad-hoc io::Error in cmd_get with the crate's
typed CronError: update/ensure CronError has a NotFound { id: String } (or
similar) variant, then return Err(CronError::NotFound { id: id.to_string()
}.into()) when client.get_job(id).await? yields None; modify the cmd_get
function to construct and return that CronError instead of using format!() so
error handling is consistent with the rest of the crate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 855f74ed-89ed-4cbb-b212-99cf8e932726

📥 Commits

Reviewing files that changed from the base of the PR and between df02750 and d6d34ed.

⛔ Files ignored due to path filters (1)
  • rsworkspace/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (20)
  • devops/docker/compose/compose.yml
  • devops/docker/compose/services/trogon-cron/Dockerfile
  • rsworkspace/crates/acp-telemetry/src/service_name.rs
  • rsworkspace/crates/trogon-cron/Cargo.toml
  • rsworkspace/crates/trogon-cron/src/cli.rs
  • rsworkspace/crates/trogon-cron/src/client.rs
  • rsworkspace/crates/trogon-cron/src/config.rs
  • rsworkspace/crates/trogon-cron/src/domain.rs
  • rsworkspace/crates/trogon-cron/src/error.rs
  • rsworkspace/crates/trogon-cron/src/executor.rs
  • rsworkspace/crates/trogon-cron/src/kv.rs
  • rsworkspace/crates/trogon-cron/src/leader.rs
  • rsworkspace/crates/trogon-cron/src/lib.rs
  • rsworkspace/crates/trogon-cron/src/main.rs
  • rsworkspace/crates/trogon-cron/src/mocks.rs
  • rsworkspace/crates/trogon-cron/src/nats_impls.rs
  • rsworkspace/crates/trogon-cron/src/scheduler.rs
  • rsworkspace/crates/trogon-cron/src/traits.rs
  • rsworkspace/crates/trogon-cron/tests/cron_unit.rs
  • rsworkspace/crates/trogon-cron/tests/integration.rs
✅ Files skipped from review due to trivial changes (3)
  • devops/docker/compose/services/trogon-cron/Dockerfile
  • rsworkspace/crates/trogon-cron/Cargo.toml
  • rsworkspace/crates/trogon-cron/src/leader.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • rsworkspace/crates/trogon-cron/tests/cron_unit.rs
  • rsworkspace/crates/trogon-cron/src/client.rs
  • rsworkspace/crates/trogon-cron/src/mocks.rs
  • rsworkspace/crates/trogon-cron/src/traits.rs

Comment thread rsworkspace/crates/trogon-cron/src/kv.rs Outdated
Comment thread rsworkspace/crates/trogon-cron/src/main.rs Outdated
Comment thread rsworkspace/crates/trogon-cron/src/executor.rs Outdated
Comment thread rsworkspace/crates/trogon-cron/src/main.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (3)
rsworkspace/crates/trogon-cron/src/domain.rs (1)

296-304: ⚠️ Potential issue | 🟠 Major

Reject malformed NATS subjects in PublishSubject.

This factory still accepts invalid subjects as long as they start with cron.. Inputs like cron., cron..backup, cron.*, or cron.bad subject become valid domain objects here and only fail later when the scheduler tries to publish. Tighten this value object so construction guarantees a real NATS subject, not just the prefix. As per coding guidelines: "Prefer domain-specific value objects over primitives (e.g., AcpPrefix not String). Each type's factory must guarantee correctness at construction—invalid instances should be unrepresentable."

#!/bin/bash
# Inspect the current PublishSubject factory and the existing NATS token helpers.
sed -n '296,309p' rsworkspace/crates/trogon-cron/src/domain.rs
fd -i 'token.rs' rsworkspace/crates/trogon-nats -x sed -n '1,220p' {}
rg -n 'PublishSubject|validate|subject' rsworkspace/crates/trogon-cron/src rsworkspace/crates/trogon-nats/src
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rsworkspace/crates/trogon-cron/src/domain.rs` around lines 296 - 304, The
PublishSubject::new constructor currently only checks the "cron." prefix;
tighten it to reject empty or malformed NATS subjects by validating the
remainder after "cron.": ensure there's at least one non-empty segment after the
prefix, disallow consecutive dots or trailing dots (reject "cron.",
"cron..backup"), forbid wildcard tokens like '*' or '>' and any whitespace, and
enforce the same token rules used by the NATS helpers in the trogon-nats crate
(see token validation helpers in token.rs) so subject components are valid
identifiers; if validation fails, return
Err(CronError::invalid_job_config(JobConfigError::PublishSubjectPrefix { subject
})) as before.
rsworkspace/crates/trogon-cron/src/nats_impls.rs (1)

35-40: 🛠️ Refactor suggestion | 🟠 Major

Keep the production trait impls as SDK passthroughs.

NatsConfigStore and NatsLeaderLock still do error adaptation inside the trait impls via CronError::kv_source(...)/map_err(...). That preserves context, but it still violates the repo rule that production infrastructure trait bodies should be thin passthroughs to the SDK. Consider moving this translation above the trait boundary or reshaping the traits so the impls can expose the SDK error/return types directly. As per coding guidelines: "Production implementations of infrastructure traits must be zero-cost passthroughs to the underlying SDK. No error wrapping (use the SDK's error types directly via associated type Error), no return type conversion (add associated types like type Info to match the SDK's return), no map_err, no map(|_| ())."

#!/bin/bash
# Inspect the trait signatures and the current production impl bodies.
sed -n '1,240p' rsworkspace/crates/trogon-cron/src/traits.rs
sed -n '1,260p' rsworkspace/crates/trogon-cron/src/nats_impls.rs

Also applies to: 44-99, 101-148, 228-253

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rsworkspace/crates/trogon-cron/src/nats_impls.rs` around lines 35 - 40, The
production impls (e.g., NatsConfigStore::config_bucket and NatsLeaderLock impls)
are wrapping SDK errors with CronError via map_err/CronError::kv_source inside
trait bodies; change the trait signatures to expose the SDK types/errors
directly (add associated types like type Error and type Info to the trait in
traits.rs) and make the impls zero-cost passthroughs that return the SDK's
Result/values without map_err or wrapping; move the CronError translation to the
call sites or adapter layer above the trait boundary so functions like
config_bucket simply call self.js.get_key_value(CONFIG_BUCKET).await and return
the SDK result as-is.
rsworkspace/crates/trogon-cron/src/kv.rs (1)

36-48: ⚠️ Potential issue | 🟠 Major

Don't treat any same-name JetStream resource as compatible.

After an already exists create failure, these helpers only prove the bucket/stream name exists. They never verify that the existing resource still has the requested history, max_age, or subjects. That can silently accept a leader bucket with the wrong TTL or tick/DLQ streams wired to the wrong subject set. Fetch the existing config and fail if the important fields differ.

#!/bin/bash
# Show the desired configs and the current already-exists fallback paths.
sed -n '24,127p' rsworkspace/crates/trogon-cron/src/kv.rs

Also applies to: 56-106, 108-127

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rsworkspace/crates/trogon-cron/src/kv.rs` around lines 36 - 48, The current
get_or_create_leader_bucket helper (and the other helpers using get_or_create
with kv::Config like the blocks at 56-106 and 108-127) only treats an "already
exists" result as success without validating the existing resource; update the
logic so that after a create failure due to already existing you fetch the
existing bucket/stream config from JetStream (via js) and compare the important
fields (for kv::Config: history and max_age; for streams also verify subjects
and retention/replicas/etc. as appropriate) against the requested
kv::Config/stream config used in get_or_create; if any critical field mismatches
(e.g., history != 1, max_age != Duration::from_secs(10), wrong subjects) return
a CronError indicating a config mismatch instead of silently accepting the
existing resource. Ensure this validation is applied in
get_or_create_leader_bucket and the similar helper blocks referenced so you fail
fast when an incompatible same-name resource exists.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@rsworkspace/crates/trogon-cron/src/executor.rs`:
- Around line 63-68: The multiplication in retry_backoff(base_sec, attempt) can
overflow; replace base_sec * multiplier with a saturation or capped value to
avoid panics/wraps — e.g., compute let secs =
base_sec.saturating_mul(multiplier) (or use checked_mul and clamp to a defined
MAX_RETRY_BACKOFF_SECS constant) and then return Duration::from_secs(secs);
update retry_backoff to use base_sec.saturating_mul(multiplier) or checked_mul +
min(secs, MAX) to keep the retry delay well-defined.

In `@rsworkspace/crates/trogon-cron/src/kv.rs`:
- Around line 163-199: The current 500ms deadline can truncate the watcher
initial-snapshot; change the logic in the loop that drains the initial snapshot
(the tokio::select! using deadline, tokio::pin!(deadline), and the _ = &mut
deadline branch) to instead rely on the watcher entries' e.delta == 0 as the
end-of-snapshot marker: keep consuming watcher.next() until you see Some(Ok(e))
with e.delta == 0 (or None/Err handled as now), deserialize JobConfig on
kv::Operation::Put, and only treat an empty bucket specially (e.g., if watcher
yields None immediately) or load a point-in-time snapshot before attaching the
watch; if you must keep a timeout for defense, make it much larger and clearly
document it.

In `@rsworkspace/crates/trogon-cron/src/nats_impls.rs`:
- Around line 79-99: list_jobs currently swallows deserialization failures and
omits corrupt entries; change the logic in list_jobs (and where it calls
config_bucket(), kv.keys(), kv.get(), and serde_json::from_slice::<JobConfig>)
to fail fast or surface the offending key instead of silently skipping: after
obtaining the value from kv.get(&key).await, attempt serde_json::from_slice and
map any Err into a CronError that includes the key (e.g. via
CronError::kv_source or a new CronError variant) so the function returns an
error containing the key and underlying serde error rather than dropping the
entry; ensure any early returns use the existing CronError type so callers see
corrupted configs.

---

Duplicate comments:
In `@rsworkspace/crates/trogon-cron/src/domain.rs`:
- Around line 296-304: The PublishSubject::new constructor currently only checks
the "cron." prefix; tighten it to reject empty or malformed NATS subjects by
validating the remainder after "cron.": ensure there's at least one non-empty
segment after the prefix, disallow consecutive dots or trailing dots (reject
"cron.", "cron..backup"), forbid wildcard tokens like '*' or '>' and any
whitespace, and enforce the same token rules used by the NATS helpers in the
trogon-nats crate (see token validation helpers in token.rs) so subject
components are valid identifiers; if validation fails, return
Err(CronError::invalid_job_config(JobConfigError::PublishSubjectPrefix { subject
})) as before.

In `@rsworkspace/crates/trogon-cron/src/kv.rs`:
- Around line 36-48: The current get_or_create_leader_bucket helper (and the
other helpers using get_or_create with kv::Config like the blocks at 56-106 and
108-127) only treats an "already exists" result as success without validating
the existing resource; update the logic so that after a create failure due to
already existing you fetch the existing bucket/stream config from JetStream (via
js) and compare the important fields (for kv::Config: history and max_age; for
streams also verify subjects and retention/replicas/etc. as appropriate) against
the requested kv::Config/stream config used in get_or_create; if any critical
field mismatches (e.g., history != 1, max_age != Duration::from_secs(10), wrong
subjects) return a CronError indicating a config mismatch instead of silently
accepting the existing resource. Ensure this validation is applied in
get_or_create_leader_bucket and the similar helper blocks referenced so you fail
fast when an incompatible same-name resource exists.

In `@rsworkspace/crates/trogon-cron/src/nats_impls.rs`:
- Around line 35-40: The production impls (e.g., NatsConfigStore::config_bucket
and NatsLeaderLock impls) are wrapping SDK errors with CronError via
map_err/CronError::kv_source inside trait bodies; change the trait signatures to
expose the SDK types/errors directly (add associated types like type Error and
type Info to the trait in traits.rs) and make the impls zero-cost passthroughs
that return the SDK's Result/values without map_err or wrapping; move the
CronError translation to the call sites or adapter layer above the trait
boundary so functions like config_bucket simply call
self.js.get_key_value(CONFIG_BUCKET).await and return the SDK result as-is.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c4dd3c74-8f61-46c5-aef2-1ac766fb5f6b

📥 Commits

Reviewing files that changed from the base of the PR and between 04091e9 and a7d0da9.

📒 Files selected for processing (11)
  • rsworkspace/crates/trogon-cron/src/client.rs
  • rsworkspace/crates/trogon-cron/src/domain.rs
  • rsworkspace/crates/trogon-cron/src/error.rs
  • rsworkspace/crates/trogon-cron/src/executor.rs
  • rsworkspace/crates/trogon-cron/src/kv.rs
  • rsworkspace/crates/trogon-cron/src/lib.rs
  • rsworkspace/crates/trogon-cron/src/main.rs
  • rsworkspace/crates/trogon-cron/src/nats_impls.rs
  • rsworkspace/crates/trogon-cron/src/scheduler.rs
  • rsworkspace/crates/trogon-cron/tests/cron_unit.rs
  • rsworkspace/crates/trogon-cron/tests/integration.rs
✅ Files skipped from review due to trivial changes (2)
  • rsworkspace/crates/trogon-cron/src/lib.rs
  • rsworkspace/crates/trogon-cron/src/client.rs

Comment thread rsworkspace/crates/trogon-cron/src/executor.rs Outdated
Comment thread rsworkspace/crates/trogon-cron/src/kv.rs Outdated
Comment thread rsworkspace/crates/trogon-cron/src/nats_impls.rs Outdated
Comment thread rsworkspace/crates/trogon-cron/src/executor.rs Outdated
Comment thread rsworkspace/crates/trogon-cron/src/domain.rs Outdated
Comment thread rsworkspace/crates/trogon-cron/src/kv.rs Outdated
Comment thread rsworkspace/crates/trogon-cron/src/scheduler.rs Outdated
Comment thread rsworkspace/crates/trogon-scheduler/src/nats.rs Outdated
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ScheduleNotFound { id } => write!(formatter, "schedule '{id}' does not exist"),
Self::ScheduleDeleted { id } => write!(formatter, "schedule '{id}' was deleted"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error Display impls regressed to Debug format

Medium Severity

RemoveScheduleError and ResumeScheduleError had their Display impls replaced with write!(formatter, "{self:?}"), producing raw debug output like ScheduleNotFound { id: ScheduleId("backup") } instead of the previous user-friendly messages (e.g., "schedule 'backup' does not exist"). This is inconsistent with peer error types CreateScheduleError and PauseScheduleError which still implement proper match-based Display formatting.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d881b74. Configure here.


fn pause_job_command(id: &str) -> PauseSchedule {
PauseSchedule::new(ScheduleId::parse(id).unwrap())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate function definition in pause_schedule tests

High Severity

The test module defines pause_job_command twice — once at the original location and again at a new location. This will cause a compilation error. The second definition appears to be a copy-paste artifact from adding create_job_command alongside helper functions from another test module.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit dab428f. Configure here.


fn pause_job_command(id: &str) -> PauseSchedule {
PauseSchedule::new(ScheduleId::parse(id).unwrap())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate function and missing reference in test module

High Severity

The test module defines pause_job_command twice (lines 110 and 131), and create_job_command calls a nonexistent create_schedule function. This appears to be a copy-paste merge artifact — the old helper functions were replaced with job() but stale copies of create_job_command and a second pause_job_command were left behind, preventing the test module from compiling.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a557de5. Configure here.

&self.schedule_publisher,
).await?;
desired_schedules = rebuilt_jobs;
scheduler_watcher = watcher;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initial leader establishment error crashes instead of retrying

Medium Severity

When the controller first becomes leader, establish_scheduler_processor is called with ?, propagating any transient error (e.g., a momentary NATS hiccup) out of run_until, which terminates the entire scheduler process. In contrast, when the watcher later breaks, reestablish_scheduler_processor is used — which retries with backoff and respects the shutdown signal. The initial establishment path lacks this same resilience, so a brief network blip during first leadership acquisition kills the controller instead of retrying.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c053082. Configure here.

}
Some(ScheduleKind::Rrule(inner)) => {
let next = next_rrule_occurrence(inner, now)?;
Ok((format!("@at {}", next.to_rfc3339()), None))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RRULE schedules fire once only

High Severity

RRULE schedules are turned into a single Nats-Schedule @at timestamp for the next occurrence only, while cron and @every expressions stay recurring on NATS. Nothing in the leader controller re-publishes after a fire, so multi-occurrence RRULEs (for example daily rules) stop after the first execution.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 624a450. Configure here.

events,
state,
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No-stream path skips decide state

Medium Severity

For deciders with WritePrecondition::NoStream (including CreateSchedule), command execution now calls decide on initial_state() without reading the event stream. Existing or deleted schedule IDs still look “missing” at decide time, so callers get append/OCC failures instead of AlreadyExists or ScheduleDeleted, and domain rules in decide never run against real stream state.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 75d9733. Configure here.

)
.await?;

Ok(outcome)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Append succeeds before projection

Medium Severity

EventStore::append_stream persists events to JetStream first, then updates the schedules KV projection. If project_appended_events fails partway through a batch, events remain on the stream while the read model is stale or partially updated, and the command returns an error despite a successful append.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 75d9733. Configure here.

yordis added 11 commits June 10, 2026 19:10
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
…-headers metadata

Event payloads no longer carry timestamps or actor IDs; that data
lives in the envelope headers, so the schedule event contracts and
their consumers needed to follow.

Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
Align the command name with the ScheduleCreated event and schedule_created_from_job helper.
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
Align command-domain vocabulary with the read model so aggregates, errors, and helpers consistently use Schedule terminology.
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
Expose processors, projections, queries, and pause/remove/resume deciders;
fix Schedule→ScheduleEventSchedule conversion and align Cargo deps for a
buildable trogon-scheduler binary and tests.
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
yordis added 2 commits June 11, 2026 16:21
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
Drop the scheduler controller, processors module, CLI, runtime config,
and main binary, along with their dependencies on clap and confique.
The crate is now a library only, with controller/leader-election logic
removed pending a different orchestration approach.

Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
pub const LEADER_KEY: &str = "lock";
pub const EVENTS_STREAM: &str = "SCHEDULER_EVENTS";
pub const EVENTS_SUBJECT_PREFIX: &str = "scheduler.schedules.events.";
pub const EVENTS_SUBJECT_PATTERN: &str = "scheduler.schedules.events.>";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Event store worker stream mismatch

High Severity

Persisted schedule commands append to JetStream stream SCHEDULER_EVENTS on subjects like scheduler.schedules.events.{id}, while the execution processor’s durable consumer listens on SCHEDULER_SCHEDULE_EVENTS with filter scheduler.schedules.events.v1.>. Those names do not overlap, so reconciliation never sees command-appended events and enabled schedules are not published to execution.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ae4d6d6. Configure here.

yordis added 2 commits June 11, 2026 19:01
Drop the `ResolvedSchedule`, `SchedulePublisher`, `LeaderLock` traits,
and their associated KV constants and tests. These were carryovers from
an earlier design where the scheduler directly managed NATS schedule
streams and leader election; the current architecture handles delivery
through the events stream alone.

Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
commit_watched_projection_state(&mut state, stream_id, next_state);
ack_watch_message(&message).await;
if let Some(change) = change {
return Some((change_from_projection_change(change), (state, subscriber, kv)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Watcher acks skipped projection errors

Medium Severity

In load_and_watch_schedules, when prepare_watched_projection_change cannot apply an event (decode/validation/state transition failure), it returns None, the loop still calls ack_watch_message, and the in-memory watcher state never advances for that JetStream message. The KV projection and live subscribers can miss updates while the consumer treats the message as processed.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 550f0e0. Configure here.

Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 47 total unresolved issues (including 46 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 8123459. Configure here.

.iter()
.map(|header| (header.name.clone(), header.value.clone())),
),
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Projection skips header validation

Medium Severity

project_message builds read-model headers with MessageHeaders::from_pairs, which does not validate names or values, while MessageHeaders::deserialize uses MessageHeaders::new and rejects invalid pairs. Projected JSON can be written to KV but later get_schedule / list_schedules calls fail deserialization on the same record.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8123459. Configure here.

@yordis yordis changed the title feat: add trogon-cron scheduler feat(scheduler): support durable schedule control plane Jun 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

rust:coverage-baseline-reset Relax Rust coverage gate to establish a new baseline

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants