Skip to content

feat: background daemon skeleton + scheduler + score-backlog poll (Phase 2, PR 1) - #29

Merged
Harikeshav-R merged 6 commits into
mainfrom
feat/daemon-scheduler
Aug 5, 2026
Merged

feat: background daemon skeleton + scheduler + score-backlog poll (Phase 2, PR 1)#29
Harikeshav-R merged 6 commits into
mainfrom
feat/daemon-scheduler

Conversation

@Harikeshav-R

Copy link
Copy Markdown
Owner

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_posting hardwires the url source, so there is nothing genuinely new to poll today. But score_posting is 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.daemon package
    • poll.pyrun_scoring_poll: the pure scheduled job. Scores every posting with no MatchScore yet (list_unscored_postings) against the active profile, best-effort per posting (a MatchingError is counted + skipped, never aborts the batch), → PollOutcome.
    • scheduler.py — a Scheduler Protocol seam + pure register_poll_job (interval from [discovery], clamped ≥ 1 min) + default_scheduler, a # pragma: no cover factory that lazily imports APScheduler's BlockingScheduler (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 injectable ProcessControl seam (real os.kill impl pragma'd).
  • atlas daemon start|stop|status (cli/daemon.py + main.py) — mirroring the existing sub-Typer groups; only the real blocking scheduler.start() is a pragma'd edge.
  • [discovery] config (DiscoveryConfig: poll_interval_minutes, enable_scraping), list_unscored_postings (matching repo), pid_file() (paths).
  • New dep: apscheduler (+ mypy override; no stubs).

Decisions (confirmed up front)

  • Scope: scheduler + score-backlog job. IPC socket deferred to when the TUI needs to trigger work; desktop notifications + discovery adapters are later Phase 2 items.
  • Scheduler: BlockingScheduleratlas daemon start runs foreground; the OS (systemd/launchd/Task Scheduler) handles backgrounding.

Testability

Split into pure (tested directly with db_engine + FakeLLMProvider / new FakeScheduler + FakeProcessControl) vs thin # pragma: no cover edges (real scheduler.start(), the lazy APScheduler import, real os.kill) — the established logging/opener/runner seam pattern. Confirmed: importing atlas.daemon does not import apscheduler.

Key commits

  • build(deps): add apscheduler for the background daemon
  • feat(config): add [discovery] config and the daemon pid_file path
  • feat(matching): add list_unscored_postings for the score backlog
  • feat(daemon): add the background scheduler and scoring poll
  • feat(cli): add atlas daemon start|stop|status
  • docs: record the daemon skeleton and open Phase 2

Testing

  • ruff format --check + ruff check clean; mypy --strict clean on Linux and --platform win32.
  • Full suite 808 passing at 100% line + branch coverage.
  • Verified end-to-end against a temp DB: a poll tick scores the backlog (unscored posting → score 88), status distinguishes a live vs. stale PID, and stop clears a stale pidfile.

No migration (job_source.enabled/last_polled_at already exist, unused until real polling). Refs: # (daemon is the first of several Phase 2 items).

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).
@Harikeshav-R
Harikeshav-R merged commit 7942e4f into main Aug 5, 2026
10 checks passed
@Harikeshav-R
Harikeshav-R deleted the feat/daemon-scheduler branch August 5, 2026 05:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant