Files
tai/tai_pkg/scheduler.py
T
retoor 8df5cf7a45 Split monolithic tai.py into tai_pkg package, keep tai-orig.py as original
tai.py (8643 lines) is now a thin facade over tai_pkg/ (33 feature modules, one class or feature per file). tai-orig.py preserves the pre-split single-file version byte for byte (sha256 verified against snapshot); import tai and ./tai.py behavior unchanged.

Equivalence proof, all run on this tree immediately pre-commit: differential battery tai-orig.py vs facade 3718 checks 0 failures (surface, pure fuzz, tool dispatch, store/audit, release ops, fake-HOME install roundtrip, CLI runs); test_tai.py 261 OK; test_seal.py 16 OK; --version/--help byte-identical; dir(tai) zero removals.

Fix included: opencode wire-protocol schema echo leaked into replies; opencode_parse now scans with JSONDecoder.raw_decode capped at 200 scans (13 OpencodeParseTests). Test count 261 = 237 HEAD + 8 pre-existing uncommitted ToolArgumentsTests (now committed with this change, disclosed) + 13 OpencodeParseTests + 3 EmbeddedStringsTests. Patch-target-only test migration (24 hunks), zero assertion changes. tai_pkg clean-regenerated after mid-verification tamper was found and reverted.
2026-10-10 19:25:02 +02:00

150 lines
6.0 KiB
Python

"""Schedule parsing, display, and the scheduler loop."""
import re
import sqlite3
import threading
from datetime import datetime, timedelta, timezone
from tai_pkg.agents import REGISTRY
from tai_pkg.consts import Consts
from tai_pkg.seal import Seal
from tai_pkg.seal import SealError
from tai_pkg.text import Text
__all__ = ["Scheduler"]
class Scheduler:
"""Scheduler."""
@staticmethod
def parse_schedule_at(raw):
text = str(raw or "").strip()
if not text:
raise ValueError("empty datetime")
if text[-1:] in ("Z", "z"):
text = text[:-1] + "+00:00"
try:
moment = Text._fromisoformat(text)
except ValueError:
raise ValueError("invalid datetime, use ISO like 2026-10-08T09:00")
return moment.astimezone(timezone.utc).isoformat()
@staticmethod
def parse_duration(raw):
match = re.fullmatch(r"(\d+)\s*([smhdw])?", str(raw or "").strip().lower())
if not match:
return None
return int(match.group(1)) * {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}[match.group(2) or "s"]
@staticmethod
def format_delay(seconds):
seconds = int(seconds)
if seconds < 0:
return "overdue"
if seconds < 60:
return "%ds" % seconds
if seconds < 3600:
return "%dm" % (seconds // 60)
if seconds < 86400:
return "%dh" % (seconds // 3600)
return "%dd" % (seconds // 86400)
@staticmethod
def local_display(utc_iso):
try:
return Text._fromisoformat(utc_iso).astimezone().strftime("%Y-%m-%d %H:%M")
except ValueError:
return utc_iso
@staticmethod
def format_schedule_lines(items, now):
if not items:
return "(no schedules)"
lines = []
for item in items:
try:
due_in = (Text._fromisoformat(item["next_run"]) - now).total_seconds()
except ValueError:
due_in = -1
when = "every %s" % Scheduler.format_delay(item["every"]) if item["every"] else "at %s" % Scheduler.local_display(item["next_run"])
marked = " [%s]" % " ".join(item["tags"]) if item["tags"] else ""
lines.append("#%d [%s] %s (%s) %s%s :: %s" % (item["id"], item["status"], when, Scheduler.format_delay(due_in), item["name"] or "(no name)", marked, item["prompt"][:80]))
if item["last"]:
extra = " :: %s" % item["result"][:120] if item["result"] else ""
lines.append(" last: %s%s" % (item["last"], extra))
return "\n".join(lines)
@staticmethod
def note_schedule_result(db_path, row_id, status, result=""):
try:
db = sqlite3.connect(db_path, timeout=30)
db.execute("PRAGMA journal_mode=WAL")
db.execute("PRAGMA busy_timeout=30000")
try:
db.execute("UPDATE schedules SET last_status = ?, last_result = ?, updated = ? WHERE id = ?", (status, result[:2000], datetime.now(timezone.utc).isoformat(), row_id))
db.commit()
finally:
db.close()
except sqlite3.Error:
pass
@staticmethod
def watch_schedule(db_path, row_id, agent_id, timeout):
status, text = REGISTRY.poll_agent(agent_id, wait=timeout + 30)
Scheduler.note_schedule_result(db_path, row_id, status, text)
@staticmethod
def scheduler_tick(config, seal, runner=None):
fired = 0
now = datetime.now(timezone.utc)
db = sqlite3.connect(config.db_path, timeout=30)
db.execute("PRAGMA journal_mode=WAL")
db.execute("PRAGMA busy_timeout=30000")
try:
rows = db.execute("SELECT id, prompt, profile, every_sec, next_run, timeout FROM schedules WHERE status = 'pending' AND next_run <= ? ORDER BY next_run", (now.isoformat(),)).fetchall()
for row_id, prompt, profile, every_sec, next_run, timeout in rows:
stamp = datetime.now(timezone.utc).isoformat()
if every_sec:
try:
claimed_next = Text._fromisoformat(next_run)
except ValueError:
continue
while claimed_next <= datetime.now(timezone.utc):
claimed_next += timedelta(seconds=every_sec)
done = db.execute("UPDATE schedules SET next_run = ?, updated = ? WHERE id = ? AND status = 'pending' AND next_run = ?", (claimed_next.isoformat(), stamp, row_id, next_run))
else:
done = db.execute("UPDATE schedules SET status = 'done', updated = ? WHERE id = ? AND status = 'pending'", (stamp, row_id))
db.commit()
if done.rowcount != 1:
continue
if prompt.startswith(Seal.PREFIX):
try:
prompt = seal.unlock(prompt)
except SealError as exc:
Scheduler.note_schedule_result(config.db_path, row_id, "error: " + Text.short_error(exc))
continue
agent_id = REGISTRY.spawn_agent(prompt, profile, timeout, config, seal, 0, runner)
watcher = threading.Thread(target=Scheduler.watch_schedule, args=(config.db_path, row_id, agent_id, timeout), daemon=True)
watcher.start()
fired += 1
finally:
db.close()
return fired
@staticmethod
def run_scheduler_loop(config, seal, stop_flag):
while not stop_flag.is_set():
try:
Scheduler.scheduler_tick(config, seal)
except Exception as exc:
print("scheduler error: %s" % Text.short_error(exc))
stop_flag.wait(Consts.SCHEDULER_INTERVAL)
@staticmethod
def start_scheduler(config, seal):
stop_flag = threading.Event()
worker = threading.Thread(target=Scheduler.run_scheduler_loop, args=(config, seal, stop_flag), daemon=True)
worker.start()
return stop_flag