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.
150 lines
6.0 KiB
Python
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
|