Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Background daemon** (`atlas.daemon`): the first Phase 2 feature (PROJECT.md
§4.1) — a long-running scheduler process. It runs one scheduled job today, the
**scoring poll** (`run_scoring_poll`), which clears the fit-score backlog by
scoring every not-yet-scored posting against the active profile (best-effort per
posting, so one unscoreable posting never aborts the batch). The scheduler is
APScheduler behind an injectable `Scheduler` seam (`register_poll_job` wires the
job from the `[discovery]` interval; the real `BlockingScheduler` is built by a
pragma'd factory that imports APScheduler lazily, so the hermetic suite never
loads it). Lifecycle (`start_daemon` / `stop_daemon` / `daemon_status`) is
tracked by a PID file under the state dir, with the OS process operations behind
an injectable `ProcessControl` seam — everything but the real scheduler start and
OS signals is hermetically testable. The IPC surface for the TUI, desktop
notifications, and discovery-source polling are later Phase 2 work.
- **`atlas daemon start|stop|status` commands** (PROJECT.md §9): `start` runs the
scheduler in the foreground (blocking; background it with your OS service
manager) and refuses to start if one is already running; `stop` signals the
running daemon and clears its PID file; `status` reports running/stopped (Rich
grid or `--json`). Unknown-config / already-running / not-running cases exit `1`.
- **`[discovery]` config section** (`DiscoveryConfig`): `poll_interval_minutes`
(default `120`, drives the daemon's poll) and `enable_scraping` (default
`false`, reserved for the later opt-in scraping phase), per PROJECT.md §10.
Previously ignored-by-design; now loaded into `Config.discovery`.
- **`list_unscored_postings`** (`atlas.matching.repository`): returns postings
with no `MatchScore` yet — the fit-score backlog the daemon's poll drains.
- **`pid_file()`** (`atlas.config.paths`): the daemon's PID-file path under the
state dir.
- New runtime dependency: `apscheduler` (the daemon's scheduler).
- **Tailor workspace TUI screen** (`atlas.tui.screens.tailor_workspace`): the
final slice of Phase 1 item #6 (PROJECT.md §8, screen #4), which **completes the
core loop**. Opened from the Application-detail screen (press `t`), it shows the
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,21 @@ freeze the app. When no AI backend is configured the TUI still opens for browsin
and those actions are disabled (run `atlas doctor` to set the backend up).
Inline editing of selections and per-section regenerate are coming next.

Run background work with the daemon:

```bash
atlas daemon start # run the scheduler in the foreground (blocking)
atlas daemon status # report running/stopped (--json for scripting)
atlas daemon stop # stop a running daemon
```

`atlas daemon start` runs a scheduler that, on the `[discovery]`
`poll_interval_minutes` interval, scores any not-yet-scored postings against your
active profile — clearing the fit-score backlog in the background. (Discovery
polling of ATS boards and aggregators, plus the daemon's IPC link to the TUI,
arrive with the source adapters.) It blocks the terminal; background it with your
OS service manager (`systemd --user`, `launchd`, Task Scheduler).

Logs go to stderr (so stdout / `--json` stays clean) and to a rotating file
under your platform's state directory; verbosity follows `--log-level` / `-v` /
the `ATLAS_LOG_LEVEL` env var / the `[logging]` config.
Expand Down
10 changes: 9 additions & 1 deletion docs/PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1097,7 +1097,15 @@ The document specs everything; build order is phased. Each phase is independentl
depth — `honesty_validate` / AI-phrase scrub / keyword-gap / diff-mode (§5.7, §5.8, §11).)*

