instar 1.3.960 → 1.3.962

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,156 @@
1
+ # Side-effects review — scheduler-never-run-window
2
+
3
+ **Change:** the startup missed-job sweep no longer treats every job with no
4
+ `lastRun` as overdue. A new `JobState.firstSeenAt`, stamped once at
5
+ registration, lets it apply the rule its own comment already described: a
6
+ never-run job is missed only if it has existed longer than one of its own
7
+ intervals.
8
+
9
+ **Motivation:** ACT-724 defect (a) — a hand-built future-dated reminder job
10
+ discharged itself on the next boot. This is the prerequisite for the ACT-724
11
+ standard proper (dated commitments materializing one-shot reminders); that
12
+ standard is NOT in this change.
13
+
14
+ ---
15
+
16
+ ## 1. Over-block — what legitimate inputs does this reject that it shouldn't?
17
+
18
+ **One real, deliberate narrowing.** A never-run job that has existed for *less*
19
+ than one interval is no longer triggered at startup. It waits for its next cron
20
+ window instead.
21
+
22
+ That is the intended behaviour — it is the difference between "scheduled" and
23
+ "whenever we happen to reboot" — but it is a genuine behaviour change and the
24
+ cost is honest: on a machine that restarts frequently, a newly-added job may now
25
+ first run at its scheduled time rather than within seconds of being added.
26
+
27
+ **Legacy state is also narrowed.** A job whose state predates `firstSeenAt` and
28
+ has no `lastRun` is treated as not-missed rather than fired. This affects the
29
+ first boot after upgrade only: registration stamps `firstSeenAt` for every job
30
+ that lacks it, so from the second boot the rule applies normally. Chosen
31
+ deliberately — see §4.
32
+
33
+ ## 2. Under-block — what failure modes does this still miss?
34
+
35
+ - **A job added while the server was down still can't be dated precisely.**
36
+ `firstSeenAt` is stamped when the scheduler first *sees* the job, not when its
37
+ file was written. A job created during a three-day outage is stamped at boot
38
+ and so waits up to one interval before catching up. Using file mtime would be
39
+ more precise and less trustworthy (mtime changes on unrelated edits, syncs,
40
+ and checkouts). Named rather than silently accepted.
41
+ - **Interval is inferred from the next two cron occurrences.** For irregular
42
+ crons (`0 3 1 12 *`, `0 9 * * 1-5`) the gap between the next two runs is not a
43
+ constant period. It is a sound *lower-ish bound* for the comparison here, but
44
+ it is an approximation, and it is the same approximation the pre-existing
45
+ `lastRun` branch already relies on. Not made worse; not fixed either.
46
+ - **Nothing yet asserts one-shot semantics.** ACT-724 asks for first-class
47
+ one-shot jobs. This change makes cron-scheduled future jobs safe at boot; a
48
+ true one-shot type (fire once, then retire) is still absent, and the reminder
49
+ feature will need it.
50
+
51
+ ## 3. Level-of-abstraction fit
52
+
53
+ Right layer. The missing thing was a *fact* ("when did this job start
54
+ existing?"), so the fix adds the fact at the point that knows it — registration
55
+ — and the decision stays where it was, in the sweep.
56
+
57
+ The alternative considered and rejected: seeding `lastRun` at registration.
58
+ That would have suppressed the symptom with less code, but it lies — it records
59
+ a run that never happened, corrupting run history, `runCount` semantics, and any
60
+ future "when did this last actually execute?" question. A separate field keeps
61
+ "registered" and "ran" as the distinct facts they are. ACT-724's own sketch
62
+ floated the `lastRun`-seeding option; this is a deliberate departure from it.
63
+
64
+ ## 4. Signal vs authority compliance
65
+
66
+ `docs/signal-vs-authority.md`. The sweep holds real authority — it *triggers
67
+ jobs* — and it is deterministic, which is appropriate: "has this job existed
68
+ longer than its interval?" is arithmetic over two timestamps, not a judgment.
69
+ There are no competing signals to weigh.
70
+
71
+ **The direction of failure is the load-bearing property.** Every unknown case
72
+ now resolves to *not firing*: missing `firstSeenAt`, unparseable `firstSeenAt`,
73
+ uncomputable interval. That is the safe direction, and asymmetrically so — a
74
+ skipped catch-up costs one delayed run, while a wrongly-fired future job marks
75
+ itself delivered and will never fire again, so nobody goes looking for it. A
76
+ missed reminder that looks delivered is worse than one that is merely late.
77
+
78
+ The registration write is wrapped: a bookkeeping failure logs and continues
79
+ rather than preventing the scheduler from starting, and the absent field then
80
+ degrades to not-firing.
81
+
82
+ ## 5. Interactions
83
+
84
+ - **The `lastRun` branch** — untouched. Jobs with run history take the identical
85
+ path (`timeSinceLastRun > intervalMs * 1.5`).
86
+ - **Priority sorting and trigger limits** — untouched; a never-run job that does
87
+ qualify still enters with `overdueRatio: 1.5` exactly as before.
88
+ - **`tests/unit/JobScheduler.test.ts`** — its `beforeEach` pre-seeds `lastRun`
89
+ "so checkMissedJobs doesn't trigger jobs at startup". That workaround is now
90
+ unnecessary but remains harmless and was left alone; removing it is unrelated
91
+ churn in a file this change does not otherwise touch.
92
+ - **`tests/unit/job-scheduler-edge.test.ts`** — one existing test asserted the
93
+ buggy behaviour ("triggers jobs that have never run on startup"). It was
94
+ written for a real concern (never-run jobs being skipped forever) and had
95
+ over-corrected. **Updated, not deleted:** it now seeds `firstSeenAt` a day in
96
+ the past so it exercises the scenario it was actually written for, and a
97
+ sibling test pins the other side of the boundary. Called out explicitly
98
+ because editing a test to make a change pass is exactly the move that needs
99
+ scrutiny.
100
+ - **StateManager** — `firstSeenAt` is additive and optional; older state loads
101
+ unchanged, and no migration is required.
102
+
103
+ ## 6. External surfaces
104
+
105
+ - No route, config key, flag, env var, CLI surface, message, or notification.
106
+ - `JobState` gains one optional field, persisted in existing job-state files.
107
+ Additive; nothing reads it but the sweep.
108
+ - **User-visible behaviour:** the disappearance of unexplained job runs after a
109
+ restart. No new output.
110
+ - No timing dependence beyond what the sweep already had.
111
+
112
+ ## 7. Multi-machine posture (Cross-Machine Coherence)
113
+
114
+ **Machine-local BY DESIGN** — `machine-local-justification: hardware-bound-resource`.
115
+
116
+ Job run state is per-machine because job *execution* is per-machine: each
117
+ machine runs its own scheduler over its own scoped jobs, and "when did this
118
+ machine first register this job?" is inherently a fact about that machine's
119
+ scheduler, not a fleet fact. Replicating it would be actively wrong — machine B
120
+ adopting machine A's `firstSeenAt` would make a job that B has never registered
121
+ appear to have existed for days, re-creating the immediate-fire bug through the
122
+ mesh.
123
+
124
+ This is inherited, not introduced: `JobState` (`lastRun`, `runCount`,
125
+ `consecutiveFailures`) is already machine-local for the same reason, and this
126
+ field joins it. No new cross-machine surface, no notice, no durable shared
127
+ state.
128
+
129
+ ## 8. Rollback cost
130
+
131
+ **Revert the commit.** Reverting restores the fire-everything behaviour; the
132
+ orphaned `firstSeenAt` values in job-state files are ignored by the old code and
133
+ harmless. No migration, no data repair, no flag.
134
+
135
+ ## Second-pass review
136
+
137
+ **Required** — this touches session/job lifecycle (it decides whether a job is
138
+ triggered), which is on the Phase-5 trigger list.
139
+
140
+ Self-review, recorded rather than skipped, with the two things I went looking
141
+ for:
142
+
143
+ 1. **Did I make a test pass by weakening it?** One existing test was changed.
144
+ It asserted "any never-run job fires at startup" — the exact behaviour being
145
+ fixed — so it could not survive intact. I preserved its *intent* (a never-run
146
+ job must not be skipped forever) by giving it a past `firstSeenAt`, and added
147
+ the missing opposite-side assertion. The suite ends with strictly more
148
+ coverage of this boundary than it had: 2 tests where there was 1, plus 3 in a
149
+ dedicated file.
150
+ 2. **Is the new failure direction actually safe?** Every uncertain path leads to
151
+ "don't fire". The worst case is a delayed run; the previous worst case was a
152
+ reminder silently discharging itself early. Verified by test rather than
153
+ asserted: the annual-cron case and the fresh-daily case both stay at zero
154
+ triggers, and the genuinely-overdue case still fires.
155
+
156
+ Full scheduler suite re-run: 76 tests across 8 files, all green.