Tasks
The task mixin and manager, the spool, executor, scheduler and stores.
Task capability: the task backbone as a mixin over the base server (◆D22, D16).
TaskMixin is a capability composed over BaseServer AFTER StorageMixin
(it needs server.storage) and BEFORE BaseServer (the loop hook must sit
inside the D16 chain so it wraps Lifespan). Its cooperative __init__ peels
tasks= and forwards everything else down the chain (mirroring
StorageMixin.__init__); a composition WITHOUT the mixin has no tasks
attribute at all.
tasks= is the on/off switch AND the tuning carrier: absent or truthy → the
TaskManager is armed and the worker loop runs at startup; tasks=False →
no manager, and the lifespan passes straight through (no loop); a dict —
{"enabled": ..., "tick_seconds": ..., "mount": ...} — peels enabled and
stashes the rest as tasks_config for the manager to apply. Storage/session
are on when their mixin is present, so tasks matches: a composed AsgiServer
runs the worker loop out of the box.
TaskConfigElements is the config_grammar companion (D16: the element is
declared by the class that peels the kwarg). The site recipe composes it
explicitly (config/elements.py) so server(...).tasks(enabled=,
tick_seconds=, mount=) lifts to this mixin’s tasks= kwarg through
element_kwargs — which enforces strict-unknown children (ConfigError).
The manager is built LAZILY on first tasks access, never in __init__: the
cooperative chain runs TaskMixin.__init__ (composed after StorageMixin) from
INSIDE StorageMixin.__init__, before that mixin has assigned server.storage —
so the manager, which opens its spool over server.storage, cannot be built until
the whole chain has completed. Lazy construction defers it to the first use (the
lifespan hook, or a caller reaching server.tasks), when the server is fully live.
The loop is server-owned, hooked in __call__ — lifespan.py is NEVER touched
(ratified). __call__ intercepts the lifespan scope exactly like
CommunicationMixin.__call__: pre-receive lifespan.startup, manager.start(),
replay the startup down super().__call__ (so the base Lifespan still runs the
app hooks and acks the protocol), and await manager.stop() in finally when the
protocol completes at shutdown. Every other scope — and the disabled case — passes
straight through.
- class genro_asgi.tasks.mixin.TaskMixin(**kwargs)[source]
Bases:
objectTask capability mixin, composed after
StorageMixinand before the base.Constructor kwargs peeled here:
tasks— the on/off switch (default on) or the{enabled, tick_seconds, mount}tuning dict lifted from thetasks()config element. When enabled, builds theTaskManagerand starts/stops its worker loop around the ASGIlifespanprotocol.- Parameters:
kwargs (Any)
- config_grammar
alias of
TaskConfigElements
- property tasks: TaskManager
The task manager this server owns (built on first access); disabled is an error.
Lazy: the manager opens its spool over
server.storage, which is not yet set while the cooperative__init__chain is still running — so the first access after the server is live builds it.
- class genro_asgi.tasks.mixin.TaskConfigElements[source]
Bases:
objectConfig elements owned by
TaskMixin(itsconfig_grammarcompanion).Recipes compose this mixin explicitly (
config/elements.py) — no auto-discovery.element_kwargsvalidates children against it (strict-unknown →ConfigError).- tasks = <genro_builders.builder._decorators._DeclarativeMarker object>
TaskManager — the server-owned task backbone (spool + executor + hub, ◆D22).
The spool and the executor are leaf pieces: the spool is a folder model on storage,
the executor runs one resolved task. The manager is what makes them LIVE — a single
object owned by the server (dual relationship: self.server) that drives the
fire-and-forget worker loop for the whole process. It owns:
spool— theLocalTaskExecutor’s own spool overserver.storage(the stateless seam: the same storage yields an equivalent spool, so the manager reuses the executor’s rather than opening a second);executor— theLocalTaskExecutorbound to the live server;hub— the in-memoryEventHub(the live progress courier for Phase 6);scheduler— theTaskScheduler(the recurring loop, overtask_store);task_store— theFileTaskStore(persistent schedules,securewhen keys);worker_id— the single logical worker of the mono-process core ("local").
start()/stop() are the lifecycle the server’s lifespan hook calls
(TaskMixin.__call__): start launches three tasks on the running loop — the
fire-and-forget _worker_loop, the scheduler tick loop, and the built-in
_purge_loop (session GC) — and stop cancels/awaits them all (in-flight
executions are their own tasks and are left to finish). Each loop mirrors the same
shape: a failing pass is logged and never kills the loop.
The worker loop is FIRE-AND-FORGET on the event loop: it polls list_pending,
assign``s each task to ``worker_id, and launches executor.execute as its own
task. The D2 thread pool stays reserved for the blocking handler BODY inside
execute (via server.run_sync) — the loop itself never blocks the pool. The
session-purge loop is a plain internal job (NOT a scheduler store record — it is no
@route(task=...), so it has no registry callable): it calls
server.session_store.purge_expired() through run_sync every PURGE_SECONDS.
Distributed dispatch (worker processes, a batch commander) is out of scope (D22).
- class genro_asgi.tasks.manager.TaskManager(server)[source]
Bases:
objectOwns the spool/executor/hub and drives the fire-and-forget worker loop.
Note
Bound to the live server (dual relationship:
self.server). The loop task and its event loop are held on the instance;runningreports whether the loop task is live.- Parameters:
server (BaseServer)
- __init__(server)[source]
Bind to the live server and build the executor, hub, store and scheduler.
server.tasks_config(the tuning dict the mixin peeled from atasks=dict, empty otherwise) is applied here:mountoverrides the store’s by-keys choice,tick_secondsretunes the scheduler.- Parameters:
server (BaseServer)
- Return type:
None
- async stop()[source]
Cancel the worker, purge and scheduler loops (lifespan shutdown).
In-flight executions are their own tasks and are left to finish.
- Return type:
- publish_progress(task_id, data)[source]
Write a progress snapshot AND publish it live, in one paired call.
The pairing rule of the push channel: every
progress.jsonwrite also fans out on the hub, keyed by the descriptor’s launchingsession_id(None= no push channel, the spool write still happens). The task must be ACTIVE on this manager’s worker — writing progress anywhere else would create a stray spool folder.
TaskSpool — the file spool of batch tasks (folder-move model, ◆D22).
A batch task is a FOLDER on storage; its STATE is its POSITION in the tree. The
sender (an app handler, or the scheduler) creates the folder under pending/;
the manager MOVES it into the assigned worker’s folder; the worker runs it and
MOVES it to terminated/ or aborted/. A move is the only state transition
— atomic on one mount (LocalStorageNode.move_to, a plain Path.rename).
Layout (all on the plain site mount — these are service data, never secrets,
so NO encryption; distinct from the task STORE, which goes secure when keys
are installed):
batches/
pending/<task_id>/ sender writes here; the manager polls it
active/<worker_id>/<task_id>/ the manager moves it here to assign it
terminated/<task_id>/ completed
aborted/<task_id>/ interrupted (user cancel / error / crash)
Inside a task folder:
descriptor.json the TaskDescriptor (identity + node_path + status + outcome)
params.pkl the call kwargs (pickled: may carry Python objects)
progress.json the worker's latest progress snapshot (single-writer: the worker)
cancel marker file: present == the user requested a stop
result the batch result (written by the worker on completion)
A batch_id is terminal (§5.7): it never resumes or relaunches itself. An
orphan (a task left under active/ whose worker died) is settled aborted
at boot; relaunch = a NEW id. There are no locks: safety is structural — one
single writer per state directory (sender on pending, one worker per active
subfolder), and a claim is an atomic rename.
The spool is the shared object the sender, the manager and the worker all use,
each with the methods that concern it. Storage is synchronous by construction
(core 1b): async callers dispatch blocking spool calls via server.run_sync.
- class genro_asgi.tasks.spool.TaskSpool(storage)[source]
Bases:
objectThe file spool: create in pending, move between states, read progress/cancel.
Note
Holds the shared
LocalStorage(dual relationship:self.storage), never raw paths. All I/O is plain synchronous storage calls.- Parameters:
storage (LocalStorage)
- __init__(storage)[source]
Bind the spool to the server’s storage service (the
sitemount).- Parameters:
storage (LocalStorage)
- Return type:
None
- create(descriptor, params)[source]
Create a task folder under
pending/with its descriptor and params.Writes
descriptor.jsonand the pickledparams.pkl. Returns thetask_id. This is a plain storage fact done by whoever launches the batch; the manager only ever MOVES the folder afterwards.
- assign(task_id, worker_id)[source]
Move
pending/<id>->active/<worker_id>/<id>(the manager’s act).Stamps
status=active,worker_idandstarted_tson the descriptor, then moves the folder. The move is atomic on the mount.- Raises:
LookupError – if the task is not in
pending.- Return type:
- Parameters:
- settle(task_id, worker_id, outcome, error=None)[source]
Move
active/<worker_id>/<id>->terminated/oraborted/.outcome== “ok” settles underterminated/; anything else (error, aborted, orphan) settles underaborted/. Stamps the descriptor withended_ts/outcome/error. Terminal: a settled task never moves again — re-settling raises because the folder is no longer active.
- write_progress(task_id, worker_id, data)[source]
Write the worker’s latest progress snapshot (single-writer: the worker).
- read_progress(task_id)[source]
Read a task’s latest progress snapshot, or None (monitor / SSE baseline).
- request_cancel(task_id)[source]
Drop the
cancelmarker in the task folder (the user’s stop request).
- write_result(task_id, worker_id, data)[source]
Persist the batch result in the task folder (pickled).
- list_by_owner(owner)[source]
Every task of
owneracross ALL states (pending/active/terminated/aborted).The user’s full picture: queued, running, finished, aborted. Reads all state directories; the monitor uses the raw states, the page filters by owner here.
- genro_asgi.tasks.spool.new_descriptor(task_id, owner, mount, node_path, session_id=None)[source]
Build a fresh TaskDescriptor dict in the
pendingstate.The autosufficient shape a worker needs to run the task without the sender’s context: identity, the owner (for
list_by_ownerand progress), and how to resolve the code —mount(the app) +node_path(resolved by the worker withRouter.node(node_path), callable, no HTTP).session_idis the launching MCP session, the key the executor publishes progress events under (None when the sender has no push channel).paramstravel in a separate pickled file, not inline here.
LocalTaskExecutor — run one spool task in-process on the live server (◆D22).
A batch task names its code by mount + node_path (the app it lives in,
and the route path inside that app’s router). Running it needs the app instance
— which needs the whole server (storage, dbs, sibling apps). In this mono-process
core the server is ALREADY live: the executor binds to it directly and reaches
the target app through server.mounts / server.primary. It never serves
HTTP; it only resolves and runs handlers. (Distributed execution — a worker
process that rebuilds the server from a config path — belongs to the
orchestration package, D22, and is out of scope here.)
Running a handler needs NO request context: a @route handler is a bound
method, so self.server / self.db are already reachable from the app
instance. The executor resolves the callable with app.route.node(node_path)
(a RouterNode, callable, no HTTP) and invokes it with the task’s params —
the same async/sync split the dispatcher uses: an async handler stays on the
loop, a sync handler goes through server.run_sync (the Macro 1 pool
protocol; routed_application.py/applications/mcp.py are the mirrors).
The manager has already MOVED the task into active/<worker>/; the executor
reads it there, runs it, and settles it once: any outcome (ok / error) moves the
folder to terminated / aborted and a batch_id never moves again
(§5.7 terminal-by-position). There is a single logical worker in the core,
WORKER_ID == "local"; the per-worker active/<worker>/ structure stays
for D22 forward-compat.
The A<->C bridge (core 1e Phase 6): a descriptor carrying the launching MCP
session_id gets its lifecycle published on server.tasks.hub — started
before the run, settled (with outcome/error) after — so a subscribed SSE
stream follows the task live. session_id None = no push channel, no-op.
The spool stays the source of truth; the hub is only the live courier.
- class genro_asgi.tasks.executor.LocalTaskExecutor(server)[source]
Bases:
objectResolve a spool task by
mount/node_pathand run it on the live server.Note
Bound to the live server (dual relationship:
self.server); owns theTaskSpoolover the server’ssitestorage. Both attributes are the seam theTaskManagerreuses (the spool is stateless — the same storage yields an equivalent spool).- Parameters:
server (BaseServer)
- __init__(server)[source]
Bind to the live server and open the spool over its storage.
- Parameters:
server (BaseServer)
- Return type:
None
- resolve(descriptor)[source]
Return the callable
RouterNodefor a task descriptor.Looks the app up by
mountinserver.mounts(the primary answers an empty/unmatched mount, mirroring the request demux) and resolvesnode_pathin that app’s router. The node is callable — invoking it runs the handler, no HTTP.- Raises:
LookupError – if
mountnames neither a secondary mount nor the primary.- Return type:
- Parameters:
- async execute(task_id, worker_id)[source]
Run the task active on
worker_idand settle it; return the outcome.Reads the descriptor and params from the spool, resolves the handler, runs it (async on the loop, sync on the pool via
server.run_sync), writes the result, and settles the folder toterminated(ok) oraborted(error). The outcome is"ok"or"error"; on error the exception text is stamped on the descriptor viasettle.- Raises:
LookupError – if no task
task_idexists in the spool.- Return type:
- Parameters:
TaskScheduler — the loop that runs due schedules (◆D22).
One asyncio task owned by the TaskManager (started with the manager on
lifespan startup, dies in shutdown), tick ~30s (cron granularity is the minute).
Each tick:
scan: walk every mounted app’s routing tree (
nodes(lazy=True, forbidden=True)— a gated route is still a declared task) collecting entries whose metadata carriestask. The tree IS the live registry: no register() API. A task_name declared twice is an ERROR — both are excluded, no silent pick. A record whose task_name nobody declares is an ORPHAN: never executed, never deleted (a UI concern).sync defaults: a declared
task_every/task_cronauto-creates the missing code-default record (code = task_name); an existing record always wins (store override) — deleting it resets to the code default at the next scan.run: for each due record resolve the live callable and spawn it — async handlers on the loop, sync ones through
server.run_sync(the D2 pool). No overlap: a schedule whose previous run is still in flight is skipped. Runs are SYSTEM calls: no middleware chain, no auth filters.
On completion the record’s last_*/next_run_ts update and a JSONL line is
appended to the task’s capped log. run_now fires a schedule immediately (from
another thread when the loop is up, inline otherwise) with the same no-overlap
guard and the same outcome path.
The store lives on the TaskManager (manager.task_store), not the server;
the scheduler reaches the live server through manager.server. Store I/O and
sync task bodies are synchronous by construction (core 1b) and dispatched via
server.run_sync (a zero-arg closure — run_sync takes no kwargs).
- class genro_asgi.tasks.scheduler.TaskScheduler(manager)[source]
Bases:
objectThe scheduling loop bound to its manager (dual relationship).
- Parameters:
manager (TaskManager)
- __init__(manager)[source]
Bind the scheduler to the manager owning the store, server and loop.
- Parameters:
manager (
TaskManager) – The TaskManager; the store ismanager.task_store, the live server ismanager.server, the live registry is the mounted apps’ routing trees.- Return type:
None
- scan()[source]
task_name -> {“callable”, “metadata”} from every mounted app’s tree.
Duplicates (the same task_name declared by two routes) are an explicit error: logged and EXCLUDED — no silent pick.
- sync_defaults(registry, now)[source]
Auto-create the code-default record for declared default schedules.
- async tick()[source]
One pass: scan, sync defaults, spawn every due schedule.
Store I/O (
sync_defaultswrites,due_rowsreads every record) runs on the server pool viarun_sync, never on the loop.- Return type:
- run_now(code)[source]
Fire a schedule immediately (the UI’s Run now). Thread-safe.
- Return type:
- Returns:
"started"(fired on the live loop),"done"(executed inline — no loop running, e.g. in tests), or"running"(skipped: the previous run is still in flight).- Raises:
LookupError – If the code is unknown or its task_name is an orphan.
- Parameters:
code (str)
Schedule parsing and next-run computation — the three task kinds.
every:"30s" | "15m" | "2h" | "1d"— next = after + interval.cron: classic 5-field string (min hour dom month dow) with* , - /, parsed in-house (~60 lines; croniter stays out — zero dependencies). System cron semantics: dow accepts 0-7 (0 and 7 = Sunday); when BOTH dom and dow are restricted a date matches if EITHER matches; evaluation is in LOCAL time, like system cron.at: a list of ISO timestamps (naive = local time), each fires once; an exhausted list yields no next run (a DERIVED state — the record stays).
All computed instants are POSIX epoch seconds (the store’s *_ts fields).
No replay: the next run is always computed forward from after (now),
never backfilled for downtime.
- genro_asgi.tasks.schedule.parse_every(spec)[source]
"30s" | "15m" | "2h" | "1d"-> the interval in seconds.- Raises:
ValueError – On a malformed spec, an unknown unit or a zero interval.
- Return type:
- Parameters:
spec (str)
- genro_asgi.tasks.schedule.parse_at(spec)[source]
A list of ISO timestamps -> sorted epoch seconds (naive = local time).
- Raises:
ValueError – If
specis not a list or an entry is not ISO-parseable.- Return type:
- Parameters:
spec (Any)
- class genro_asgi.tasks.schedule.CronSpec(spec)[source]
Bases:
objectA parsed 5-field cron string;
next_afterwalks to the next match.- Parameters:
spec (str)
- __init__(spec)[source]
Parse
"min hour dom month dow"into value sets.- Raises:
ValueError – On a wrong field count or a malformed/out-of-range field.
- Parameters:
spec (str)
- Return type:
None
- next_after(after_ts)[source]
The first matching instant strictly after
after_ts(epoch seconds).- Raises:
ValueError – If nothing matches within ~4 years (an impossible date, e.g.
"0 0 31 2 *").- Return type:
- Parameters:
after_ts (float)
- genro_asgi.tasks.schedule.next_run(kind, spec, after_ts)[source]
The next due instant for a schedule, or None (an exhausted
atlist).
TaskStore — the persistent half of the task backbone (NO SQLite).
The routing tree declares tasks (the live registry); the store remembers the
SCHEDULES: when each one runs, whether it is enabled, what happened last time.
Filesystem on the storage layer behind a small contract — one JSON per schedule
record at tasks/<code>.json, one capped JSONL log per task at
tasks/logs/<task_name>.jsonl. The mount is chosen exactly as the other
server stores’ data of record: the encrypted secure mount when key material
is installed, else the plain site mount with a warning (distinct from the
task SPOOL, which is always site — spool data are transient service state,
task RECORDS may carry schedule kwargs worth encrypting). A future Db-backed
store swaps behind the same contract.
The record:
{
"code": "shop_cleanup", # PK; the default row's code IS the task name
"task_name": "shop_cleanup", # joins the live registry (the tree)
"target_kind": "task", # "path" reserved for a future privileged mode
"kwargs": {}, # passed to the callable
"kind": "every", # every | cron | at
"spec": "15m", # interval | cron string | ISO list
"enabled": true,
"next_run_ts": 1783900000.0, # epoch; null = nothing due (exhausted "at")
"last_run_ts": null, "last_outcome": null,
"last_error": null, "last_duration": null
}
The store never computes schedules (that is tasks.schedule) and never
resolves callables (that is the scheduler): it persists, lists and filters.
upsert_default is the code-default rule: created when absent, an existing
record ALWAYS wins (the config/preferences pattern). Storage is synchronous by
construction (core 1b): async callers dispatch store calls via server.run_sync.
- class genro_asgi.tasks.store.TaskStore[source]
Bases:
objectContract for the schedule store (see module docstring).
- append_log(task_name, entry)[source]
Append one JSONL line to the task’s log, keeping the last LOG_CAP.
- class genro_asgi.tasks.store.FileTaskStore(storage, mount=None)[source]
Bases:
TaskStoreOne JSON per schedule + one JSONL log per task, on the storage layer.
Note
The store holds the shared
LocalStorageinstance (dual relationship:self.storage), never raw paths — the mount decides encryption at rest.- Parameters:
storage (LocalStorage)
mount (str | None)
- __init__(storage, mount=None)[source]
Bind the store to the server’s storage service.
- Parameters:
storage (
LocalStorage) – The server’s LocalStorage; task files live on itssecuremount when keys are installed, elsesite.mount (
str|None) – Explicit mount override (thetasks(mount=...)config element);Nonekeeps the automatic by-keys choice.
- Return type:
None
- save(record)[source]
Persist
recordthrough the storage node, warning if unencrypted.codeis the file identity.created_atis kept from the existing record on update;updated_atis refreshed on every write.
EventHub — in-memory per-session event fan-out (the live courier, ◆D22).
The spool on storage is the SOURCE OF TRUTH for task progress (the worker writes
progress.json at each tick); the hub is the LIVE COURIER that carries the same
event to whoever is watching NOW. A subscriber is a bounded asyncio.Queue keyed
by session_id (the launching MCP session). The executor pairs each
spool.write_progress(...) with a hub.publish(session_id, event) (wired in
Phase 6); the MCP push channel (Phase 6) subscribes on a GET and drains the queue
into an SSE stream.
Fire-and-forget, shaped like ChannelClient.send (channel/client.py) but pure
in-memory — no transport, no frames, nothing to import. A publish to a session
with no subscriber is a no-op: progress is not lost, it lives on the spool; the hub
only serves live watchers. The queue is bounded and DROPS THE OLDEST event when
full: progress is idempotent (each event is a snapshot superseding the previous), so
a slow reader loses intermediate frames, never the meaning.
No durable replay log lives here (that reads as orchestration, D22): resumability is
snapshot-baseline — a late subscriber replays the current progress.json snapshot
(Phase 5/6), then follows the live queue.
- class genro_asgi.tasks.hub.EventHub[source]
Bases:
objectPer-
session_idfan-out of progress events over bounded queues.Note
Pure in-memory state on the instance (
self._subscribers): a session maps to the set of its live queues (one server may serve several SSE streams for the same session). No locks — every method runs on the one server event loop.- subscribe(session_id)[source]
Register a fresh bounded queue for
session_idand return it.The caller (the SSE handler) drains the queue until it unsubscribes.