Archive completed tasks, add cleanup commands, self-documenting dashboard UI
CI / build (push) Has been cancelled

- Archive 79 completed framework-dev tasks from tasks/ -> tasks/complete/
- status.py: add --cleanup-done and --install-cleanup-schedule commands
- Add scripts/automaton-cleanup.sh for periodic task archiving
- Dashboard: rename 'Background' tab -> 'Agent', 'Cleanup' agent -> 'Completed Task Archiver', remove redundant group headers and pill badges, dim inactive agent placeholders
- .rules.md: add Self-Documenting UI Names rule
- New tests: test_cleanup_done.py, expanded test_app.py and test_task.py
This commit is contained in:
Lap Tran
2026-06-24 22:43:33 -04:00
parent e13513faaa
commit 4a2301b077
572 changed files with 856 additions and 101 deletions
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env bash
python3 "/Users/laptran/.automaton/scripts/status.py" --cleanup-done --days 7
+317 -15
View File
@@ -42,6 +42,8 @@ Usage:
python status.py --available --agent {id} List available work
python status.py --can-edit --project {p} [--task {name}] [--file {path}] Check if edits allowed (harness hook)
python status.py --scope-check --task {name} --file {path} Check file scope
python status.py --cleanup-done [--days 7] [--dry-run] Archive complete tasks older than N days into tasks/complete/
python status.py --install-cleanup-schedule [--days 7] [--interval 86400] Install daily cleanup job
"""
from __future__ import annotations
@@ -262,7 +264,7 @@ def _all_task_dirs(project: Optional[str] = None) -> list[tuple[str, Path]]:
if not base.exists():
return tasks
for entry in sorted(base.iterdir()):
if entry.is_dir() and not entry.name.startswith("."):
if entry.is_dir() and not entry.name.startswith(".") and entry.name != "complete":
tasks.append((entry.name, entry))
subtasks = entry / "subtasks"
if subtasks.exists():
@@ -1625,6 +1627,44 @@ LOOP_TICK_SCRIPT_SH = "automaton-loop-tick.sh"
LOOP_TICK_SCRIPT_BAT = "automaton-loop-tick.bat"
LOOP_CONTEXT_FLOOR_KB = 16_000 # mirrors vram_detect.LOOP_MODE_CONTEXT_FLOOR_KB
CLEANUP_DEFAULT_DAYS = 7
CLEANUP_DEFAULT_INTERVAL_SEC = 86400 # daily
CLEANUP_TICK_SCRIPT_SH = "automaton-cleanup.sh"
CLEANUP_TICK_SCRIPT_BAT = "automaton-cleanup.bat"
CLEANUP_LABEL = "cleanup" # launchd label: com.automaton.cleanup
def _completed_task_age_days(task_path: Path) -> Optional[float]:
"""Days since the task last transitioned to its current phase, measured by
the ``.state`` file mtime (the moment ``_write_state`` recorded completion).
Returns None if ``.state`` is missing or unreadable."""
state_file = task_path / ".state"
if not state_file.exists():
return None
try:
mtime = state_file.stat().st_mtime
except OSError:
return None
return (datetime.now(timezone.utc).timestamp() - mtime) / 86400.0
def _launchagents_dir() -> Path:
"""LaunchAgents directory, overridable via AUTOMATON_LAUNCHAGENTS_DIR so
tests can redirect plist writes to a temp dir without touching the host."""
override = os.environ.get("AUTOMATON_LAUNCHAGENTS_DIR")
if override:
return Path(override)
return Path.home() / "Library" / "LaunchAgents"
def _tasks_root_for(task_path: Path) -> Path:
"""Return the tasks directory that should hold the ``complete/`` archive
for this task (handles subtask paths)."""
parent = task_path.parent
if parent.name == "complete":
return parent.parent
return parent
def _loops_dir(project: Optional[str] = None) -> Path:
project_dir = _find_project_dir(project)
@@ -1850,7 +1890,7 @@ def _disable_schedule(name: str, project: Optional[str]) -> None:
loop_path = _loop_dir(name, project)
system = platform.system()
if system == "Darwin":
plist = Path.home() / "Library" / "LaunchAgents" / f"com.automaton.loop.{name}.plist"
plist = _launchagents_dir() / f"com.automaton.loop.{name}.plist"
if plist.exists():
plist.rename(plist.with_suffix(".plist.disabled"))
try:
@@ -1883,14 +1923,11 @@ def _disable_schedule(name: str, project: Optional[str]) -> None:
capture_output=True, timeout=5, check=False)
def _install_cron_block(name: str, loop_path: Path, interval: int) -> int:
"""Install (or re-install) the cron block for a loop. Idempotent: strips
any prior block for the same loop name before appending the new one.
Returns 0 on success, 2 on crontab write failure.
"""
def _install_cron_block_generic(marker: str, stub_path: Path, interval: int) -> int:
"""Install (or re-install) an idempotent cron block delimited by
``# <marker>`` / ``# end <marker>``. Strips any prior block with the same
marker before appending the new one. Returns 0 on success, 2 on failure."""
minutes_interval = max(1, interval // 60)
stub_path = loop_path / LOOP_TICK_SCRIPT_SH
try:
res = subprocess.run(["crontab", "-l"], capture_output=True, text=True,
timeout=5, check=False)
@@ -1900,17 +1937,17 @@ def _install_cron_block(name: str, loop_path: Path, interval: int) -> int:
kept = []
inside = False
for line in existing:
if line.strip() == f"# automaton-loop:{name}":
if line.strip() == f"# {marker}":
inside = True
continue
if inside and line.strip() == f"# end automaton-loop:{name}":
if inside and line.strip() == f"# end {marker}":
inside = False
continue
if not inside:
kept.append(line)
kept.append(f"# automaton-loop:{name}")
kept.append(f"# {marker}")
kept.append(f"*/{minutes_interval} * * * * {stub_path}")
kept.append(f"# end automaton-loop:{name}")
kept.append(f"# end {marker}")
try:
subprocess.run(["crontab", "-"], input="\n".join(kept) + "\n",
capture_output=True, text=True, timeout=5, check=False)
@@ -1920,12 +1957,22 @@ def _install_cron_block(name: str, loop_path: Path, interval: int) -> int:
return 0
def _install_cron_block(name: str, loop_path: Path, interval: int) -> int:
"""Install (or re-install) the cron block for a loop. Idempotent: strips
any prior block for the same loop name before appending the new one.
Returns 0 on success, 2 on crontab write failure.
"""
stub_path = loop_path / LOOP_TICK_SCRIPT_SH
return _install_cron_block_generic(f"automaton-loop:{name}", stub_path, interval)
def _enable_schedule(name: str, project: Optional[str]) -> None:
"""Inverse of _disable_schedule. Called from --resume-loop after pause clear."""
loop_path = _loop_dir(name, project)
system = platform.system()
if system == "Darwin":
plist = Path.home() / "Library" / "LaunchAgents" / f"com.automaton.loop.{name}.plist"
plist = _launchagents_dir() / f"com.automaton.loop.{name}.plist"
disabled = plist.with_suffix(".plist.disabled")
if disabled.exists() and not plist.exists():
disabled.rename(plist)
@@ -2028,7 +2075,7 @@ def cmd_install_schedule(args) -> int:
system = platform.system()
if system == "Darwin":
plist_dir = Path.home() / "Library" / "LaunchAgents"
plist_dir = _launchagents_dir()
plist_dir.mkdir(parents=True, exist_ok=True)
plist_path = plist_dir / f"com.automaton.loop.{name}.plist"
plist = (
@@ -2072,6 +2119,251 @@ def cmd_install_schedule(args) -> int:
return 2
def cmd_cleanup_done(args) -> int:
"""--cleanup-done [--days N] [--dry-run] [--json]
Move tasks whose ``.state`` base phase is ``complete`` and whose completion
age (measured by ``.state`` mtime) is >= --days into the ``tasks/complete/``
archive folder. Archived tasks are excluded from ``--list``/``--audit`` and
from the dashboard. Idempotent. ``--dry-run`` lists candidates without moving.
"""
days = args.days if args.days is not None else CLEANUP_DEFAULT_DAYS
if days < 0:
print("ERROR: --days must be non-negative")
return 2
tasks = _all_task_dirs(args.project)
moved = []
skipped = []
candidates = []
for name, path in tasks:
phase = _read_state(path)
if phase is None:
continue
if _base_phase(phase) != "complete":
continue
age = _completed_task_age_days(path)
if age is None:
continue
if age < days:
continue
candidates.append((name, path, age))
tasks_root = _tasks_root_for(path)
complete_dir = tasks_root / "complete"
dest = complete_dir / name
if dest.exists():
skipped.append({"name": name, "reason": "dest_exists", "age_days": round(age, 2)})
continue
if args.dry_run:
moved.append({"name": name, "dest": str(dest), "age_days": round(age, 2),
"dry_run": True})
continue
complete_dir.mkdir(parents=True, exist_ok=True)
try:
path.rename(dest)
except OSError as exc:
skipped.append({"name": name, "reason": f"rename_failed: {exc}",
"age_days": round(age, 2)})
continue
moved.append({"name": name, "dest": str(dest), "age_days": round(age, 2)})
verb = "would move" if args.dry_run else "moved"
for item in moved:
print(f"[{verb}] {item['name']} (age {item['age_days']}d) -> {item['dest']}")
for item in skipped:
print(f"[skipped] {item['name']} ({item['reason']}, age {item['age_days']}d)")
if args.json_output:
summary = {
"days": days, "dry_run": bool(args.dry_run),
"moved": [m["name"] for m in moved],
"skipped": skipped, "candidates": [c[0] for c in candidates],
}
print(json.dumps(summary))
return 0
def _cleanup_stub_path() -> Path:
return AUTOMATON_DIR / "scripts" / "automaton-cleanup.sh"
def cmd_install_cleanup_schedule(args) -> int:
"""--install-cleanup-schedule [--days N] [--interval SEC]
Install an OS scheduler unit that runs ``--cleanup-done`` periodically
(default daily). Mirrors ``--install-schedule`` but for the cleanup job,
which is framework-wide (project = the framework dir)."""
days = args.days if args.days is not None else CLEANUP_DEFAULT_DAYS
interval = args.interval if args.interval is not None else CLEANUP_DEFAULT_INTERVAL_SEC
project_dir = _find_project_dir(args.project)
status_script = AUTOMATON_DIR / "scripts" / "status.py"
project_arg = f"--project \"{project_dir}\"" if str(project_dir) != str(AUTOMATON_DIR) else ""
if platform.system() == "Windows":
stub_path = AUTOMATON_DIR / "scripts" / "automaton-cleanup.bat"
stub = (
"@echo off\r\n"
f"python \"{status_script}\" --cleanup-done --days {days} {project_arg}\r\n"
)
else:
stub_path = _cleanup_stub_path()
stub = (
"#!/usr/bin/env bash\n"
f"python3 \"{status_script}\" --cleanup-done --days {days}"
+ (f" {project_arg}\n" if project_arg else "\n")
)
stub_path.write_text(stub)
if platform.system() != "Windows":
stub_path.chmod(0o755)
system = platform.system()
if system == "Darwin":
plist_dir = _launchagents_dir()
plist_dir.mkdir(parents=True, exist_ok=True)
plist_path = plist_dir / f"com.automaton.cleanup.plist"
plist = (
f"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
f"<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" "
f"\"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n"
f"<plist version=\"1.0\">\n"
f"<dict>\n"
f" <key>Label</key><string>com.automaton.cleanup</string>\n"
f" <key>ProgramArguments</key>\n"
f" <array>\n"
f" <string>{stub_path}</string>\n"
f" </array>\n"
f" <key>StartInterval</key><integer>{interval}</integer>\n"
f" <key>RunAtLoad</key><false/>\n"
f"</dict>\n"
f"</plist>\n"
)
plist_path.write_text(plist)
print(f"Installed launchd unit: {plist_path}")
print(f"Interval: {interval}s, cleanup threshold: {days}d. Stub: {stub_path}")
return 0
elif system == "Linux":
rc = _install_cron_block_generic("automaton-cleanup", stub_path, interval)
if rc == 0:
print(f"Installed crontab block (every {max(1, interval // 60)} min), "
f"threshold {days}d. Stub: {stub_path}")
return rc
elif system == "Windows":
minutes_interval = max(1, interval // 60)
subprocess.run(
["schtasks", "/create", "/tn", "AutomatonCleanup",
"/tr", str(stub_path), "/sc", "minute",
"/mo", str(minutes_interval), "/f"],
capture_output=True, timeout=10, check=False,
)
print(f"Installed schtasks unit (every {minutes_interval} min). Stub: {stub_path}")
return 0
else:
print(f"ERROR: unsupported platform '{system}' for --install-cleanup-schedule")
return 2
def _parse_launchd_plist(plist_path: Path) -> dict:
"""Minimal plist extractor for ProgramArguments, StartInterval, Label."""
import plistlib
try:
with plist_path.open("rb") as fh:
data = plistlib.load(fh)
return data
except (OSError, ValueError):
return {}
def _read_loop_runtime_state(name: str, project: Optional[str]) -> Optional[dict]:
try:
loop_path = _loop_dir(name, project)
except ValueError:
return None
return _read_state_loop(loop_path)
def _list_scheduled_jobs(project: Optional[str] = None) -> list[dict]:
"""Enumerate installed scheduled jobs (loops + cleanup) for the dashboard
'Background' tab. Each entry: ``{label, kind, status, next_run_seconds,
stub_path, runtime_state?}``. ``status`` ∈ {enabled, disabled, misconfigured}."""
jobs: list[dict] = []
system = platform.system()
def _launchd_label_kind(label: str) -> tuple[str, str]:
# returns (kind, name_or_empty)
if label.startswith("com.automaton.loop."):
return ("loop", label[len("com.automaton.loop."):])
if label in ("com.automaton.cleanup",):
return ("cleanup", "")
if label.startswith("com.automaton.cleanup."):
return ("cleanup", label[len("com.automaton.cleanup."):])
return ("unknown", label)
if system == "Darwin" or system == "Windows":
# On Windows we cannot easily enumerate schtasks entries here; on Darwin
# we scan LaunchAgents. (Windows listing is best-effort left to the
# dashboard surfacing 'unknown' for the tab.)
if system == "Darwin":
plist_dir = _launchagents_dir()
if plist_dir.exists():
for entry in sorted(plist_dir.iterdir()):
if not entry.name.startswith("com.automaton."):
continue
disabled = entry.name.endswith(".plist.disabled")
real = entry.with_suffix("") if disabled else entry
label = real.stem # filename without .plist
data = _parse_launchd_plist(real) if real.exists() else {}
interval = data.get("StartInterval")
stub = (data.get("ProgramArguments") or [None])[0]
kind, sub = _launchd_label_kind(label)
runtime = None
agent_type = None
if kind == "loop" and sub:
s = _read_loop_runtime_state(sub, project)
runtime = s
loop_dir = _loop_dir(sub, project)
cfg = _read_loop_config(loop_dir) or {}
ws = cfg.get("work_source") or {}
agent_type = ws.get("kind", "single")
status = "misconfigured" if not real.exists() else (
"disabled" if disabled else "enabled")
# flag stub pointing at a missing path
if stub and not Path(stub).exists():
status = "misconfigured"
jobs.append({
"label": label, "kind": kind, "name": sub,
"status": status, "next_run_seconds": interval,
"stub_path": stub, "runtime_state": runtime,
"agent_type": agent_type,
})
if system == "Linux":
try:
res = subprocess.run(["crontab", "-l"], capture_output=True, text=True,
timeout=5, check=False)
lines = res.stdout.splitlines() if res.returncode == 0 else []
except (OSError, subprocess.SubprocessError):
lines = []
marker = None
for line in lines:
s = line.strip()
if s.startswith("# automaton-loop:") or s.startswith("#automaton-loop:"):
marker = s.lstrip("#").strip()
name = marker.split("automaton-loop:")[1]
cfg = _read_loop_config(_loop_dir(name, project)) or {}
ws = cfg.get("work_source") or {}
agent_type = ws.get("kind", "single")
jobs.append({
"label": f"automaton-loop:{name}", "kind": "loop", "name": name,
"status": "enabled", "next_run_seconds": None,
"stub_path": None, "runtime_state": _read_loop_runtime_state(name, project),
"agent_type": agent_type,
})
elif s in ("# automaton-cleanup", "#automaton-cleanup"):
jobs.append({
"label": "automaton-cleanup", "kind": "cleanup", "name": "",
"status": "enabled", "next_run_seconds": None,
"stub_path": None, "runtime_state": None,
})
return jobs
def cmd_pause_loop(args) -> int:
name = args.pause_loop
loop_path = _loop_dir(name, args.project)
@@ -2565,6 +2857,12 @@ def main():
parser.add_argument("--can-continue", metavar="NAME", help="Cheap status check: is the loop runnable right now?")
parser.add_argument("--loop-list", action="store_true", help="List all loops and their status")
parser.add_argument("--claim-loop-task", metavar="NAME", help="Claim a task for this loop (cross-loop ownership check)")
parser.add_argument("--cleanup-done", action="store_true",
help="Move complete tasks older than --days into tasks/complete/ (age by .state mtime)")
parser.add_argument("--install-cleanup-schedule", action="store_true",
help="Install an OS scheduler unit that runs --cleanup-done periodically (default daily)")
parser.add_argument("--days", type=int, help="Cleanup age threshold in days (default 7, used with --cleanup-done / --install-cleanup-schedule)")
parser.add_argument("--dry-run", action="store_true", help="With --cleanup-done, list candidates without moving them")
parser.add_argument("--version", action="store_true", help="Print framework version and exit")
args = parser.parse_args()
@@ -2587,6 +2885,10 @@ def main():
return cmd_can_continue(args)
if args.loop_list:
return cmd_loop_list(args)
if args.install_cleanup_schedule:
return cmd_install_cleanup_schedule(args)
if args.cleanup_done:
return cmd_cleanup_done(args)
if args.create_task:
return cmd_create_task(args)
if args.approve: