Archive completed tasks, add cleanup commands, self-documenting dashboard UI
CI / build (push) Has been cancelled
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:
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env bash
|
||||
python3 "/Users/laptran/.automaton/scripts/status.py" --cleanup-done --days 7
|
||||
+317
-15
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user