S12. Cron Scheduler — Producing Work on a Schedule
S12. Cron Scheduler — Producing Work on a Schedule
The Problem
S11 changes how a command runs after it starts: a long Bash command can run in the background. It does not record when future work should start, and no component keeps checking the current time.
For requests such as "run tests every morning at 9am" or "check CI status every 30 minutes," the user would still have to submit the prompt again at each scheduled time. The Harness needs to store the schedule, put the corresponding prompt into a pending queue when it becomes due, and deliver it to the Agent Loop when the Agent is idle.
The Solution
Suppose the Agent registers this job:
cron: 0 9 * * *
prompt: run testsAt 09:00 local time, the scheduler thread matches the job and puts [Scheduled] run tests into cron_queue. The queue processor waits until the Agent is idle, then starts an Agent Loop turn. The model can then call Bash to run the tests.
The S12 code keeps the five base tools and Hooks from S04, then adds schedule_cron, list_crons, and cancel_cron. It does not include S11 background commands because this chapter delivers a prompt to start work, not the result of a command that is already running.
How It Works
What CronJob stores
@dataclass
class CronJob:
id: str
cron: str
prompt: str
recurring: bool
durable: bool
pending_delivery: bool = False
last_fired: str | None = Nonecron controls when the job becomes due. prompt is the task sent to the Agent. pending_delivery marks a due job that the model has not accepted, while last_fired prevents another enqueue in the same minute.
Five-field cron expressions
minute hour day month weekday
* * * * * every minute
0 9 * * * every day at 09:00
*/5 * * * * every 5 minutes
0 9 * * 1-5 weekdays at 09:00This chapter supports , /N, N, N-M, and N,M,.... Before saving a job, schedule_job() calls validate_cron() and rejects expressions with the wrong number of fields or out-of-range values.
Enqueue when due
The scheduler thread reads local time once per second. When an expression matches and the job has not fired in the current minute, _enqueue_due_job() saves pending_delivery and last_fired before adding the job to the in-memory queue:
def poll_due_jobs(moment: datetime):
minute_marker = moment.strftime("%Y-%m-%d %H:%M")
with cron_lock:
for job in list(scheduled_jobs.values()):
if job.pending_delivery or job.last_fired == minute_marker:
continue
if cron_matches(job.cron, moment):
_enqueue_due_job(job, minute_marker)If persistence fails, _enqueue_due_job() restores the previous state and does not expose a memory-only delivery to the queue processor.
Deliver when the Agent is idle
queue_processor_loop() does not check the time. It checks the queue, and agent_lock prevents a scheduled turn from changing the session while a user turn is running:
def queue_processor_loop(stop_event=RUNTIME_STOP):
while not stop_event.wait(0.2):
if not has_cron_queue() or not agent_lock.acquire(blocking=False):
continue
try:
if has_cron_queue():
run_agent_turn_locked()
finally:
agent_lock.release()The Agent Loop takes due jobs from the queue and appends each one as a new user message:
fired = consume_cron_queue()
for job in fired:
messages.append({"role": "user", "content": f"[Scheduled] {job.prompt}"})If the model call fails, those messages are removed from the current session and the jobs return to the queue. Once the model accepts the call, one-shot jobs are removed and recurring jobs clear pending_delivery until the next match.
Persistence boundary
| Mode | Stored in | After a process restart | |---|---|---| | durable=True | .scheduled_tasks.json | Loaded again | | durable=False | Memory | Gone |
The code updates .scheduled_tasks.json through a temporary file and os.replace(). If the file is corrupt, startup reports the error instead of ignoring it.
Delivery is at least once. If the process exits after the model accepts a prompt but before the acknowledgement reaches disk, the same job may be delivered again after restart.
Runtime boundary
- The scheduler uses the Agent process's local time.
- The scheduler stops when the Agent process exits.
durablepreserves the job definition only. - Restart loads saved jobs but does not replay schedule times missed while the process was down.
- Scheduled turns run in the queue processor thread. A tool call that needs interactive approval is denied instead of competing with the main terminal for input.
- Scheduler and queue processor threads start only in the CLI. Importing
code.pystarts no background thread.
Use crontab, a systemd timer, or an external scheduler when jobs must run while the Agent is closed.
Try It
cd learn-claude-code
python s12_cron_scheduler/code.pyEnter these prompts in order:
Schedule "run date" every 2 minutes and keep it after restart.List all cron jobs.Cancel the cron job you just created.
You can inspect .scheduled_tasks.json and watch for the [Scheduled] run date message when the job becomes due. Keep the Agent process running while testing a minute-level schedule.
What's Next
The scheduler can start an Agent Loop turn at a specified time, but one Agent still handles that turn. When a task requires parallel investigation, changes across multiple modules, and a combined result, the Harness also needs to assign work to multiple Agents and collect what each one produces.
s13 Agent Teams → A Lead assigns tasks, teammates run independently, and results return through inboxes.
S12 — Complete teaching code
#!/usr/bin/env python3
"""
s12_cron_scheduler.py - Cron Scheduler
+--------------------------+ 09:00 +-----------------------+
| 0 9 * * * | --------> | [Scheduled] run tests |
| prompt: "run tests" | +-----------+-----------+
+--------------------------+ |
scheduled_jobs cron_queue | agent idle
v
+-------------+
| Agent Loop |
+-------------+
"""
import glob
import json
import os
import secrets
import subprocess
import threading
from dataclasses import asdict, dataclass
from datetime import datetime
from pathlib import Path
try:
import readline
readline.parse_and_bind("set bind-tty-special-chars off")
readline.parse_and_bind("set input-meta on")
readline.parse_and_bind("set output-meta on")
readline.parse_and_bind("set convert-meta off")
except ImportError:
pass
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
DURABLE_PATH = WORKDIR / ".scheduled_tasks.json"
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
SYSTEM = (
f"You are a coding agent at {WORKDIR}. Use tools to solve tasks. "
"Use schedule_cron for work that should start at a future local time."
)
# -- From s04: tool implementations --
def run_bash(command: str) -> str:
try:
result = subprocess.run(
command,
shell=True,
cwd=WORKDIR,
capture_output=True,
text=True,
timeout=120,
)
output = (result.stdout + result.stderr).strip()
if result.returncode != 0:
return f"Error: command exited with status {result.returncode}\n{output}"
return output[:50000] if output else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def run_read(path: str, limit: int | None = None) -> str:
try:
file_path = (WORKDIR / path).resolve()
lines = file_path.read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
return "\n".join(lines)
except Exception as error:
return f"Error: {error}"
def run_write(path: str, content: str) -> str:
try:
file_path = (WORKDIR / path).resolve()
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content)
return f"Wrote {len(content)} bytes to {path}"
except Exception as error:
return f"Error: {error}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
file_path = (WORKDIR / path).resolve()
text = file_path.read_text()
if old_text not in text:
return f"Error: text not found in {path}"
file_path.write_text(text.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as error:
return f"Error: {error}"
def run_glob(pattern: str) -> str:
try:
matches = [
match
for match in glob.glob(pattern, root_dir=WORKDIR)
if (WORKDIR / match).resolve().is_relative_to(WORKDIR)
]
return "\n".join(matches) if matches else "(no matches)"
except Exception as error:
return f"Error: {error}"
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"},
"limit": {"type": "integer"}},
"required": ["path"]}},
{"name": "write_file", "description": "Write content to a file.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"},
"content": {"type": "string"}},
"required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in a file once.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"},
"old_text": {"type": "string"},
"new_text": {"type": "string"}},
"required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files matching a glob pattern.",
"input_schema": {"type": "object",
"properties": {"pattern": {"type": "string"}},
"required": ["pattern"]}},
]
TOOL_HANDLERS = {
"bash": run_bash,
"read_file": run_read,
"write_file": run_write,
"edit_file": run_edit,
"glob": run_glob,
}
# -- From s04: hooks and permission checks --
HOOKS = {"UserPromptSubmit": [], "PreToolUse": [], "PostToolUse": [], "Stop": []}
def register_hook(event: str, callback):
HOOKS[event].append(callback)
def trigger_hooks(event: str, *args):
for callback in HOOKS[event]:
result = callback(*args)
if result is not None:
return result
return None
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="]
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
def request_permission(block, reason: str) -> str | None:
if threading.current_thread() is not threading.main_thread():
return "Permission denied: scheduled turns cannot request interactive approval"
print(f"\n\033[33m[permission] {reason}\033[0m")
print(f" Tool: {block.name}({block.input})")
choice = input(" Allow? [y/N] ").strip().lower()
if choice not in ("y", "yes"):
return "Permission denied by user"
return None
def permission_hook(block):
if block.name == "bash":
command = block.input.get("command", "")
for pattern in DENY_LIST:
if pattern in command:
print(f"\n\033[31m[blocked] '{pattern}'\033[0m")
return "Permission denied by deny list"
if any(keyword in command for keyword in DESTRUCTIVE):
return request_permission(block, "Potentially destructive command")
if block.name in ("read_file", "write_file", "edit_file"):
path = block.input.get("path", "")
if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):
return request_permission(block, "Access outside workspace")
return None
def log_hook(block):
preview = str(list(block.input.values())[:2])[:60]
print(f"\033[90m[HOOK] {block.name}({preview})\033[0m")
return None
def large_output_hook(block, output):
if len(str(output)) > 100000:
print(
f"\033[33m[HOOK] Large output from {block.name}: "
f"{len(str(output))} chars\033[0m"
)
return None
def context_inject_hook(query: str):
print(f"\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\033[0m")
return None
def summary_hook(messages: list):
tool_count = sum(
1
for message in messages
for block in (
message.get("content")
if isinstance(message.get("content"), list)
else []
)
if isinstance(block, dict) and block.get("type") == "tool_result"
)
print(f"\033[90m[HOOK] Stop: session used {tool_count} tool calls\033[0m")
return None
register_hook("UserPromptSubmit", context_inject_hook)
register_hook("PreToolUse", permission_hook)
register_hook("PreToolUse", log_hook)
register_hook("PostToolUse", large_output_hook)
register_hook("Stop", summary_hook)
# -- New in s12: cron jobs --
@dataclass
class CronJob:
id: str
cron: str
prompt: str
recurring: bool
durable: bool
pending_delivery: bool = False
last_fired: str | None = None
scheduled_jobs: dict[str, CronJob] = {}
cron_queue: list[CronJob] = []
cron_lock = threading.RLock()
def _cron_field_matches(field: str, value: int) -> bool:
if field == "*":
return True
if field.startswith("*/"):
return value % int(field[2:]) == 0
if "," in field:
return any(_cron_field_matches(part.strip(), value)
for part in field.split(","))
if "-" in field:
start, end = field.split("-", 1)
return int(start) <= value <= int(end)
return value == int(field)
def cron_matches(cron_expr: str, moment: datetime) -> bool:
fields = cron_expr.strip().split()
if len(fields) != 5:
return False
minute, hour, day, month, weekday = fields
cron_weekday = (moment.weekday() + 1) % 7
if not (
_cron_field_matches(minute, moment.minute)
and _cron_field_matches(hour, moment.hour)
and _cron_field_matches(month, moment.month)
):
return False
day_matches = _cron_field_matches(day, moment.day)
weekday_matches = _cron_field_matches(weekday, cron_weekday)
if day == "*" and weekday == "*":
return True
if day == "*":
return weekday_matches
if weekday == "*":
return day_matches
return day_matches or weekday_matches
def _validate_cron_field(field: str, minimum: int, maximum: int) -> str | None:
if field == "*":
return None
if field.startswith("*/"):
step = field[2:]
if not step.isdigit() or int(step) <= 0:
return f"Invalid step: {field}"
return None
if "," in field:
for part in field.split(","):
error = _validate_cron_field(part.strip(), minimum, maximum)
if error:
return error
return None
if "-" in field:
start, end = field.split("-", 1)
if not start.isdigit() or not end.isdigit():
return f"Invalid range: {field}"
start_value, end_value = int(start), int(end)
if start_value > end_value:
return f"Range start is greater than end: {field}"
if start_value < minimum or end_value > maximum:
return f"Range {field} is outside [{minimum}-{maximum}]"
return None
if not field.isdigit():
return f"Invalid field: {field}"
value = int(field)
if value < minimum or value > maximum:
return f"Value {value} is outside [{minimum}-{maximum}]"
return None
def validate_cron(cron_expr: str) -> str | None:
fields = cron_expr.strip().split()
if len(fields) != 5:
return f"Expected 5 fields, got {len(fields)}"
field_rules = [
("minute", 0, 59),
("hour", 0, 23),
("day-of-month", 1, 31),
("month", 1, 12),
("day-of-week", 0, 6),
]
for field, (name, minimum, maximum) in zip(fields, field_rules):
error = _validate_cron_field(field, minimum, maximum)
if error:
return f"{name}: {error}"
return None
def save_durable_jobs():
with cron_lock:
payload = [
asdict(job)
for job in scheduled_jobs.values()
if job.durable
]
temporary = DURABLE_PATH.with_name(
f"{DURABLE_PATH.name}.{os.getpid()}.{threading.get_ident()}.tmp"
)
try:
temporary.write_text(json.dumps(payload, indent=2))
os.replace(temporary, DURABLE_PATH)
finally:
temporary.unlink(missing_ok=True)
def load_durable_jobs():
if not DURABLE_PATH.exists():
return
try:
payload = json.loads(DURABLE_PATH.read_text())
if not isinstance(payload, list):
raise ValueError("expected a JSON list")
except (OSError, json.JSONDecodeError, ValueError) as error:
print(f" [cron] could not load {DURABLE_PATH.name}: {error}")
return
loaded = 0
with cron_lock:
for item in payload:
try:
job = CronJob(**item)
error = validate_cron(job.cron)
if error:
raise ValueError(error)
if not job.id.startswith("cron_"):
raise ValueError("invalid job ID")
if not job.prompt.strip():
raise ValueError("prompt cannot be empty")
except (TypeError, ValueError) as error:
print(f" [cron] skipped invalid saved job: {error}")
continue
scheduled_jobs[job.id] = job
if job.pending_delivery:
cron_queue.append(job)
loaded += 1
if loaded:
print(f" [cron] loaded {loaded} durable job(s)")
def new_cron_id() -> str:
for _ in range(100):
job_id = f"cron_{secrets.token_hex(4)}"
if job_id not in scheduled_jobs:
return job_id
raise RuntimeError("Could not allocate a cron job ID")
def schedule_job(cron: str, prompt: str, recurring: bool = True,
durable: bool = True) -> CronJob | str:
error = validate_cron(cron)
if error:
return error
if not prompt.strip():
return "Prompt cannot be empty"
with cron_lock:
job = CronJob(
id=new_cron_id(),
cron=cron,
prompt=prompt,
recurring=recurring,
durable=durable,
)
scheduled_jobs[job.id] = job
try:
if durable:
save_durable_jobs()
except Exception:
scheduled_jobs.pop(job.id, None)
raise
print(f" [cron] scheduled {job.id}: {cron} -> {prompt[:60]}")
return job
def cancel_job(job_id: str) -> str:
with cron_lock:
job = scheduled_jobs.get(job_id)
if job is None:
return f"Job {job_id} not found"
previous_queue = list(cron_queue)
scheduled_jobs.pop(job_id)
cron_queue[:] = [queued for queued in cron_queue if queued.id != job_id]
try:
if job.durable:
save_durable_jobs()
except Exception:
scheduled_jobs[job_id] = job
cron_queue[:] = previous_queue
raise
print(f" [cron] cancelled {job_id}")
return f"Cancelled {job_id}"
def _enqueue_due_job(job: CronJob, minute_marker: str | None = None):
old_pending = job.pending_delivery
old_last_fired = job.last_fired
job.pending_delivery = True
if minute_marker is not None:
job.last_fired = minute_marker
try:
if job.durable:
save_durable_jobs()
except Exception:
job.pending_delivery = old_pending
job.last_fired = old_last_fired
raise
cron_queue.append(job)
def poll_due_jobs(moment: datetime):
minute_marker = moment.strftime("%Y-%m-%d %H:%M")
with cron_lock:
for job in list(scheduled_jobs.values()):
try:
if job.pending_delivery or job.last_fired == minute_marker:
continue
if cron_matches(job.cron, moment):
_enqueue_due_job(job, minute_marker)
print(f" [cron] due {job.id}: {job.prompt[:60]}")
except Exception as error:
print(f" [cron] could not enqueue {job.id}: {error}")
def consume_cron_queue() -> list[CronJob]:
with cron_lock:
jobs = list(cron_queue)
cron_queue.clear()
return jobs
def acknowledge_cron_jobs(jobs: list[CronJob]):
changed: list[tuple[CronJob, bool]] = []
removed: list[CronJob] = []
with cron_lock:
for delivered in jobs:
current = scheduled_jobs.get(delivered.id)
if current is None:
continue
changed.append((current, current.pending_delivery))
if current.recurring:
current.pending_delivery = False
else:
removed.append(current)
scheduled_jobs.pop(current.id)
try:
if any(job.durable for job, _ in changed):
save_durable_jobs()
except Exception:
for job in removed:
scheduled_jobs[job.id] = job
for job, pending in changed:
job.pending_delivery = pending
queued_ids = {job.id for job in cron_queue}
for job, _ in changed:
if job.id not in queued_ids:
cron_queue.append(job)
raise
def restore_cron_jobs(jobs: list[CronJob]):
with cron_lock:
queued_ids = {job.id for job in cron_queue}
for delivered in jobs:
current = scheduled_jobs.get(delivered.id)
if current is None:
continue
current.pending_delivery = True
if current.id not in queued_ids:
cron_queue.append(current)
queued_ids.add(current.id)
def has_cron_queue() -> bool:
with cron_lock:
return bool(cron_queue)
def run_schedule_cron(cron: str, prompt: str, recurring: bool = True,
durable: bool = True) -> str:
result = schedule_job(cron, prompt, recurring, durable)
if isinstance(result, str):
return f"Error: {result}"
return f"Scheduled {result.id}: {cron} -> {prompt}"
def run_list_crons() -> str:
with cron_lock:
jobs = list(scheduled_jobs.values())
if not jobs:
return "No cron jobs."
lines = []
for job in jobs:
frequency = "recurring" if job.recurring else "one-shot"
storage = "durable" if job.durable else "session"
lines.append(
f"{job.id}: {job.cron} -> {job.prompt[:60]} "
f"[{frequency}, {storage}]"
)
return "\n".join(lines)
def run_cancel_cron(job_id: str) -> str:
return cancel_job(job_id)
TOOLS.extend([
{"name": "schedule_cron",
"description": "Schedule a prompt with a 5-field cron expression.",
"input_schema": {"type": "object",
"properties": {
"cron": {"type": "string"},
"prompt": {"type": "string"},
"recurring": {"type": "boolean"},
"durable": {"type": "boolean"}},
"required": ["cron", "prompt"]}},
{"name": "list_crons", "description": "List scheduled cron jobs.",
"input_schema": {"type": "object", "properties": {}, "required": []}},
{"name": "cancel_cron", "description": "Cancel a cron job by ID.",
"input_schema": {"type": "object",
"properties": {"job_id": {"type": "string"}},
"required": ["job_id"]}},
])
TOOL_HANDLERS.update({
"schedule_cron": run_schedule_cron,
"list_crons": run_list_crons,
"cancel_cron": run_cancel_cron,
})
def execute_tool(block) -> str:
blocked = trigger_hooks("PreToolUse", block)
if blocked is not None:
return str(blocked)
handler = TOOL_HANDLERS.get(block.name)
try:
output = handler(**block.input) if handler else f"Unknown: {block.name}"
except Exception as error:
output = f"Error: {error}"
trigger_hooks("PostToolUse", block, output)
return str(output)
# -- Scheduler and agent loop --
RUNTIME_STOP = threading.Event()
runtime_threads: list[threading.Thread] = []
runtime_started = False
runtime_lock = threading.Lock()
agent_lock = threading.Lock()
session_history: list = []
def cron_scheduler_loop(stop_event: threading.Event = RUNTIME_STOP):
while not stop_event.wait(1.0):
poll_due_jobs(datetime.now())
def agent_loop(messages: list, context: dict | None = None):
fired = consume_cron_queue()
scheduled_start = len(messages)
for job in fired:
messages.append({"role": "user", "content": f"[Scheduled] {job.prompt}"})
print(f" [cron] delivered {job.id}: {job.prompt[:60]}")
waiting_for_ack = list(fired)
while True:
try:
response = client.messages.create(
model=MODEL,
system=SYSTEM,
messages=messages,
tools=TOOLS,
max_tokens=8000,
)
except Exception as error:
if waiting_for_ack:
del messages[scheduled_start:]
restore_cron_jobs(waiting_for_ack)
print(f" [error] {type(error).__name__}: {error}")
return context
messages.append({"role": "assistant", "content": response.content})
if waiting_for_ack:
try:
acknowledge_cron_jobs(waiting_for_ack)
except Exception as error:
print(f" [cron] acknowledgement failed: {error}")
waiting_for_ack = []
if response.stop_reason != "tool_use":
force = trigger_hooks("Stop", messages)
if force:
messages.append({"role": "user", "content": force})
continue
return context
results = []
for block in response.content:
if block.type != "tool_use":
continue
output = execute_tool(block)
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
def print_latest_assistant_text(messages: list):
for message in reversed(messages):
if message.get("role") != "assistant":
continue
content = message.get("content", "")
if isinstance(content, str):
print(content)
else:
for block in content:
if getattr(block, "type", None) == "text":
print(block.text)
elif isinstance(block, dict) and block.get("type") == "text":
print(block.get("text", ""))
return
def run_agent_turn_locked(user_query: str | None = None):
if user_query is not None:
trigger_hooks("UserPromptSubmit", user_query)
session_history.append({"role": "user", "content": user_query})
agent_loop(session_history)
print_latest_assistant_text(session_history)
print()
def queue_processor_loop(stop_event: threading.Event = RUNTIME_STOP):
while not stop_event.wait(0.2):
if not has_cron_queue() or not agent_lock.acquire(blocking=False):
continue
try:
if has_cron_queue():
run_agent_turn_locked()
finally:
agent_lock.release()
def start_runtime_threads():
global runtime_started
with runtime_lock:
if runtime_started:
return
load_durable_jobs()
RUNTIME_STOP.clear()
runtime_threads.extend([
threading.Thread(
target=cron_scheduler_loop,
name=cron-scheduler,
daemon=True,
),
threading.Thread(
target=queue_processor_loop,
name=cron-queue-processor,
daemon=True,
),
])
for thread in runtime_threads:
thread.start()
runtime_started = True
def stop_runtime_threads():
global runtime_started
with runtime_lock:
if not runtime_started:
return
RUNTIME_STOP.set()
for thread in runtime_threads:
thread.join(timeout=1)
runtime_threads.clear()
runtime_started = False
if __name__ == "__main__":
print("s12: Cron Scheduler - run prompts on a local schedule")
print("Enter a question, press Enter to send. Type q to quit.\n")
start_runtime_threads()
try:
while True:
try:
query = input("\033[36ms12 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
with agent_lock:
run_agent_turn_locked(query)
finally:
stop_runtime_threads()
Try it — Cron Scheduler scenario
A daemon loop watches durable cron definitions, queues due jobs, and lets the agent process them.