### Phase 2 — Discovery & background
- [ ] **Daemon** + scheduler + IPC.
- [ ] **Daemon** + scheduler + IPC. *(**Scheduler skeleton ✅** `atlas.daemon` — an APScheduler
`BlockingScheduler` (behind an injectable `Scheduler` seam; the real one built by a pragma'd,
lazy-import factory) running one job today: the **scoring poll** (`run_scoring_poll`) that
clears the fit-score backlog (`matching.repository.list_unscored_postings`) against the active
profile, best-effort per posting. PID-file lifecycle (`start_daemon`/`stop_daemon`/
`daemon_status`, OS ops behind a `ProcessControl` seam), the `[discovery]` config
(`poll_interval_minutes`), and `atlas daemon start|stop|status`. **Remaining:** the **IPC
surface** (Unix socket / Windows named pipe) for the TUI to trigger work + stream progress, and
wiring the poll to real discovery sources once the adapters below land.)*
- [ ] **Company watchlist** + ATS adapters (Greenhouse, Lever, Ashby, Workday).
- [ ] **Aggregator** adapters + saved keyword searches.
- [ ] Dedup + scored **Discover** queue in the TUI.
Expand Down
74 changes: 53 additions & 21 deletions docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,13 @@
> whenever a roadmap item lands, tick it here and move the "Next up" pointer. A stale
> STATUS.md is a bug.

- **Last updated:** 2026-08-04 (the **Tailor workspace** + background-worker action
wiring landed — `atlas tui`'s tailor/cover/re-render/open run in Textual thread
workers; **Phase 1 item #6 is DONE — the core loop is complete**. Phase 2
(discovery daemon) is next)
- **Current phase:** Phase 1 — Core loop ✅ **complete** (onboarding · master resume
· paste-URL scrape · fit scoring · tailoring + cover letter + rendering · app
tracking + full TUI all landed). **Phase 2 — Discovery & background** is next
(daemon + scheduler + IPC).
- **Last updated:** 2026-08-04 (**Phase 2 has begun** — the **daemon skeleton +
scheduler** landed: `atlas.daemon` + `atlas daemon start|stop|status`, running a
background scoring poll over the fit-score backlog. The daemon's IPC surface +
discovery-source polling, then the ATS/aggregator adapters, come next)
- **Current phase:** Phase 2 — Discovery & background 🚧 (daemon + scheduler ✅ —
APScheduler process, `[discovery]` config, PID-file lifecycle, score-backlog
poll; **IPC + discovery adapters next**). Phase 1 core loop ✅ complete.
- **Design source of truth:** [`docs/PROJECT.md`](./PROJECT.md) — especially the
[phased roadmap](./PROJECT.md#15-phased-roadmap).
- **Working agreement:** [`AGENTS.md`](../AGENTS.md) (branching, commits, tests, PR flow).
Expand All @@ -25,18 +24,24 @@

## ▶ Next up (do this next)

**Phase 1 (the core loop) is complete** — item #6 landed in full: the tracking state machine +
CLI, the four browse/track TUI screens, and now the **Tailor workspace** with its background
thread-worker actions (tailor / cover / re-render / open). Atlas can now, end to end: onboard →
ingest a master resume → scrape a posting → score it → tailor a resume + cover letter → render
PDFs → track the application through its pipeline, all from the CLI **or** the TUI.

**Do Phase 2 next: Discovery & background** ([PROJECT.md §15](./PROJECT.md#15-phased-roadmap),
§5.4). The first item is the **daemon + scheduler + IPC** (`atlas.daemon`, PROJECT.md §4.1) — a
long-running APScheduler process that polls job sources, AI-scores new postings, and exposes a
local IPC surface to the TUI. Then the **company watchlist + ATS adapters** (Greenhouse, Lever,
Ashby, Workday), the **aggregator adapters** + saved searches, the scored **Discover** queue in
the TUI, and **multiple profiles** fully wired.
**Phase 2 (Discovery & background) has begun.** The **daemon skeleton + scheduler** landed
(see "What has landed"): `atlas.daemon` is a long-running APScheduler process
(`atlas daemon start|stop|status`) that runs one scheduled job today — the **scoring poll**,
which clears the fit-score backlog against the active profile. This proves the process +
scheduler + hermetic-testability pattern; there are no discovery-source adapters yet, so the
poll's real work is scoring, not fetching.

**Do these next to grow the daemon (PROJECT.md §4.1, §5.4, §15):**
1. **IPC surface** — a local socket (Unix domain / Windows named pipe) so the TUI can trigger
"poll/tailor now" and stream progress. Follow the `platform/opener.py` seam (a Protocol +
`sys.platform`-dispatched, pragma'd transport); a pure `handle_request` is the tested core.
2. **Company watchlist + ATS adapters** (Greenhouse, Lever, Ashby, Workday) — the first real
discovery sources. Needs a source-attribution variant of `add_posting` (today it hardwires
the `url` source) and job-source repository functions (list-enabled, stamp `last_polled_at`).
3. **Aggregator adapters** + saved keyword searches; the scored **Discover** queue in the TUI;
**multiple profiles** fully wired. Add the "owned by" claim convention (PROJECT.md §4.1) once
the daemon and TUI both write discovery rows, plus a `busy_timeout` PRAGMA.
4. **Desktop notifications** (`desktop-notifier`, §5.16) for new high-fit matches / deadlines.

> **Deferred Phase-1 depth (optional, revisit as needed — not blockers for Phase 2):**
> **PR 2b — tailoring depth**: `honesty_validate` traceability (§11), AI-phrase scrub (§5.7
Expand Down Expand Up @@ -89,6 +94,33 @@ The Phase-0 AI-provider checklist below is retained as historical reference.

## ✅ What has landed

Phase 2 · Daemon skeleton + scheduler — `atlas.daemon` + `atlas daemon start|stop|status`
(the first Phase 2 feature — the background scheduler):

- `atlas.daemon.poll`: `run_scoring_poll(session, *, provider, clock=utcnow)` — the pure
scheduled job. It scores every posting with no `MatchScore` yet (via the new
`matching.repository.list_unscored_postings`) against the active profile, **best-effort per
posting** (a `MatchingError` — no active profile / no master resume / AI failure — is counted
and skipped, not fatal), returning a `PollOutcome` (scored / skipped). Pure over the session →
tested directly with `FakeLLMProvider`.
- `atlas.daemon.scheduler`: a `Scheduler` Protocol (the injectable seam) + the pure
`register_poll_job` (wires the poll on an `interval` trigger from
`config.discovery.poll_interval_minutes`, clamped ≥ 1) + `default_scheduler` — a
`# pragma: no cover` factory that **lazily** imports APScheduler and returns a
`BlockingScheduler`, so the hermetic suite never imports the scheduler stack.
- `atlas.daemon.service`: the lifecycle — `start_daemon` (refuse-if-running → write PID →
register job → `scheduler.start()`), `stop_daemon` (signal + clear PID), `daemon_status`
(running/stopped, stale-PID-aware), and `read_pid`/`write_pid`. The OS process ops
(`current_pid`/`is_running`/`terminate`) sit behind an injectable `ProcessControl` seam whose
real `os.kill`-based impl is pragma'd; tests use a `FakeProcessControl`.
- Config/paths: `DiscoveryConfig` (`[discovery]` — `poll_interval_minutes`, `enable_scraping`;
previously ignored-by-design) + `pid_file()` under the state dir.
- CLI (`atlas.cli.daemon` + `main`): `atlas daemon start` (blocking; refuses if already
running), `stop`, `status` (Rich grid / `--json`). New `apscheduler` dep (+ mypy override, no
stubs). 100% line+branch; `mypy --strict` incl. win32. Verified end-to-end against a temp DB:
a poll tick scores the backlog, `status` reflects a live vs. stale PID, and `stop` clears a
stale pidfile. The daemon's IPC surface + discovery-source polling are next.

Phase 1 · Tailor workspace + action workers — `atlas.tui` (item #6, PR 3 — **completes item #6
and the Phase 1 core loop**):

Expand Down Expand Up @@ -610,6 +642,6 @@ high-level state.
|---|---|---|
| 0 | Foundations (hygiene/CI · scaffold · config/DB/logging · AI providers) | ✅ **complete** — hygiene/CI · scaffold · config/keyring · data layer (SQLModel/SQLite WAL/Alembic) · logging · AI provider abstraction (core contract · CLI + API backends · failover · `atlas doctor` · capability probe) |
| 1 | Core loop (onboarding · resume · scrape · scoring · tailoring · tracking · TUI) | ✅ **complete** — onboarding · master resume · paste-URL scrape · fit scoring · tailoring + cover letter + rendering · application tracking (state machine + CLI) · full TUI (Dashboard · Applications/Kanban · Application detail · Posting detail · Tailor workspace with background action workers). Optional depth (PR-2b tailoring / interactive editing) deferred |
| 2 | Discovery & background (daemon · ATS · aggregators · Discover queue) | 🚧 **next** — not started |
| 2 | Discovery & background (daemon · ATS · aggregators · Discover queue) | 🚧 in progress — daemon skeleton + scheduler ✅ (APScheduler process · `[discovery]` config · PID-file lifecycle · score-backlog poll · `atlas daemon start\|stop\|status`); IPC surface, ATS/aggregator adapters, Discover queue, multiple profiles next |
| 3 | Scheduling & status intelligence (CalDAV · email scan · Q&A drafting) | ⬜ not started |
| 4 | Polish & depth (analytics · more adapters · scraping · DOCX · encryption) | ⬜ not started |
7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ classifiers = [
dynamic = ["version"]
dependencies = [
"alembic>=1.18.5",
"apscheduler>=3.11.3",
"beautifulsoup4>=4.15.0",
"httpx>=0.28.1",
"jinja2>=3.1.6",
Expand Down Expand Up @@ -133,6 +134,12 @@ exclude = ["src/atlas/db/migrations/"]
module = "weasyprint.*"
ignore_missing_imports = true

# APScheduler ships no type stubs; it is imported lazily behind the Scheduler
# seam (src/atlas/daemon/scheduler.py) and never in the hermetic suite.
[[tool.mypy.overrides]]
module = "apscheduler.*"
ignore_missing_imports = true

# --- pytest + coverage ----------------------------------------------------------
[tool.pytest.ini_options]
minversion = "8.0"
Expand Down
37 changes: 37 additions & 0 deletions src/atlas/cli/daemon.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Daemon status rendering for the Atlas CLI.

The ``atlas daemon`` commands (PROJECT.md §9) keep their Typer wiring thin in
:mod:`atlas.cli.main` and delegate the display here, mirroring the other CLI
render modules: this holds the **pure, I/O-light** rendering of a
:class:`~atlas.daemon.service.DaemonStatus` through the shared semantic theme, so
it is testable without invoking the CLI (AGENTS.md §6.2). The lifecycle logic
itself lives in :mod:`atlas.daemon.service`; ``--json`` output comes straight from
the status model's :meth:`~pydantic.BaseModel.model_dump_json`.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from rich.table import Table
from rich.text import Text

if TYPE_CHECKING:
from rich.console import RenderableType

from atlas.daemon.service import DaemonStatus

__all__ = ["render_daemon_status"]


def render_daemon_status(status: DaemonStatus) -> RenderableType:
"""Render a :class:`~atlas.daemon.service.DaemonStatus` as a styled Rich grid."""
grid = Table.grid(padding=(0, 2))
grid.add_column(style="muted", no_wrap=True)
grid.add_column()
if status.running:
grid.add_row("Daemon", Text("running", style="success"))
grid.add_row("PID", str(status.pid))
else:
grid.add_row("Daemon", Text("stopped", style="muted"))
return grid
82 changes: 82 additions & 0 deletions src/atlas/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from atlas.ai.router import build_provider_chain
from atlas.cli.console import console, error_console, print_json_line
from atlas.cli.coverletter import render_cover_letter_outcome
from atlas.cli.daemon import render_daemon_status
from atlas.cli.doctor import render_report, run_doctor
from atlas.cli.matching import render_score
from atlas.cli.materials import render_open_outcome, render_rerender_outcome
Expand Down Expand Up @@ -57,9 +58,14 @@
)
from atlas.config.errors import ConfigError
from atlas.config.loader import load_config
from atlas.config.paths import pid_file
from atlas.config.secrets import default_secret_store
from atlas.coverletter.errors import CoverLetterError
from atlas.coverletter.service import write_application_cover_letter
from atlas.daemon.errors import DaemonAlreadyRunningError, DaemonNotRunningError
from atlas.daemon.poll import run_scoring_poll
from atlas.daemon.scheduler import default_scheduler
from atlas.daemon.service import daemon_status, start_daemon, stop_daemon
from atlas.db import initialize_database, session_scope
from atlas.logging import setup_logging
from atlas.matching.errors import MatchingError
Expand Down Expand Up @@ -132,6 +138,13 @@
)
app.add_typer(status_app)

daemon_app = typer.Typer(
name="daemon",
help="Run and control the background scheduler.",
no_args_is_help=True,
)
app.add_typer(daemon_app)


@app.callback()
def main(
Expand Down Expand Up @@ -965,3 +978,72 @@ def list_applications_command(
print_json_line(report.model_dump_json(indent=2))
else:
console.print(render_applications(report))


@daemon_app.command("start")
def daemon_start() -> None:
"""Start the background scheduler (foreground, blocking) (PROJECT.md §4.1, §9).

Runs Atlas's scheduled work — currently the scoring poll, which clears the
fit-score backlog against the active profile on the ``[discovery]``
``poll_interval_minutes`` interval. This blocks the terminal until stopped
(``atlas daemon stop`` or Ctrl-C); background it with your OS service manager.
Exits ``1`` if config/secrets can't load or a daemon is already running.
"""
try:
config = load_config()
store = default_secret_store()
except ConfigError as exc:
error_console.print(f"[error]atlas daemon start:[/error] {exc}")
raise typer.Exit(code=1) from exc
provider = build_provider_chain(config.ai, store)
engine = _open_database()

def run() -> None:
"""Run one scoring poll in its own short transaction."""
with session_scope(engine) as session:
run_scoring_poll(session, provider=provider)

try:
start_daemon(
pid_file(),
config.discovery,
scheduler=default_scheduler(),
run=run,
)
except DaemonAlreadyRunningError as exc:
error_console.print(f"[error]atlas daemon start:[/error] {exc}")
raise typer.Exit(code=1) from exc
finally:
engine.dispose()


@daemon_app.command("stop")
def daemon_stop() -> None:
"""Stop the running background scheduler (PROJECT.md §9).

Signals the daemon process to shut down and clears its PID file. Exits ``1``
if no daemon is running.
"""
try:
pid = stop_daemon(pid_file())
except DaemonNotRunningError as exc:
error_console.print(f"[error]atlas daemon stop:[/error] {exc}")
raise typer.Exit(code=1) from exc
console.print(f"[success]Stopped the daemon[/success] (pid {pid}).")


@daemon_app.command("status")
def daemon_status_command(
as_json: bool = typer.Option(
False,
"--json",
help="Emit the daemon status as JSON for scripting instead of text.",
),
) -> None:
"""Report whether the background scheduler is running (PROJECT.md §9)."""
status = daemon_status(pid_file())
if as_json:
print_json_line(status.model_dump_json(indent=2))
else:
console.print(render_daemon_status(status))
4 changes: 4 additions & 0 deletions src/atlas/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@
config_dir,
config_file,
data_dir,
pid_file,
state_dir,
)
from atlas.config.schema import (
AiBackends,
AiConfig,
ClaudeCodeBackend,
Config,
DiscoveryConfig,
LoggingConfig,
OpenRouterBackend,
)
Expand All @@ -45,6 +47,7 @@
"Config",
"ConfigError",
"ConfigValidationError",
"DiscoveryConfig",
"KeyringUnavailableError",
"LoggingConfig",
"OpenRouterBackend",
Expand All @@ -55,6 +58,7 @@
"data_dir",
"default_secret_store",
"load_config",
"pid_file",
"resolve_api_key",
"save_config",
"select_backend",
Expand Down
Loading