feat: background daemon skeleton + scheduler + score-backlog poll (Phase 2, PR 1) - #29
Merged
Conversation
Add the APScheduler runtime dependency (the daemon's scheduler, PROJECT.md §4.1, §13) plus a mypy override — like WeasyPrint, APScheduler ships no type stubs and is imported lazily behind a Scheduler seam, never in the hermetic suite.
Add DiscoveryConfig ([discovery]: poll_interval_minutes=120, enable_scraping= false, per PROJECT.md §10) — previously ignored-by-design, now loaded into Config.discovery — and a pure pid_file() helper under the state dir for the daemon's PID file. Both surfaced from atlas.config. Updates the extra-keys-ignored test to use a still-unmodeled section now that [discovery] is real.
Add list_unscored_postings(session) — postings with no MatchScore row yet, oldest-first — the fit-score backlog the daemon's scoring poll drains. A NOT-IN select over match_score.job_posting_id, mirroring the repository's select/col idioms.
Introduce atlas.daemon — the first Phase 2 feature (PROJECT.md §4.1), a long-running scheduler process. - poll.py: run_scoring_poll — the pure scheduled job. Scores every not-yet-scored posting (list_unscored_postings) against the active profile, best-effort per posting (a MatchingError is counted + skipped, not fatal), returning a PollOutcome. Pure over the session → tested with a fake provider. - scheduler.py: a Scheduler Protocol seam + the pure register_poll_job (interval from config.discovery, clamped >= 1 minute) + default_scheduler, a pragma'd factory that lazily imports APScheduler's BlockingScheduler so the hermetic suite never loads the scheduler stack. - service.py: the lifecycle — start_daemon / stop_daemon / daemon_status + read_pid/write_pid, with the OS process ops (current_pid/is_running/terminate) behind an injectable ProcessControl seam whose real os.kill-based impl is pragma'd. Stale-PID aware. errors.py: DaemonError + already/not-running. Tests use a tmp_path pidfile, FakeProcessControl, and FakeScheduler (added to conftest) so the whole flow is hermetic — no real process, scheduler, or waiting.
Wire the daemon lifecycle onto the CLI (PROJECT.md §9), mirroring the existing sub-Typer groups. `start` builds the provider chain (like `add`), binds a poll callable that scores the backlog in its own session_scope, and runs the scheduler — refusing if one is already running; the real blocking scheduler.start() is reached only here. `stop` signals the running daemon and clears its PID file; `status` reports running/stopped (Rich grid or --json). cli/daemon.py holds the pure render_daemon_status. Unknown-config / already-running / not-running exit 1. Command bodies tested via CliRunner with a FakeScheduler + FakeProcessControl so .start() never blocks.
Document the first Phase 2 feature (the daemon skeleton + scheduler): CHANGELOG Added entries (atlas.daemon, the daemon commands, [discovery] config, list_unscored_postings, pid_file, the apscheduler dep), the README daemon section, the STATUS what-landed block, and the PROJECT §15 annotation. Move STATUS "Next up" to the daemon's IPC surface + the ATS/aggregator adapters, mark Phase 2 in-progress (Phase 1 stays complete).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
The first Phase 2 feature (Discovery & background) — the daemon + scheduler (PROJECT.md §4.1, §15). Phase 1's core loop is complete; this starts the background half.
Scope reality: there are no discovery adapters yet (ATS/aggregators are later Phase 2 items) and
add_postinghardwires theurlsource, so there is nothing genuinely new to poll today. Butscore_postingis fully reusable, so the daemon's first useful scheduled job is scoring the fit-score backlog — clearing not-yet-scored postings against the active profile in the background. This proves the process + scheduler + hermetic-testability pattern before the discovery adapters land to fill it with real polling.What changed
atlas.daemonpackagepoll.py—run_scoring_poll: the pure scheduled job. Scores every posting with noMatchScoreyet (list_unscored_postings) against the active profile, best-effort per posting (aMatchingErroris counted + skipped, never aborts the batch), →PollOutcome.scheduler.py— aSchedulerProtocol seam + pureregister_poll_job(interval from[discovery], clamped ≥ 1 min) +default_scheduler, a# pragma: no coverfactory that lazily imports APScheduler'sBlockingScheduler(the hermetic suite never loads it).service.py— lifecycle:start_daemon/stop_daemon/daemon_status+read_pid/write_pid, PID file under the state dir, stale-PID aware. OS process ops behind an injectableProcessControlseam (realos.killimpl pragma'd).atlas daemon start|stop|status(cli/daemon.py+main.py) — mirroring the existing sub-Typer groups; only the real blockingscheduler.start()is a pragma'd edge.[discovery]config (DiscoveryConfig:poll_interval_minutes,enable_scraping),list_unscored_postings(matching repo),pid_file()(paths).apscheduler(+ mypy override; no stubs).Decisions (confirmed up front)
atlas daemon startruns foreground; the OS (systemd/launchd/Task Scheduler) handles backgrounding.Testability
Split into pure (tested directly with
db_engine+FakeLLMProvider/ newFakeScheduler+FakeProcessControl) vs thin# pragma: no coveredges (realscheduler.start(), the lazy APScheduler import, realos.kill) — the establishedlogging/opener/runnerseam pattern. Confirmed: importingatlas.daemondoes not import apscheduler.Key commits
build(deps): add apscheduler for the background daemonfeat(config): add [discovery] config and the daemon pid_file pathfeat(matching): add list_unscored_postings for the score backlogfeat(daemon): add the background scheduler and scoring pollfeat(cli): add atlas daemon start|stop|statusdocs: record the daemon skeleton and open Phase 2Testing
ruff format --check+ruff checkclean;mypy --strictclean on Linux and--platform win32.statusdistinguishes a live vs. stale PID, andstopclears a stale pidfile.No migration (
job_source.enabled/last_polled_atalready exist, unused until real polling).Refs: #(daemon is the first of several Phase 2 items).