[BUG] Session crons fire from the idle timer at ~1/5 the interactive rate in a forked background job (silent monitoring outage)
What's Wrong?
In a forked background job (--fork-session --resume <file> under a --bg-pty-host parent), session crons created with CronCreate fire at roughly one fifth the rate they do in an interactive session. The jobs remain in the cron store and CronList lists them normally; what degrades is the idle timer — the path that fires a due job when no session event is occurring.
The practical effect is a silent monitoring outage: the cron store looks healthy, any status surface reads "armed", and nothing fires. In our case a patrol that reports machine health stopped reporting for ~3 hours and the absence of reports was indistinguishable from clean reports.
This is a degradation, not an absence. An earlier read of a small window suggested the fork never fired from the idle path; measuring the full transcripts showed that is too strong.
Measurement
Classification method: parse the session .jsonl, take entries with subtype == "scheduled_task_fire", sort by timestamp, and for each firing walk back to the nearest preceding entry that is not the firing's own machinery (queue-operation rows and other scheduled_task_fire rows). A gap > 0.3s from a non-turn_duration, non-resume predecessor means the idle timer fired it; a ~0.01s gap after turn_duration means it rode a turn boundary.
| Session | Kind | Firings | Idle-driven | % |
|---|---|---|---|---|
| A | interactive | 161 | 123 | 76.4% |
| B | interactive (earlier) | 109 | 95 | 87.2% |
| C | forked background job | 34 | 5 | ~15% |
Same machine, same cron definitions, same patrol config. C's 5 idle firings were read individually rather than trusted from the bucket; three further candidates sat exactly on the 0.3s cutoff immediately after a user event and were excluded as artifacts of the threshold.
A representative clean idle firing from an interactive session, showing the empty window:
00:52:56.851Z system stop_hook_summary
00:52:56.860Z system turn_duration <- turn ends
(5m 46s: no entries of any type)
00:58:43.046Z system scheduled_task_fire <- fires from an empty window
00:58:43.049Z queue-operation
gap = 346.186s
In session C, slot boundaries repeatedly passed while the session was idle with no firing, and the missed firings were then delivered coalesced into one at the next turn boundary — ~11 due slots of a 15-minute cron produced a single firing.
Steps to Reproduce
- Start a session, create a recurring cron with
CronCreate(e.g.8,23,38,53 * * * *). - Leave the session genuinely idle across several slot boundaries.
- Observe firings in the session
.jsonl(subtype == "scheduled_task_fire"), classifying by gap as above. - Repeat in a forked background job:
claude --session-id <new> --fork-session --resume <path>.jsonlunder abg-pty-hostparent. - Compare idle-driven proportions.
Expected vs actual
Expected: a due cron fires from the idle path at a comparable rate regardless of how the session was launched.
Actual: the forked background job fires from the idle path at ~15% vs 76–87% interactive.
Limits of this report, stated explicitly
- Background-ness and fork-ness are not separated. The affected session was both backgrounded and forked. We looked for an existing counter-example on the machine: two other background jobs exist but have no transcripts, and the
dispatch.launch.forkflag lives inroster.jsonrather than per-job state, so it was unavailable for them. Constructing a clean split (a background job that is not a fork, or a fork that is not backgrounded) requires restarting the daemon and an hour of idle time, and interferes with the measurement. A maintainer with the source can split these variables far more cheaply than we can. - One machine, three session lineages, 304 firings total.
- One further observation offered without a theory: in the degraded session, the genuine idle firings that did occur had
away_summaryas the preceding entry type. That may indicate which wake path still functions.
Two measurement traps, in case they help reproduce
- The
.jsonlis not written in timestamp order. Walking by line number produced 0 idle firings in a session that had 123 — a confident, clean, completely wrong result. The tell was an interval computing to −0.669s; a negative duration is the instrument reporting its own breakage. - A firing's record is not flushed until after the turn it triggers, so the firing that wakes a session cannot be classified in the turn it causes — only one turn later. "Go idle then report" is therefore not a workable test design; a later event must supply the flushing turn.
- Idle firings carry poll latency, not slot punctuality: a
:53slot fired at:58:43. Measuring lateness against the schedule will make a healthy timer look dead. Judge by the gap from the preceding entry.