Skip to content

[P2] Warn or notify when a scheduled report is skipped because another report is running #34

Open

Description

@MatthewS65537

Severity: P2 · Confidence: high · Area: Schedules

Where

Problem

While any run is generating, a due occurrence is permanently marked skipped ("Skipped overlap…") — never deferred, even inside the 2h grace window. notifyFailure covers only failed/uncertain/ready, so the owner gets no message, and the confirm view doesn't warn about collisions. README's "One report executes at a time" reads as serialized, not dropped. (The skip itself is intentional per test/schedules.test.ts:337.)

Failure scenario

Two daily jobs at 08:00 ("News", "PR digest"): every day one runs and the other is silently lost; the only trace is "⚠ skipped" in \schedule.

Evidence

Local probe cc_mnt/review-20261011/commands/same-time.test.ts → ['News:running:', 'PR digest:skipped:Skipped overlap...'].

Suggested fix

Prefer deferring (leave the occurrence unclaimed) while within grace so both run in sequence. Minimum: DM on overlap skips and warn in scheduleConfirmView when another enabled job's occurrence falls within this job's timeout window; fix the README wording.

Acceptance

A same-minute pair either runs both in sequence, or the owner is warned at creation and DMed on the skip.


Found in the 2026-10-11 codebase review of commit 22ce7ee (typecheck clean, 1,142/1,142 tests passing). Probe paths refer to the maintainer's local, gitignored review ledger.

Activity

  1. MatthewS65537 commented on Oct 11, 2026

    @MatthewS65537
    OwnerAuthor

    Proposed fix

    Approach (recommend: defer): When another report is generating, leave the due occurrence unclaimed. Write no skip record and don't advance nextRunAt. Each 5s poll re-evaluates it, and the first poll after the active report finishes claims it, still inside the existing 2-hour grace window. This keeps "one report executes at a time" as a real serialization guarantee instead of a silent drop. Both 08:00 reports arrive, the second a few minutes late. I recommend defer over notify-on-skip because notifying would only tell the owner, every day, that a report they configured was thrown away. Defer removes the loss itself, and the change is one line. Report timeouts are capped at 60 minutes and uncertain runs stop blocking at their deadline, so a deferred occurrence normally starts well within grace. Only chains of three or more long same-time reports can run past the window, and they still get the existing "outside the 2-hour grace window" skip.

    Changes:

    src/schedules/scheduler.ts, pollOnce() job loop:

    if (!existing) {
      if (now - latest > SCHEDULE_GRACE_MS) store.skipOccurrence(job.id, latest, "Missed occurrence outside the 2-hour grace window.");
      // Defer: no record and the pointer stays put, so a later poll claims this occurrence
      // once the active report ends (within grace). One report still generates at a time.
      else if (store.runs().some(r => isGenerationActive(r, now))) continue;
      else store.claim(job.id, latest);
    }

    continue skips the nextRunAt advance and step. The earlier skipOccurrence(job.id, job.nextRunAt, …) for older missed occurrences is idempotent (createRun returns the existing record without committing), so it can run on every deferred poll. Claim order among several deferred jobs follows store.jobs() (creation order).

    README.md:172: "One report executes at a time." becomes:

    One report generates at a time; a report that comes due while another is generating starts when that one finishes (within the same two-hour window).

    src/schedules/controls.ts scheduleConfirmView, last section: append " Reports due at the same time run one after another."

    Tests: I prototyped this in cc_mnt/review-20261011/fixes/proto-34/: a copy of scheduler.ts plus defer.test.ts, which passes.

    • test/schedules.test.ts, replace "allows only one global generation, not one per job": two jobs at the same time. After the first poll only the first is running and the second has no run record. After the first is marked delivered and the clock moves 20 minutes, a poll claims the second with scheduledAt equal to the original occurrence. Both jobs' nextRunAt then advance to the next day.
    • Update it.each(["running","waiting","uncertain"])("skips overlapping automatic work while a manual run is …") to assert deferral instead: store.run(automaticRunId(job.id, now)) is undefined and nextRunAt is unchanged. A further case: once now - latest > SCHEDULE_GRACE_MS while still blocked, the occurrence is skipped with the grace error.
    • Those 4 cases are the only existing scheduler/runner tests that change. The other 115 pass against the prototype.

    Risks / interactions:

    • \schedule "next …" keeps showing the due time until the deferred occurrence is claimed. That is accurate, since it is still pending.
    • \schedule run (manual) still refuses while anything is active. Unchanged.
    • A \schedule edit/pause during deferral recomputes nextRunAt and drops the pending occurrence. That is consistent with "changes apply to future occurrences".
    • The deferred run's startedAt/deadlineAt start when it is claimed, so the full timeout is available.
    • Restarts are safe: nothing is persisted for a deferred occurrence, and recovery follows the existing claim rules.

    Alternatives considered:

    • Keep skipping and DM on overlap skips: the owner learns of the loss but still loses the report every day.
    • Allow concurrent generation: breaks the single-generation design (approval routing and shared uncertainty guard).

    Effort: S. Confidence: high


    Proposal only, not implemented. Sketches are against 22ce7ee.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions