Commit Graph

5 Commits

Author SHA1 Message Date
8a556f8d7d Cache .mypy_cache so mypy stops re-analysing the stdlib every run
Profiled the 23s hook step per hook. It is not evenly spread: mypy ~10s and
pylint ~9s are the whole of it, and the remaining 21 hooks share ~4s. Local
timings with a warm cache put mypy at 0.25s against 5.7s cold, so nearly all
of its CI cost is re-analysing the stdlib and third-party stubs from scratch
into a 19MB cache dir that was then thrown away.

Keyed on the config rather than on the sources. The mypy cache metadata
already records the interpreter, the flag set and a hash per source file, so
mypy re-checks whatever actually changed and a restored cache cannot let
stale code pass. The config hash is in the key regardless, so bumping the
mypy rev or editing its args busts it.

Deliberately not keyed on github.sha, which is the other common pattern:
that re-saves 19MB x3 on every commit and would evict the 68MB hook caches
under the 10GB LRU budget for the repo. A fixed key keeps the dependency
analysis, which is the part that costs the 10s.

pylint needs no change -- it already runs --jobs=4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kMvSMtsVMB3d2uoZ56pbp
2026-09-12 18:28:58 +02:00
a473e91485 Stop installing linters the pre-commit jobs never use, and add cache fallbacks
Two follow-ups measured on the warm-cache run of d56b936, where the three
pre-commit jobs were 39-44s split as: 4s runner setup, 9s pip, 2s cache
restore, 23s hooks.

The 9s pip was pure waste. Every linting hook runs in its own isolated env
-- ruff, mypy, pylint, bandit and codespell each come from their own
repo+rev, mypy and pylint declaring their own additional_dependencies -- and
the only local hooks in the pre-commit stage need bash and python3. The
pytest-coverage hook, the one thing that would need the real dependency set,
is stages: [pre-push] and never runs here. Verified by running all three
configs green in a venv containing pre-commit and nothing else. The pin is
grepped out of requirements.txt so it cannot drift from the
dependency-freshness gate.

restore-keys is added because the exact key includes the config hash, so the
next hook-rev bump -- this session did one -- would have been a total miss
and reinstalled everything. A prefix hit rebuilds only the changed hook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kMvSMtsVMB3d2uoZ56pbp
2026-09-12 18:22:39 +02:00
d56b936b48 Cache pre-commit hooks per package, and pip, so CI stops reinstalling
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
freedays pre-commit / pre-commit (push) Waiting to run
freedays tests / test (push) Waiting to run
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Measured on 6154dc0: the three pre-commit jobs took 41-59s, of which 38-49s
was pre-commit installing hook environments and ~11s was an uncached pip.

The cache was not merely cold, it was miskeyed. pre-commit/action@v3.0.1
keys on hashFiles('.pre-commit-config.yaml') -- the repo ROOT's config --
but the hooks it installs come from the per-package config passed in
extra_args. Four different configs, one cache key: the three package jobs
raced to overwrite each other's hook sets, and editing a per-package config
never invalidated the key, so the restored cache was silently wrong rather
than missing. Three 68MB entries in the cache list are the symptom.

So the action is replaced with a direct `pre-commit run`, which keeps the
same --config/--all-files behaviour plus --show-diff-on-failure, wrapped in
an actions/cache keyed on the config that actually drives the install.
pre-commit is already an exact pin in each requirements.txt.

setup-python gains cache: pip keyed on each requirements.txt, for the test
jobs too.

The two crdt-sync workflows now list their own file in `paths`, which the
other four already did -- a workflow edit should re-run its own gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kMvSMtsVMB3d2uoZ56pbp
2026-09-12 18:16:51 +02:00
aa032c95e4 Drop the gatelock dependency, and actually sync the pool
Two things the pool needed to be real rather than merely correct.

**freedays no longer depends on gatelock.** It imported
gatelock.log_integrity for ~15 lines of HMAC, but importing gatelock runs
its __init__, which imports tkinter for the lock-window machinery. So a
headless policy library had acquired a hard dependency on a GUI toolkit,
and `import freedays` failed anywhere without Tk:

    ImportError: libtk8.6.so: cannot open shared object file

A vmbox run caught it. The host masked it completely -- Tk is installed
here for the lockers -- and so did CI, whose own comment claimed "no
display needed" while the job would have needed libtk. freedays/_hmac.py
now does the signing with hashlib and hmac.

The format is deliberately unchanged (HMAC-SHA256 over compact
sorted-key JSON), so nothing has to be re-signed: all 8 entries already
in the real audit trail, written by gatelock's implementation, verify
against the new one. That was checked, not assumed.

This also dissolves the version diamond that shaped the app rollout:
freedays now pins only crdt-sync, so it can never conflict with whatever
gatelock tag an app carries.

**The pool now syncs on a schedule.** A `sync-quiet` subcommand plus a
systemd user timer every 15 minutes, because "it syncs" was otherwise
only true if you typed `freedays sync` yourself -- a day marked on the
phone would sit there. sync-quiet exits 0 when there is no network or no
credential, on purpose: a unit that goes red every time the laptop is
offline is a unit you learn to ignore, and a real bug still reaches the
journal through the library's logging.

install.sh gives freedays its own venv rather than borrowing an app's --
the pool is shared by five apps, so binding its CLI to one of them would
make "is freedays installed?" depend on something unrelated, and
uninstalling that app would take the command with it.

Verified in vmbox first (systemd), then on this host: the timer is
enabled and scheduled, and the service *finishes* rather than failing
when there is no remote.

seed_session.py gains freedays, without which the one command that can
give the pool a Firebase session would reject it outright (--app is
choices-validated) and the pool would stay local-only with nothing
saying why. That file was already exactly at the 250-line cap, so the
app list and its rationale move to tool/_seeded_apps.py.

freedays: 109 tests, 100% branch coverage. crdt-sync: 433 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:56:42 +02:00
ef69619597 Add freedays: one shared pool of days when every gate stands down
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
freedays pre-commit / pre-commit (push) Waiting to run
freedays tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Five apps (diet-guard, wake-alarm, screen-locker, leetcode-guard,
home-guard) each enforce something daily, and none of them had a way to
say "not today". screen-locker was the only one with any notion of a day
off, and it had two overlapping ones: a budgeted sick day designed to be
hard to take, and an undocumented scheduled_skips.json that no other app
could see.

This is the second thing generalised, not the first. Free days are
pre-declared, carry no justification, allow consecutive runs, and are
capped only by an annual budget -- 35 per calendar year, resetting Jan 1.
Sick days stay in screen-locker exactly as they are: their rolling
windows (1/7d, 3/30d, 10/90d) exist precisely to forbid the consecutive
runs a holiday is made of, so merging the two policies would have had to
ruin one of them.

One global pool: a free day is free for every app at once, so there is
nothing per-app to mark and nothing per-app to forget.

The integration is one call, `freedays.is_free_day()`, which reads a
single local JSON file and never the network -- a gate fires from a
systemd timer and a hung sync must not be able to hold one open or shut.
It returns False for anything it cannot parse, so an unreadable pool
means the normal rules apply rather than everything switching off.

Two design points worth keeping:

- Un-marking flips a `state` field rather than tombstoning the record.
  crdt-sync deletes are monotonic, so a tombstoned date could never be
  marked free again.
- `consumed` is a sticky flag. Releasing a day that has not arrived
  refunds it; releasing today does not, because those hours were already
  unguarded. It is OR-ed against the stored value on every write so two
  devices either side of midnight cannot hand a spent day back.

Timezone is local, decided in _day.today() and nowhere else. The apps
disagreed about this before: screen-locker's lock chain computes the day
in UTC while add_scheduled_skip.py uses local time, so a skip added near
midnight could name a date the chain never matched.

94 tests, 100% branch coverage, no suppressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:08:22 +02:00