Skip to content

scheduler — top-level modules

scheduler.budget

Deterministic context budgeter (BUILD-SPEC §13).

A tokenizer + assembler — code, not a model. It composes each agent invocation to fit the active model's window with headroom for the reply, in the §13 priority order:

Constitution -> role -> retrieved RAG chunks -> history -> tool outputs -> task

When it won't fit, it trims in the §13 order: tighten retrieval depth first (the primary lever — retrieval is usually over-fetched), then compact history (sliding window, oldest first), then truncate tool outputs, and — if even the mandatory frame won't fit — flag escalate so the caller routes to a larger-window tier rather than silently dropping the Constitution. The Constitution, role, and task are never trimmed (Invariant 6); keeping the Constitution lean is therefore context-budget discipline, not just style.

Token counts are a deterministic ESTIMATE (no model in the loop); we bias slightly high and reserve reply headroom so the estimate is safe. A real tokenizer can be injected via Budgeter(estimator=...) without changing callers.

DEFAULT_REPLY_RESERVE = 1024 module-attribute

Estimator = Callable[[str], int] module-attribute

ContextParts dataclass

role instance-attribute

task instance-attribute

retrieved = () class-attribute instance-attribute

history = () class-attribute instance-attribute

tool_outputs = () class-attribute instance-attribute

constitution = None class-attribute instance-attribute

BudgetReport dataclass

window instance-attribute

reserve instance-attribute

used_tokens instance-attribute

retrieved_kept instance-attribute

retrieved_dropped instance-attribute

history_kept instance-attribute

history_dropped instance-attribute

tool_truncated instance-attribute

fits instance-attribute

escalate instance-attribute

BudgetedContext dataclass

messages instance-attribute

report instance-attribute

ConstitutionFrameError

Bases: RuntimeError

Refused: a caller tried to assemble a context whose outermost frame is not the canonical Constitution (Invariant 6/9) without a deliberate, visible override. The Constitution is a fixed point, not caller-substitutable content — closing the Threat-B assembly-logic seam.

Budgeter dataclass

window instance-attribute

reserve = DEFAULT_REPLY_RESERVE class-attribute instance-attribute

estimator = estimate_tokens class-attribute instance-attribute

assemble(parts, *, allow_constitution_override=False)

estimate_tokens(text)

Deterministic token estimate (~4 chars/token, rounded up). Stable across runs and biased slightly high so budgeting with reply headroom stays safe.

suggest_num_ctx(p95_tokens, *, headroom_frac=0.25, floor=2048, step=1024)

Right-size a role's load-time window from tracked usage (§13): p95 + headroom, rounded up to a step multiple, never below floor. The deterministic basis for the per-(model, role) window safe-lever (§14) — the OS agent may tune within bounds.

scheduler.cron

Cron / trough jobs — wiring the curator + dreaming agents into the supervisor (BUILD-SPEC §9, §13).

These are the §9 cognitive-tier jobs that "earn the big model": dreaming synthesis and curation. Both kinds already route to the synthesis tier (scheduler.router), and the supervisor's foreground gate keeps that tier out of foreground time (HEAVY_TIERS) — so these run trough-only, never concurrent with the owner's use (§13), and the two-slot loader's ceiling + swap discipline apply to them for free.

This module only builds the handlers and the enqueue helpers; the supervisor owns the loop. A full dreaming pass is run-to-completion here (the dreamer caps max_clusters); rewriting it as checkpointed per-cluster steps (the queue.checkpoint seam, roadmap §7) is a later refinement if a pass ever grows long enough to want to yield mid-way.

DREAM_KIND = 'dream' module-attribute

CURATE_KIND = 'curate' module-attribute

Handler = Callable[[Job], 'str | None'] module-attribute

dream_handler(dreamer)

curate_handler(curator)

cron_handlers(dreamer, curator)

The handler map for a supervisor that runs the trough jobs.

enqueue_dream(queue, router)

enqueue_curate(queue, router)

scheduler.interface

Wire the Ambassador / interface gateway into the scheduler (Track B / B1).

The interface substrate (edge gateway, core inbox, the Ambassador) was built but never scheduledscheduler/cron.py had no reference to it. This module is that missing wiring, and it lives on the SCHEDULER side because it owns the queue: the Ambassador never imports the scheduler (it stays pure + testable), so the delegation seam (task → gate → queue) and the completed-result surfacing are injected from here as plain closures over the queue.

Two job kinds (scheduler/router.py): * ambassador — the inbox-drain tick: drive CoreInbox.process_once() (pinned tier, reactive) — for the scheduled/daemon path. * ambassador_task — the Ambassador's DELEGATED heavy work: a deep grounded answer on the synthesis tier, run trough-gated by the supervisor, result surfaced later.

ConversationRuntime is the in-process driver the CLI (scripts/talk.py) and the e2e test use: owner text → gateway → inbox → Ambassador → reply, with run_pending_tasks() standing in for the supervisor to complete delegated jobs between turns.

AMBASSADOR_KIND = 'ambassador' module-attribute

AMBASSADOR_TASK_KIND = 'ambassador_task' module-attribute

Handler = Callable[[Job], 'str | None'] module-attribute

ConversationRuntime dataclass

inbox instance-attribute

gateway instance-attribute

adapter instance-attribute

queue instance-attribute

task_handler instance-attribute

send(text, *, conversation='default')

Drive one full turn in-process: owner text → reply text (through the real gateway → filesystem handoff → core inbox → Ambassador → handoff → gateway path).

run_pending_tasks()

Complete any queued delegated tasks (the supervisor's job; the CLI calls this between turns so a delegated result is ready to surface on the owner's next message).

ambassador_inbox_handler(inbox)

Drain the core inbox each tick (the scheduled path). The per-message Ambassador reasoning happens inside process_once via the inbox's handler.

ambassador_task_handler(librarian)

Run one DELEGATED task: a deep grounded answer over the mirror. Returns the answer text, which the supervisor stores as the job result for the Ambassador to surface later.

enqueue_ambassador_inbox(queue, router)

Enqueue one inbox-drain tick (pinned tier, reactive).

build_task_delegation(queue, router, *, gate=None)

Return (delegate, pending_results) closures over the queue. delegate records the task in the gate (the routed-request ledger — visible, never auto-approved) and enqueues the delegated job; pending_results reads completed jobs back for the conversation. The Ambassador holds only these closures — never the queue itself.

build_conversation_runtime(config=None, *, server=None, embedder=None, store=None, drift=None)

Wire the full delegating Ambassador + inbox + gateway + queue for in-process use.

server/embedder/store are injectable (offline CLI + tests). The delegated-task librarian runs on the synthesis tier (heavy work) over the SAME store the Ambassador reads, so a delegated result lands where the next conversation can find it.

scheduler.presence

Foreground-presence detection (BUILD-SPEC §13 foreground check).

Heavy synthesis must not fire while the owner is actively using the machine. This reads the OS HID idle time (deterministic, no model) and reports whether the owner is present. The idle source is injectable so the scheduler is testable off-host and a non-macOS worker can supply its own probe.

Fail safe: if idle time is unknown, assume the owner IS present — never run heavy batch work on a blind guess.

DEFAULT_IDLE_THRESHOLD_S = 300.0 module-attribute

IdleProbe = Callable[[], 'float | None'] module-attribute

Presence dataclass

idle_probe = macos_idle_seconds class-attribute instance-attribute

threshold_s = DEFAULT_IDLE_THRESHOLD_S class-attribute instance-attribute

assume_present_when_unknown = True class-attribute instance-attribute

idle_seconds()

foreground_active()

True if the owner is actively using the machine (so heavy tiers are gated).

macos_idle_seconds()

Seconds since the last HID (keyboard/mouse) event, via ioreg. None if unavailable (e.g. not macOS, or the field is missing).

scheduler.queue

Durable job queue — the scheduler's heartbeat (BUILD-SPEC §8, §13; roadmap §7).

SQLite, WAL mode, single-writer by design: one supervisor owns this queue, so there is no write contention to reason about. The queue is the single safe serialization point — agents are config (re-composed per invocation from the stores), not OS processes, so "restoring" a job is cheap; the only heavyweight cost is a model load, which is what the scheduler is built to minimize.

Scheduling is cooperative and acts at job boundaries (roadmap §7): claim() selects the next job by priority, skipping tiers the caller says are currently blocked (the foreground gate), and — within the top-priority band — prefers a job that needs no model swap. A reactive escalation is just a high-priority job; it is dispatched next, never mid-generation. checkpoint/resume support long jobs (dreaming, curation) written as yielding steps.

PRIORITY_REACTIVE = 0 module-attribute

PRIORITY_INTERACTIVE = 10 module-attribute

PRIORITY_DEFAULT = 50 module-attribute

PRIORITY_BACKGROUND = 100 module-attribute

AgingPolicy dataclass

Anti-starvation aging (gap G6 — the liveness fix). A QUEUED job's EFFECTIVE priority improves (its number falls) the longer it waits, so background work (dreaming, curation) eventually outranks a perpetual stream of newer higher-priority jobs instead of starving under sustained foreground load — ◇ queued jobs eventually run.

Bounds, deliberately conservative: * a job that has waited < step_seconds ages zero steps, so NORMAL-load ordering is unchanged (jobs are usually claimed within seconds of enqueue); * aging never lifts a job above floor (default = INTERACTIVE), so an aged background job can come to tie with interactive work and win on FIFO, but can NEVER preempt a genuine REACTIVE escalation (a low-memory alarm must still go first — if those arrive perpetually the system is in crisis and background SHOULD wait).

step_seconds = 900.0 class-attribute instance-attribute

step = 10 class-attribute instance-attribute

floor = PRIORITY_INTERACTIVE class-attribute instance-attribute

Job dataclass

id instance-attribute

kind instance-attribute

tier instance-attribute

num_ctx instance-attribute

priority instance-attribute

state instance-attribute

payload instance-attribute

result instance-attribute

error instance-attribute

attempts instance-attribute

checkpoint instance-attribute

created_at instance-attribute

started_at instance-attribute

finished_at instance-attribute

load_key property

The (tier, window) that must be resident to run this job. Changing either forces a model reload (§13), so the supervisor batches jobs sharing a load_key.

JobQueue dataclass

path instance-attribute

aging = field(default_factory=AgingPolicy) class-attribute instance-attribute

enqueue(kind, tier, num_ctx, *, priority=PRIORITY_DEFAULT, payload=None)

claim(*, loaded_key=None, blocked_tiers=frozenset(), now=None)

Select + mark RUNNING the next eligible job (§13 policy): highest EFFECTIVE priority first (priority + anti-starvation aging, gap G6); within the top band prefer the job needing no model swap (matching loaded_key), then FIFO; skip tiers in blocked_tiers (the foreground gate) — they stay QUEUED and are revisited once the block clears. Returns None if nothing is runnable now.

complete(job_id, result=None)

fail(job_id, error)

defer(job_id, reason)

Park a job that cannot run under current conditions (e.g. ceiling breach). Not re-selected until revive_deferred() puts it back when conditions change.

revive_deferred()

Return deferred jobs to QUEUED (call when conditions change, e.g. RAM freed).

checkpoint(job_id, token)

Persist a resume token for a checkpointed-step job, then re-queue it so the next unit is dispatched at a job boundary (cooperative yielding, roadmap §7).

get(job_id)

list(state=None)

depth()

Number of jobs waiting to run (queue depth — a vital, §8).

counts()

close()

scheduler.router

Router + watchdog — the pinned tiny model's role, done in rules first (BUILD-SPEC §9, §13).

RULES FIRST (roadmap §8): the scheduler is rule-capable by design and the tiny router model is an enhancement, not a single point of failure — so Phase 3 ships a deterministic rule-based router with a seam for the model router and a fall-back-to-rules path. The router decides role/tier/window; deterministic code acts (loads, assembles, dispatches) — model advises, code acts (Invariant 3).

The watchdog reads system vitals (the reactive tier, §9) and raises flags only when a threshold is crossed; those become high-priority jobs the supervisor dispatches next, at the following job boundary (roadmap §7) — never a mid-generation interrupt.

Plan dataclass

kind instance-attribute

tier instance-attribute

num_ctx instance-attribute

priority instance-attribute

Flag dataclass

metric instance-attribute

value instance-attribute

threshold instance-attribute

note instance-attribute

Router dataclass

config instance-attribute

tier_for(kind)

plan(kind, *, priority=None)

Resolve a job kind to (tier, window, priority) from the rules + the model lineup. The window is the model's configured load-time num_ctx (§13); same-window jobs batch together to avoid reloads.

Watchdog dataclass

reader instance-attribute

min_available_gb = 2.0 class-attribute instance-attribute

check()

Read the latest vitals and return any crossed thresholds. Deterministic; escalates to a model only by enqueuing a job, which the supervisor dispatches by priority.

scheduler.supervisor

The supervisor — one loop owns the queue and the worker slot (BUILD-SPEC §13; roadmap §7).

Cooperative, job-boundary scheduling: 1. claim the next eligible job (priority; swap-avoidance within a priority band; heavy tiers gated while the owner is present — the foreground check); 2. make its (tier, window) resident via the two-slot loader, which refuses any load that would breach the RAM ceiling (Invariant 8) — such a job is deferred, not crashed; 3. run its handler to completion (or one checkpointed step), counting worker swaps (the pinned router doing interstitial work never evicts the worker, roadmap §7); 4. record vitals (queue depth, model-load time) and repeat.

A reactive escalation is simply a high-priority job; it is dispatched at the next boundary, never as a mid-generation interrupt. A handler that raises must not take down the loop.

HEAVY_TIERS = frozenset({'synthesis', 'stretch'}) module-attribute

Handler = Callable[[Job], 'str | None'] module-attribute

Supervisor dataclass

queue instance-attribute

loader instance-attribute

handlers instance-attribute

presence = field(default_factory=Presence) class-attribute instance-attribute

telemetry = None class-attribute instance-attribute

secrets = None class-attribute instance-attribute

warm = True class-attribute instance-attribute

swaps = 0 class-attribute instance-attribute

mint_token(role, ttl='10m')

Mint an ephemeral token scoped to role's policy (vault-runtime-auth.md §2). Returns the whole MintedToken: the supervisor passes .token to the agent (Phase 5) and records .accessor in the action's attestation (the Step-5 join) — it holds minting authority only, never reading the secret it mints a token for; that happens later when the agent itself calls get_secret(name, token=...).

blocked_tiers()

tick()

Dispatch at most one job. Returns False when nothing is runnable right now.

run(*, max_ticks=None)

Drain the queue cooperatively. Returns the number of jobs dispatched. Stops when nothing is runnable (e.g. only heavy jobs remain while the owner is present).

scheduler.vault_sync

Wire the vault watcher + incremental re-ingest into the scheduler (vault-sync task).

The watcher (core) only signals; this scheduler-side module turns that signal into a durable background vault_sync job and provides the handler that runs the idempotent re-ingest. So all store mutation happens on the single supervisor writer (the queue's discipline), and the core watcher stays free of any scheduler import (clean layering: scheduler depends on core, never the reverse).

vault_sync is routed to the pinned tier (the router): it needs no chat model — it calls the embedder directly — so making it "resident" is a no-op and the worker slot is never evicted. It runs at BACKGROUND priority (yields to interactive/reactive work) but is NOT in HEAVY_TIERS, so a note saved mid-session is re-ingested promptly rather than waiting for a trough (the owner may want to query what they just wrote).

VAULT_SYNC_KIND = 'vault_sync' module-attribute

Handler = Callable[[Job], 'str | None'] module-attribute

vault_sync_handler(sync)

enqueue_vault_sync(queue, router)

Enqueue one background re-ingest. Coalescing happens upstream (the watcher debounce); duplicate jobs are harmless because rescan() is idempotent.

build_vault_watcher(queue, router, config=None)

A watcher whose on_change enqueues a background vault_sync job. Call .start() to run.

The supervisor must have the vault_sync handler registered (see vault_sync_handler) to actually process the enqueued jobs.