ADR-0071: Durable ad-hoc task queue¶
- Status: Accepted
- Kind: Retrospective
- Area: scheduling-control-plane
- Date: 2026-07-09
- Relations: supersedes v0-0061, v0-0062, v0-0101
Context¶
schedule_runs was generalized so schedule_id may be null. The in-process submit_task() binding validates a TaskApplication and inserts a null-schedule row in queued state with action arguments, execution target, required capabilities, optional library provenance, queue timestamp, and an optional host-scoped concurrency key.
Submission idempotency is deliberately inactive: a non-null key is rejected rather than accepted without deduplication. There is no li task submit command or /api/tasks binding. The queue is therefore a real library capability, not yet a complete public task-admission surface.
The Studio scheduler tick hosts one worker. It heartbeats into workers, reaps expired leases, pages through eligible ad-hoc rows, matches target/capability labels, and claims through a guarded queued -> running transition. Execution reuses the scheduler subprocess argv builder. Terminal writes retain lease identity so a stale worker cannot overwrite a row reclaimed by another worker.
The transition helper is a restricted compare-and-swap implementation for dispatch and schedule-run rows. It appends status_transitions atomically with a successful status update, but does not implement every state declared by the schema and does not deduplicate its modeled idempotency_key. Scheduled fires still bypass this path and enter running under SchedulerEngine.
This ADR answers five problems:
- P1 — Ad-hoc work must survive until a worker sees it. Direct function calls alone lose work when the daemon is unavailable.
- P2 — Submission needs one typed validation boundary. Action kind, payload, target, capabilities, and provenance must be normalized once before persistence.
- P3 — Competing workers need one ownership transition. A read followed by an unguarded update would let two workers execute the same row.
- P4 — Worker death needs bounded recovery. A lease must eventually return work to the queue or fail it instead of leaving
runningforever. - P5 — Placement metadata must not become authorization. Capabilities and concurrency keys decide eligibility/order only; machine locks and permission checks retain separate authority.
| Concern | Decision |
|---|---|
| Submission contract | D1: Validate one frozen TaskApplication; reject unsupported idempotency keys. |
| Persistent queue entity | D2: Store ad-hoc work as schedule_runs rows with schedule_id IS NULL and initial queued. |
| State ownership | D3: Use one guarded transition function for the restricted shipped schedule-run graph and append transition facts atomically. |
| Worker lease and recovery | D4: Heartbeat, reap expired leases, claim with a five-minute lease, and stop after three expired attempts. |
| Capability and concurrency routing | D5: Match eligibility/serialization labels, prefer affinity labels, and use advisory host-scoped concurrency keys. |
Out of scope:
- CLI and HTTP submission bindings are not shipped.
- Submit-level idempotency is not shipped.
- Scheduled fires are not queue rows and are not claimed by this worker.
- Fixed
workflowactions are persisted but explicitly excluded from claims because no workflow worker adapter is wired. - Remote worker transport and remote execution-target implementations are not shipped.
- Capability tokens are not permissions. Authorization remains outside this queue.
- Dependency waiting, retry backoff states, timed-out/skipped transitions, and running cancellation are present in the table vocabulary but not in the restricted task transition graph.
Decision¶
D1 — TaskApplication is the single in-process submit shape¶
The shipped contract is:
# lionagi/studio/services/task_applications.py
@dataclass(frozen=True)
class TaskApplication:
action_kind: str
args: dict[str, Any]
execution_target: str
required_capabilities: list[str] = field(default_factory=list)
library_ref: str | None = None
library_content_hash: str | None = None
idempotency_key: str | None = None
async def submit_task(db: StateDB, app: TaskApplication) -> str: ...
async def cancel_task(
db: StateDB,
run_id: str,
*,
actor: Actor,
) -> bool: ...
Accepted action kinds are the subprocess launcher's set agent | flow | fanout | play | flow_yaml | engine, plus workflow. playbook is accepted as an input alias and normalized to play before storage. Execution targets are the closed set:
{"host", "local_worktree", "daytona", "remote_agent", "process"}
Exact validation semantics:
- A non-null
idempotency_keyalways raisesValueError. Empty string is also non-null and is rejected. This prevents a retrying caller from believing deduplication occurred. - Unknown action kind raises and lists the canonical set plus accepted aliases.
- Unknown execution target raises and lists the closed set.
argsmust be a dict. An empty dict is valid.required_capabilitiesmust be a list of non-empty strings. An empty list is valid and is the default. Duplicate strings are not rejected or normalized at submission.library_refandlibrary_content_hashare stored verbatim as optional provenance; submission does not resolve the ref or verify the hash.TaskApplicationis frozen, so the submitted object is not mutated during alias normalization.- Validation failure occurs before a transaction and inserts no row.
Why this way: one dataclass gives future CLI and HTTP bindings the same library boundary. Explicitly rejecting the dormant idempotency field is safer than accepting a contract the database does not enforce.
Code anchor: lionagi/studio/services/task_applications.py (TaskApplication, _validate, submit_task).
D2 — Ad-hoc tasks reuse schedule_runs¶
Submission generates a full string UUID, derives a concurrency key from serialization-class capabilities, and inserts one row:
INSERT INTO schedule_runs (
id, schedule_id, invocation_id, trigger_context,
action_kind, action_args, status, chain_depth,
fired_at, created_at, queued_at, concurrency_key,
required_capabilities, execution_target,
library_ref, library_content_hash
)
VALUES (
:id, NULL, NULL, {},
:action_kind, :action_args, 'queued', 0,
:now, :now, :now, :concurrency_key,
:required_capabilities, :execution_target,
:library_ref, :library_content_hash
);
The table contract shared with scheduled runs is:
-- lionagi/state/schema.sql (queue-relevant columns)
CREATE TABLE schedule_runs (
id TEXT PRIMARY KEY,
schedule_id TEXT REFERENCES schedules(id) ON DELETE CASCADE,
invocation_id TEXT REFERENCES invocations(id),
trigger_context JSON NOT NULL,
action_kind TEXT NOT NULL,
action_args JSON NOT NULL,
status TEXT NOT NULL DEFAULT 'running'
CHECK(status IN ('queued', 'waiting_dependency', 'running',
'retry_wait', 'completed', 'failed',
'timed_out', 'skipped', 'cancelled')),
exit_code INTEGER,
chain_parent_id TEXT REFERENCES schedule_runs(id),
chain_depth INTEGER NOT NULL DEFAULT 0,
fired_at REAL NOT NULL,
ended_at REAL,
error_detail TEXT,
created_at REAL NOT NULL,
updated_at REAL,
status_reason_code TEXT,
status_reason_summary TEXT,
status_evidence_refs JSON,
queued_at REAL,
leased_by TEXT,
lease_expires_at REAL,
concurrency_key TEXT,
lease_attempts INTEGER NOT NULL DEFAULT 0,
required_capabilities JSON,
execution_target TEXT,
library_ref TEXT,
library_content_hash TEXT
);
CREATE INDEX idx_schedule_runs_queue
ON schedule_runs(status, queued_at)
WHERE status IN ('queued', 'retry_wait');
CREATE INDEX idx_schedule_runs_concurrency
ON schedule_runs(concurrency_key, status)
WHERE status IN ('queued', 'running', 'retry_wait');
Exact initial-state semantics:
schedule_id,invocation_id, lease owner, and lease expiry are null.trigger_contextis{};chain_depthis zero.fired_at,created_at, andqueued_atall receive the submission timestamp. The use offired_atis inherited from the shared run table even though no trigger fired.- The initial insert does not append a
status_transitionsrow. Transition history begins with the first post-submit status move. - Queued persistence survives daemon restart because it is a database row; no in-memory claim is needed for discovery.
- Reusing the table does not unify ownership: scheduled rows have non-null
schedule_idand enterrunning, while ad-hoc rows have nullschedule_idand enterqueued.
Why this way: the row already has run identity, lifecycle columns, invocation linkage, and history surfaces. A second task table would duplicate those fields and require joins or migration before one operator view could show both.
D3 — Restricted schedule-run transitions use guarded CAS¶
The request/result models are:
# lionagi/state/transitions.py
class Actor(BaseModel):
type: Literal["scheduler", "operator", "system", "webhook", "agent"]
id: str
class StateReason(BaseModel):
code: str
summary: str = ""
evidence_refs: list[dict] = Field(default_factory=list)
metadata: dict = Field(default_factory=dict)
class TransitionRequest(BaseModel):
entity_type: str
entity_id: str
from_state: str | None
to_state: str
reason: StateReason
actor: Actor
idempotency_key: str
class TransitionResult(BaseModel):
applied: bool
conflict: bool = False
previous_state: str | None = None
current_state: str
transition_id: str
event_id: str | None = None
async def transition(
db: Any,
request: TransitionRequest,
*,
guard: dict[str, Any] | None = None,
patch: dict[str, Any] | None = None,
) -> TransitionResult: ...
The shipped schedule-run graph is registered in the lifecycle policy registry (lionagi/state/lifecycle/policy.py:302-350):
queued ──► waiting_dependency | running | skipped | cancelled
waiting_dependency ──► queued | cancelled
running ──► completed | failed | timed_out | retry_wait | queued | cancelled
retry_wait ──► queued | cancelled
completed, failed, timed_out, skipped, cancelled ──► no outgoing edges
The registered terminal set carries all five terminal statuses, including timed_out and skipped. An undeclared edge raises ValueError.
Exact compare-and-swap semantics:
- Unsupported entity type, invalid reason code, or guard/patch column outside the per-entity allowlist raises before mutation.
- Missing row raises
LookupError. - A
from_statemismatch returnsapplied=False, conflict=Truebefore vocabulary validation. A second cancel therefore becomes a conflict result, not a terminal-edge exception. - Extra guard mismatch likewise returns a conflict.
- The update uses
WHERE id=:id AND status=:from_stateplus guard predicates. If another writer wins between read and update, rowcount zero returns a conflict. - Status,
updated_at, allowed patch fields, and onestatus_transitionsinsert commit in the samedb._tx()transaction. - Schedule-run guard/patch fields are limited to
leased_by,lease_expires_at, andlease_attempts. - A successful result has
event_id=None; this fallback does not emit a separate event-plane fact. TransitionRequest.idempotency_keyis required by the model but is not stored or checked by the current implementation. Repeating a request is safe only when state/guard mismatch makes it a conflict; arbitrary idempotency-key deduplication is not shipped.
Transition facts use:
CREATE TABLE status_transitions (
id TEXT PRIMARY KEY,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
previous_status TEXT,
status TEXT NOT NULL,
reason_code TEXT NOT NULL,
reason_summary TEXT,
evidence_refs JSON,
source TEXT NOT NULL,
actor TEXT,
created_at REAL NOT NULL,
metadata JSON
);
Why this way: CAS plus lease guards makes ownership loss an explicit non-write. An unrestricted generic state engine was not required for the shipped slice; the cost is visible incompleteness and a modeled idempotency field without deduplication.
D4 — One host worker claims, executes, and reaps leased rows¶
Worker contracts and defaults are:
# lionagi/studio/scheduler/worker.py
TASK_WORKER_ENABLED = True
DEFAULT_LEASE_TTL_SECONDS = 300.0
DEFAULT_HEARTBEAT_TTL_SECONDS = 90.0
MAX_LEASE_ATTEMPTS = 3
ExecuteFn = Callable[[dict[str, Any]], Awaitable[tuple[int, str]]]
async def register_heartbeat(
db: StateDB,
*,
worker_id: str,
advertised_capabilities: list[str] | None = None,
execution_targets: list[str] | None = None,
now: float | None = None,
) -> None: ...
async def reap_expired_leases(
db: StateDB, *, now: float | None = None
) -> dict[str, int]: ...
async def claim_and_execute(
db: StateDB,
*,
worker_id: str,
execute: ExecuteFn | None = None,
now: float | None = None,
lease_ttl: float = 300.0,
limit: int = 20,
advertised_capabilities: list[str] | None = None,
execution_targets: list[str] | None = None,
heartbeat_ttl: float = 90.0,
) -> int: ...
async def worker_tick(...) -> dict[str, int]: ...
The workers table is:
CREATE TABLE workers (
worker_id TEXT PRIMARY KEY,
advertised_capabilities JSON NOT NULL DEFAULT '[]',
execution_targets JSON NOT NULL DEFAULT '[]',
last_heartbeat_at REAL NOT NULL,
leased_run_id TEXT REFERENCES schedule_runs(id)
);
The Studio engine creates one id shaped host:<8 hex> and calls worker_tick() once per scheduler tick. A tick heartbeats first, reaps expired leases second, and claims/executes third. The default execution target is host.
Claim SQL considers only:
WHERE status = 'queued'
AND schedule_id IS NULL
AND action_kind != 'workflow'
ORDER BY queued_at ASC, id ASC
Exact lease semantics:
- A known worker whose heartbeat age exceeds 90 seconds claims nothing. A worker with no heartbeat row is treated as not stale for compatibility with direct callers. The normal Studio tick writes its own heartbeat immediately before checking.
- Candidates are paged 50 rows at a time using a
(queued_at, id)keyset cursor. A pass scans at most 5000 queued rows and attempts at most 20 eligible claims. - The 50-row page is a database/dialect-friendly batching choice; 5000 bounds one tick's scan so a deep ineligible prefix does not monopolize it; 20 bounds executions per pass. No measured tuning rationale for the exact values is recorded.
- Claim is one
queued -> runningtransition that atomically writesleased_by,lease_expires_at=now+300, and incrementslease_attempts. - Lost claim or concurrent cancel is skipped; the worker does not retry that row in the same pass.
default_execute()convertsaction_argsinto a schedule-like dict, resolves the absoluteliprefix, builds argv through the same launcher, and awaits the child. Build/resolve errors become(1, error)rather than escaping.- Exit zero attempts
running -> completed; non-zero or executor exception attemptsrunning -> failed. Both terminal writes guard the exact lease owner and stored expiry value. - Lease expiry by time alone does not invalidate a terminal write. The stale worker loses only after a reaper/claimant changes the guarded fields or status.
- The current worker does not renew leases during long execution.
- Reaper selects
runningrows with non-null expiry strictly less thannow. Under three attempts, it guards the observed expiry, clears lease fields, and returns toqueued. At three or more, it transitions to terminalfailed. - A reaper/terminal race is resolved by CAS. A stale terminal writer cannot overwrite a reclaimed lease.
- Terminal execution currently changes status and writes a transition fact but does not patch
ended_at,exit_code, orerror_detailon the schedule-run row.
Five minutes gives ordinary subprocess tasks time to complete before recovery, 90 seconds gives three 30-second Studio ticks before heartbeat staleness, and three expiries bounds recovery. Only the “conservative bounded recovery” intent is recorded; no workload measurement justifies the exact five-minute or three-attempt values.
Why this way: a lease turns worker ownership into database state and makes restart recovery possible without a broker. Reusing the subprocess launcher avoids a second action vocabulary. The cost is queue latency tied to Studio, sequential execution inside a claim pass, and no lease renewal.
D5 — Capabilities route and order; they do not authorize¶
Capability classification is centralized:
# lionagi/studio/scheduler/capabilities.py
DEFAULT_CAPABILITY_CLASS = "eligibility"
CAPABILITY_CLASSES: dict[str, str] = {
"gpu-exclusive": "serialization",
"warmed-cache": "affinity",
}
def worker_can_serve(
required_capabilities: Iterable[str] | None,
advertised_capabilities: Iterable[str] | None,
) -> bool: ...
def affinity_score(...) -> int: ...
def host_scoped_concurrency_key(
host: str,
required_capabilities: Iterable[str] | None,
) -> str | None: ...
Exact routing semantics:
- Unknown tokens default to
eligibility. - Eligibility and serialization tokens must be a subset of worker advertisements.
execution_targetmust be in the worker target set. A null/empty stored target is claimable by any worker, thoughTaskApplicationvalidation always requires a known non-empty target.- Affinity tokens never filter. Candidate order is descending count of matching affinity tokens, with the original oldest-first SQL order retained for ties by stable sort.
- Serialization tokens are sorted and folded into
<hostname>:<token+token...>. No serialization tokens yields null concurrency key. - Before claiming, the worker reads concurrency keys of all
runningrows. A matching key blocks a candidate. - Once one key is claimed in a pass, it remains in the pass-local blocked set even if execution finishes before later candidates are examined. The next tick may admit the next row.
- This is advisory serialization. A machine-local resource such as a GPU still requires an authoritative worker-side lock; the queue key cannot prevent another local process outside this queue from using it.
- Tokens do not grant tool or data permission. A worker that can route a task must still execute under the relevant authorization policy.
Why this way: a small declarative map keeps placement rules testable and prevents token-name branches from spreading through submit and worker code. Separating eligibility, affinity, and serialization avoids turning a warm cache preference into a hard scheduling failure.
Consequences¶
- Ad-hoc queued work survives daemon restart before claim.
- Guarded leases prevent competing claimants and block stale terminal writes after ownership changes.
- Reusing
schedule_runsgives one run-shaped storage entity but does not yet give scheduled fires the queue's semantics. - Public clients cannot submit through CLI/HTTP, and retries cannot deduplicate submission.
- Workflow actions may be stored but remain permanently ineligible for the shipped worker.
- Queue service time is coupled to the 30-second Studio tick and sequential child execution.
- The transition helper is load-bearing but incomplete: contributors must check the closed graph, guard/patch allowlist, and absent idempotency-key storage before adding a state.
- Capability labels improve routing without becoming a permission system; machine-local locking remains an execution responsibility.
- Reversing D2 would require migrating historical ad-hoc rows to a new task table. D3-D5 are easier to replace behind their typed functions, but pending leases and transition history require a compatibility plan.
Current-vs-ideal delta¶
| # | Delta | Size | Issue |
|---|---|---|---|
| 1 | Add li task submit and HTTP task submission as thin bindings over submit_task(); acceptance: both bindings apply identical validation and persist the same provenance fields. | M | (filled at issue-open time) |
| 2 | Implement atomic submit idempotency; acceptance: repeating one non-empty idempotency key returns the original task id and never inserts a second row. | M | (filled at issue-open time) |
| 3 | Admit fixed workflow tasks to an eligible worker adapter; acceptance: a queued fixed workflow is leased, executed, and terminalized without bypassing the lease guard. | M | (filled at issue-open time) |
| 4 | Complete the schedule-run transition owner and event record; acceptance: every declared lifecycle edge uses one guarded API and produces one durable transition fact. | M | (filled at issue-open time) |
| 5 | Add lease renewal or explicitly constrain admitted task duration below the lease; acceptance: a healthy long-running worker cannot lose ownership solely because execution exceeds the initial TTL. | M | (filled at issue-open time) |
| 6 | Persist worker execution outcome columns atomically with terminal transition; acceptance: ended_at, exit_code, error detail, status, and the transition fact cannot disagree after a crash. | M | (filled at issue-open time) |
Alternatives considered¶
A parallel tasks table¶
A dedicated table could avoid inherited schedule columns such as fired_at and make the domain name clearer. It lost because schedule_runs already carries identity, lifecycle, invocation, reason, lease, and provenance columns. A second table would duplicate the claim protocol and split operator history.
Accept but ignore idempotency_key¶
This would preserve forward API compatibility and let callers start sending keys. It lost because a retry would create a duplicate while appearing safe. Explicit rejection truthfully exposes the missing guarantee.
External broker¶
A broker could provide mature leasing, visibility timeouts, and multi-worker scale. It lost for the current single-operator deployment because the database already supplies durable rows and guarded transactions. Adding a service would increase operations cost before the existing path reaches its limits.
Unguarded SELECT then UPDATE¶
This would simplify SQL. It lost because two workers could read the same queued row and both launch it. The compare-and-swap result is the ownership decision.
Unbounded lease requeue¶
Always returning expired work to queued would maximize retries. It lost because permanently failing or non-idempotent tasks could churn forever. Three attempts makes terminal failure inspectable, while the exact cap remains tunable future policy.
Heartbeat as lease ownership¶
Keeping a worker heartbeat fresh could be treated as proof that all of its tasks remain owned. It lost because one live worker can still hang a single task. Per-row lease expiry is the recovery fact; heartbeat affects only eligibility for new claims.
Capabilities as permissions¶
Using the same token to route and authorize would reduce concepts. It lost because advertisements are worker placement claims, not trusted grants, and an advisory concurrency key cannot secure a machine resource.
Hard affinity filtering¶
Requiring warmed-cache advertisements would improve locality when present. It lost because an otherwise capable worker would leave work queued indefinitely merely because a performance hint was absent. Stable preferential ordering retains the optimization without turning it into availability policy.
In-memory semaphore instead of persisted concurrency key¶
A semaphore would be simpler for one worker process. It lost because queue admission must remain visible across restarts and future workers. The database key is still only advisory, but it prevents known queue claim overlap without pretending to replace the OS resource lock.
Notes¶
An earlier revision of this record transcribed the pre-registry validator behavior; the unified lifecycle policy registry (lionagi/state/lifecycle/policy.py) reconciled the schedule-run and dispatch vocabularies, terminal sets, and edge graphs, and the corrected text above reflects that registry.
The transition module's top docstring names both dispatch and schedule_run, and _ENTITY_TABLES, the vocabulary, and production callers include schedule_run. The executable contract is the code shape recorded above, including its deliberately restricted graph and inactive idempotency-key field.