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
+14
View File
@@ -50,3 +50,17 @@
- Write task artifacts atomically to avoid partial reads by the dashboard or other agents
- Prefer writing to a temporary file and renaming it into place
- Avoid leaving empty or malformed artifacts in the task folder
## Self-Documenting UI Names
- All user-facing labels must be self-documenting and free of internal jargon
- A user should understand what something is without knowing the codebase internals
- Terms like "tick", "loop", "state", or "phase transition" are internal terminology — never use them directly in UI elements
- Pick names that describe what something *does*, not what it's called in code
- Past failure (1): "Loop Tick" in the Background tab — user asked "What is loop tick?" because it's scheduler jargon
- Past failure (2): "Loop" — still too vague, user said "I don't know what the loop is"
- Past failure (3): "Loop Agent" — user asked "How do I differentiate between agents with different roles?" because the name described the mechanism (loop) not the purpose
- Past failure (4): "Background" tab — the tab name described the *location* (background), not the *content* (agents). Renamed to "Agent" tab.
- Past failure (5): Group headers + agent type pill badge + icon + name all repeated the same info ("🧹 Cleanup" seen 3 times per card). Removed group headers and pill badges — only icon + name on each card.
- Past failure (6): "Cleanup" agent was vague — renamed to "Completed Task Archiver" which describes what it actually does.
- Rule: Every agent type must have a self-documenting name ending with its function (e.g., "Archiver", "Scanner", "Processor"). If multiple instances exist, list each instance card.
- Agent types with no active instances render as dimmed placeholder cards rather than being hidden, so the user always sees the full roster of available agents.
+1 -1
View File
@@ -13,7 +13,7 @@ DEFAULTS = {
"theme": "default",
}
VALID_VIEWS = {"board", "statistics", "timeline"}
VALID_VIEWS = {"board", "statistics", "timeline", "agent"}
VALID_THEMES = {"default", "dark", "light"}
+2
View File
@@ -648,6 +648,8 @@ def discover_tasks(tasks_dir: Path) -> list[Task]:
continue
if folder_path.name == "subtasks":
continue
if folder_path.name == "complete":
continue
if not _VALID_TASK_NAME_CHARS.issuperset(set(folder_path.name)):
continue
+193 -46
View File
@@ -327,51 +327,196 @@ function renderStats() {
${waveStats.length > 0 ? `<div class="stats-section"><h4>Wave Progress</h4><div class="bar-chart">${waveHtml}</div></div>` : ''}`;
}
function renderTimeline() {
const panel = document.getElementById('timeline-panel');
const filtered = getFilteredTasks();
const AGENT_TYPE_META = {
completed_task_archiver: { icon: '🧹', label: 'Completed Task Archiver', color: '#9aa0a6', desc: 'Archives completed tasks older than a threshold' },
single: { icon: '🎯', label: 'Dedicated Agent', color: '#42a5f5', desc: 'Works on a single assigned task' },
audit: { icon: '🔍', label: 'Audit Scanner', color: '#ffa726', desc: 'Scans for violations and creates remediation tasks' },
backlog: { icon: '📋', label: 'Backlog Processor', color: '#66bb6a', desc: 'Processes items from a backlog queue' },
};
// Group phases by phase group
const phaseGroupStates = {};
PHASE_GROUPS.forEach(group => {
phaseGroupStates[group.id] = {
states: group.states,
label: group.label,
color: group.color,
const AGENT_TYPES = Object.keys(AGENT_TYPE_META);
const AGENT_TYPE_META_FALLBACK = { icon: '🤖', label: 'Agent', color: '#8ab4f8', desc: '' };
async function fetchSchedule() {
try {
const res = await fetch('/api/scheduled');
const data = await res.json();
return data;
} catch (err) {
console.error('Failed to fetch scheduled jobs:', err);
return { jobs: [], available: false };
}
}
function _resolveAgentType(job) {
if (job.kind === 'cleanup' || !job.agent_type) return 'completed_task_archiver';
return job.agent_type;
}
function _scheduledStatusBadge(status) {
const map = {
enabled: { text: 'Enabled', cls: 'ok' },
disabled: { text: 'Disabled', cls: 'muted' },
misconfigured: { text: 'Misconfigured', cls: 'bad' },
};
});
const m = map[status] || { text: status || 'Unknown', cls: 'muted' };
return `<span class="bg-status bg-status-${m.cls}">${m.text}</span>`;
}
// Legend: show phase groups
const legend = PHASE_GROUPS.map(group => {
return `<div class="legend-item"><span class="legend-dot" style="background: ${group.color}"></span><span>${group.label}</span></div>`;
}).join('');
function _renderRuntimeState(rt) {
if (!rt) return '';
const rows = [];
if (rt.status) rows.push(['status', rt.status]);
if (rt.iteration_count !== undefined && rt.iteration_count !== null)
rows.push(['iterations', rt.iteration_count]);
if (rt.halt_reason) rows.push(['halt_reason', rt.halt_reason]);
if (rt.current_task) rows.push(['current_task', rt.current_task]);
if (Array.isArray(rt.score_history) && rt.score_history.length)
rows.push(['scores', rt.score_history.join(', ')]);
if (rt.resumed_count) rows.push(['resumed_count', rt.resumed_count]);
if (!rows.length) return '';
return `<div class="bg-runtime"><h5>Runtime State</h5><dl>${rows.map(r =>
`<dt>${r[0]}</dt><dd>${r[1]}</dd>`).join('')}</dl></div>`;
}
const itemsHtml = filtered.map(task => {
// For each phase group, check if the task has completed any state in that group
const phaseGroupHtml = PHASE_GROUPS.map(group => {
const groupStates = group.states;
const hasArtifact = groupStates.some(s => task.artifacts[s]);
const isComplete = task.state === 'done' || hasArtifact;
const isCurrent = groupStates.includes(task.state) && !hasArtifact;
const cls = isComplete ? 'complete' : isCurrent ? 'in_progress' : 'incomplete';
const icon = isComplete ? '✓' : isCurrent ? '◐' : '○';
return `<div class="timeline-phase ${cls}" title="${group.label}: ${cls}">${icon}</div>`;
}).join('');
const statusClass = task.state === 'done' ? 'done' : task.state === 'blocked' ? 'blocked' : 'in_progress';
const statusIcon = task.state === 'done' ? '✅' : task.state === 'blocked' ? '❌' : '🔄';
const waveHtml = task.sub_tasks.length > 0
? `<div class="timeline-wave"><h5>Sub-tasks (${task.sub_tasks.filter(st => st.has_verdict).length}/${task.sub_tasks.length})</h5>
<div class="timeline-subtask-list">${task.sub_tasks.map(st => {
const stStatus = st.has_verdict && st.verdict_status === 'PASS' ? 'pass' : st.has_verdict && st.verdict_status === 'FAIL' ? 'fail' : 'incomplete';
const stIcon = stStatus === 'pass' ? '✓' : stStatus === 'fail' ? '✗' : '○';
return `<div class="timeline-subtask ${stStatus}"><span>${stIcon}</span><span>${st.name}</span></div>`;
}).join('')}</div></div>`
function _agentRuntimeIndicator(rt) {
if (!rt || !rt.status) return '';
const map = {
running: { icon: '▶', cls: 'ok' },
halted: { icon: '⏹', cls: 'bad' },
paused: { icon: '⏸', cls: 'muted' },
complete: { icon: '✓', cls: 'ok' },
};
const m = map[rt.status] || { icon: '○', cls: 'muted' };
return `<span class="bg-agent-status bg-status-${m.cls}">${m.icon} ${rt.status}</span>`;
}
function _agentJobKey(job) {
return (job.kind || 'unknown') + ':' + (job.name || 'default');
}
function _renderAgentCard(meta, job) {
const key = _agentJobKey(job);
const name = job.name || meta.label;
const interval = job.next_run_seconds
? `Every ${Math.round(job.next_run_seconds / 3600)}h` : '—';
const stub = job.stub_path
? `<div class="bg-row"><dt>stub</dt><dd><code>${job.stub_path}</code></dd></div>`
: '';
return `<div class="timeline-item">
<div class="timeline-item-header"><span class="timeline-item-name">${task.display_name}</span><span class="timeline-item-status">${statusIcon}</span></div>
<div class="timeline-phases">${phaseGroupHtml}</div>${waveHtml}</div>`;
const runtimeHtml = _renderRuntimeState(job.runtime_state);
const agentRuntime = _agentRuntimeIndicator(job.runtime_state);
const statusBorderCls = job.status === 'enabled' ? 'ok' : job.status === 'misconfigured' ? 'bad' : 'muted';
return `<div class="bg-card bg-status-${statusBorderCls}" data-agent-card="${key}">
<div class="bg-card-header">
<div class="bg-card-title-row">
${meta.icon} <strong class="bg-name">${name}</strong>
<span data-agent-field="rt-indicator">${agentRuntime}</span>
<span data-agent-field="status-badge">${_scheduledStatusBadge(job.status)}</span>
</div>
</div>
<div class="bg-body">
<div class="bg-row"><dt>schedule</dt><dd>${interval}</dd></div>
${stub}
</div>
<div data-agent-field="runtime-state">${runtimeHtml}</div>
</div>`;
}
function _renderInactiveCard(meta) {
return `<div class="bg-card bg-status-inactive">
<div class="bg-card-header">
<div class="bg-card-title-row">
${meta.icon} <strong class="bg-name bg-name-inactive">${meta.label}</strong>
<span class="bg-status bg-status-muted">Not Installed</span>
</div>
</div>
${meta.desc ? `<div class="bg-body"><div class="bg-row" style="color: var(--text-muted); font-size: 11px;">${meta.desc}</div></div>` : ''}
</div>`;
}
async function _agentPollerTick() {
const data = await fetchSchedule();
if (!data || !data.jobs) return;
for (const job of data.jobs) {
const key = _agentJobKey(job);
const card = document.querySelector(`[data-agent-card="${key}"]`);
if (!card) continue;
// Update status badge text + class
const badge = card.querySelector('[data-agent-field="status-badge"] .bg-status');
if (badge) {
const map = { enabled: 'Enabled', disabled: 'Disabled', misconfigured: 'Misconfigured' };
badge.textContent = map[job.status] || job.status || 'Unknown';
badge.className = `bg-status bg-status-${
job.status === 'enabled' ? 'ok' : job.status === 'misconfigured' ? 'bad' : 'muted'
}`;
}
// Update runtime indicator
const rtIndicator = card.querySelector('[data-agent-field="rt-indicator"]');
if (rtIndicator) {
rtIndicator.innerHTML = _agentRuntimeIndicator(job.runtime_state);
}
// Update runtime state section
const rtSection = card.querySelector('[data-agent-field="runtime-state"]');
if (rtSection) {
rtSection.innerHTML = _renderRuntimeState(job.runtime_state);
}
// Update card border
const borderCls = job.status === 'enabled' ? 'ok' : job.status === 'misconfigured' ? 'bad' : 'muted';
card.className = card.className.replace(/bg-status-\w+/g, `bg-status-${borderCls}`);
}
}
let _agentPoller = null;
const AGENT_POLL_INTERVAL = 5000;
function _stopAgentPoller() {
if (_agentPoller) {
clearInterval(_agentPoller);
_agentPoller = null;
}
}
function _startAgentPoller() {
_stopAgentPoller();
_agentPoller = setInterval(_agentPollerTick, AGENT_POLL_INTERVAL);
}
async function renderAgentTab() {
const panel = document.getElementById('agent-panel');
if (!panel) return;
// If poller is already running, do a targeted update instead of full rebuild
if (_agentPoller) {
await _agentPollerTick();
return;
}
panel.innerHTML = '<div class="bg-loading">Loading agents…</div>';
const data = await fetchSchedule();
const jobs = data.jobs || [];
if (data.available === false) {
panel.innerHTML = '<div class="bg-empty">No agents available (not running from ~/.automaton).</div>';
return;
}
if (data.error) {
panel.innerHTML = `<div class="bg-error">Error: ${data.error}</div>`;
return;
}
// Group jobs by agent type
const groups = {};
jobs.forEach(j => {
const key = _resolveAgentType(j);
(groups[key] = groups[key] || []).push(j);
});
// Render a card for each agent type — active instances or placeholder
const cardsHtml = AGENT_TYPES.map(key => {
const meta = AGENT_TYPE_META[key];
const typeJobs = groups[key];
if (!typeJobs || typeJobs.length === 0) {
return _renderInactiveCard(meta);
}
return typeJobs.map(job => _renderAgentCard(meta, job)).join('');
}).join('');
panel.innerHTML = `<div class="timeline-header"><h4>Phase Legend:</h4><div class="phase-legend">${legend}</div></div>${itemsHtml}`;
panel.innerHTML = `<div class="bg-grid">${cardsHtml}</div>`;
_startAgentPoller();
}
async function submitReview(taskName, status) {
@@ -414,14 +559,19 @@ function switchView(view) {
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
const target = document.getElementById(`view-${view}`);
if (target) target.classList.add('active');
if (view === 'agent') {
renderAgentTab(); // full render on switch; starts poller
} else {
_stopAgentPoller();
renderCurrentView();
}
}
function renderCurrentView() {
switch (state.currentView) {
case 'board': renderBoard(); break;
case 'stats': renderStats(); break;
case 'timeline': renderTimeline(); break;
// Agent tab runs its own independent poller — not refreshed on global ticks
}
renderHeader();
}
@@ -451,10 +601,10 @@ function attachCardListeners() {
function setupKeyboard() {
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { closeDetail(); document.getElementById('help-modal').classList.remove('open'); document.getElementById('search-input').value = ''; state.searchQuery = ''; renderCurrentView(); }
else if (e.key === ' ' && !e.target.matches('input, textarea, select')) { e.preventDefault(); const views = ['board', 'stats', 'timeline']; const idx = views.indexOf(state.currentView); switchView(views[(idx + 1) % views.length]); }
else if (e.key === ' ' && !e.target.matches('input, textarea, select')) { e.preventDefault(); const views = ['board', 'stats', 'agent']; const idx = views.indexOf(state.currentView); switchView(views[(idx + 1) % views.length]); }
else if (e.key === '1' && !e.target.matches('input, textarea, select')) { switchView('board'); }
else if (e.key === '2' && !e.target.matches('input, textarea, select')) { switchView('stats'); }
else if (e.key === '3' && !e.target.matches('input, textarea, select')) { switchView('timeline'); }
else if (e.key === '3' && !e.target.matches('input, textarea, select')) { switchView('agent'); }
else if (e.key === 't' && !e.target.matches('input, textarea, select')) { switchTheme(); }
else if (e.key === 'r' && !e.target.matches('input, textarea, select')) { refreshData(); }
else if (e.key === 'f' && !e.target.matches('input, textarea, select')) { state.filterVisible = !state.filterVisible; document.getElementById('filter-bar').classList.toggle('visible', state.filterVisible); }
@@ -496,15 +646,12 @@ function applyConfig(config) {
state.theme = config.theme;
document.documentElement.setAttribute('data-theme', config.theme);
}
if (config.default_view && ['board', 'statistics', 'timeline'].includes(config.default_view)) {
if (config.default_view && ['board', 'statistics', 'agent'].includes(config.default_view)) {
switchView(config.default_view === 'statistics' ? 'stats' : config.default_view);
}
if (config.column_width && config.column_width >= 10) {
document.documentElement.style.setProperty('--col-min-width', config.column_width + 'ch');
}
if (config.show_timelines === false) {
document.querySelectorAll('.timeline-phase, .timeline-wave').forEach(el => el.style.display = 'none');
}
}
async function refreshData() {
+5 -5
View File
@@ -17,7 +17,7 @@
<div class="view-tabs">
<button class="tab active" data-view="board">Board</button>
<button class="tab" data-view="stats">Stats</button>
<button class="tab" data-view="timeline">Timeline</button>
<button class="tab" data-view="agent">Agent</button>
</div>
</div>
<div class="header-right">
@@ -80,8 +80,8 @@
<div class="view" id="view-stats">
<div class="stats-panel" id="stats-panel"></div>
</div>
<div class="view" id="view-timeline">
<div class="timeline-panel" id="timeline-panel"></div>
<div class="view" id="view-agent">
<div class="agent-panel" id="agent-panel"></div>
</div>
</main>
@@ -107,10 +107,10 @@
<div class="help-modal-content">
<h2>Keyboard Shortcuts</h2>
<table class="help-table">
<tr><td><kbd>Space</kbd></td><td>Cycle views (Board → Stats → Timeline)</td></tr>
<tr><td><kbd>Space</kbd></td><td>Cycle views (Board → Stats → Agent)</td></tr>
<tr><td><kbd>1</kbd></td><td>Board view</td></tr>
<tr><td><kbd>2</kbd></td><td>Statistics view</td></tr>
<tr><td><kbd>3</kbd></td><td>Timeline view</td></tr>
<tr><td><kbd>3</kbd></td><td>Agent view</td></tr>
<tr><td><kbd>t</kbd></td><td>Cycle themes (default → dark → light)</td></tr>
<tr><td><kbd>r</kbd></td><td>Manual refresh</td></tr>
<tr><td><kbd>f</kbd></td><td>Toggle filter bar</td></tr>
+29 -30
View File
@@ -256,37 +256,36 @@ body {
.bar-track { flex: 1; height: 20px; background: var(--bg-primary); border-radius: 6px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 6px; transition: width 0.4s ease; }
.bar-count { width: 36px; font-size: 12px; color: var(--text-secondary); text-align: right; flex-shrink: 0; font-weight: 600; }
.timeline-panel { max-width: 900px; margin: 0 auto; }
.timeline-header { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
.phase-legend { display: flex; align-items: center; gap: 10px; }
.legend-item { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--text-secondary); }
.legend-dot { width: 8px; height: 8px; border-radius: 50%; }
.legend-dot.complete { background: var(--success); }
.legend-dot.in_progress { background: var(--warning); }
.legend-dot.incomplete { background: var(--text-muted); }
.timeline-item { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 14px 18px; margin-bottom: 10px; }
.timeline-item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.timeline-item-name { font-size: 13px; font-weight: 500; color: var(--text-primary); }
.timeline-item-status { font-size: 13px; }
.timeline-phases { display: flex; gap: 4px; }
.timeline-phase {
width: 28px; height: 28px; border-radius: var(--radius-sm);
display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 600;
.agent-panel { max-width: 1100px; margin: 0 auto; }
.bg-loading, .bg-empty, .bg-error { padding: 24px; text-align: center; color: var(--text-secondary); font-size: 13px; }
.bg-error { color: var(--error); }
.bg-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
.bg-card {
background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-md);
padding: 14px 16px; box-shadow: var(--elevation-1);
}
.timeline-phase.complete { background: var(--success); color: white; }
.timeline-phase.in_progress { background: var(--warning); color: var(--bg-primary); }
.timeline-phase.incomplete { background: var(--bg-primary); color: var(--text-muted); border: 1px solid var(--border-color); }
.timeline-wave { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-color); }
.timeline-wave h5 { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px; }
.timeline-subtask-list { display: flex; flex-wrap: wrap; gap: 6px; }
.timeline-subtask {
display: flex; align-items: center; gap: 4px; padding: 3px 10px;
background: var(--bg-primary); border-radius: var(--radius-sm); font-size: 11px; color: var(--text-secondary);
}
.timeline-subtask.pass { border-left: 2px solid var(--success); }
.timeline-subtask.fail { border-left: 2px solid var(--error); }
.timeline-subtask.incomplete { border-left: 2px solid var(--text-muted); }
.bg-card.bg-status-ok { border-left: 3px solid var(--success); }
.bg-card.bg-status-muted { border-left: 3px solid var(--text-muted); }
.bg-card.bg-status-bad { border-left: 3px solid var(--error); }
.bg-card.bg-status-inactive { border-left: 3px solid var(--text-muted); opacity: 0.55; }
.bg-card-header { margin-bottom: 10px; }
.bg-card-title-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.bg-name { font-size: 14px; color: var(--text-primary); }
.bg-name-inactive { color: var(--text-muted); }
.bg-agent-status { font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 10px; text-transform: uppercase; letter-spacing: 0.04em; }
.bg-status { font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 10px; text-transform: uppercase; letter-spacing: 0.04em; }
.bg-status-ok { background: var(--success-bg); color: var(--success); }
.bg-status-muted { background: rgba(112,117,122,0.15); color: var(--text-muted); }
.bg-status-bad { background: var(--error-bg); color: var(--error); }
.bg-body { font-size: 12px; color: var(--text-secondary); }
.bg-row { display: flex; gap: 8px; margin-bottom: 4px; }
.bg-row dt { color: var(--text-muted); min-width: 60px; font-weight: 500; }
.bg-row dd { color: var(--text-primary); word-break: break-all; }
.bg-runtime { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--border-color); }
.bg-runtime h5 { font-size: 10px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; }
.bg-runtime dl { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; }
.bg-runtime dt { font-size: 11px; color: var(--text-muted); font-weight: 500; }
.bg-runtime dd { font-size: 11px; color: var(--text-primary); word-break: break-all; }
.footer { display: flex; justify-content: space-between; align-items: center; padding: 8px 24px; background: var(--bg-secondary); border-top: 1px solid var(--border-color); font-size: 11px; color: var(--text-muted); flex-shrink: 0; }
.help-modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: 200; align-items: center; justify-content: center; backdrop-filter: blur(4px); }
.help-modal.open { display: flex; animation: fadeIn 0.15s ease; }
+38
View File
@@ -18,6 +18,24 @@ from ..core.scope import detect_scope
from ..core.task import discover_tasks, TaskState, COLUMN_HEADERS
from ..config import DashboardConfig, get_config_path
_status_module = None
def _get_status_module():
"""Lazily import scripts/status.py so the dashboard can call
`_list_scheduled_jobs` without adding scripts/ to sys.path permanently."""
global _status_module
if _status_module is None:
import importlib.util
status_path = Path.home() / ".automaton" / "scripts" / "status.py"
if not status_path.exists():
return None
spec = importlib.util.spec_from_file_location("automaton_status", status_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
_status_module = mod
return _status_module
# Task states for API responses - maps state names to artifact names
TASK_STATE_ARTIFACT = {
"research": "SPEC.md",
@@ -86,6 +104,8 @@ class DashboardHandler(SimpleHTTPRequestHandler):
self._serve_task(task_name)
elif self.path == "/api/review-summary":
self._serve_review_summary()
elif self.path == "/api/scheduled":
self._serve_scheduled()
else:
self._serve_static()
@@ -383,6 +403,24 @@ class DashboardHandler(SimpleHTTPRequestHandler):
counts["pending"] += 1
self._send_json(counts)
def _serve_scheduled(self):
"""Serve the list of installed scheduled jobs (loop ticks + cleanup)."""
status_mod = _get_status_module()
if status_mod is None:
self._send_json({"jobs": [], "available": False})
return
list_fn = getattr(status_mod, "_list_scheduled_jobs", None)
if list_fn is None:
self._send_json({"jobs": [], "available": False})
return
try:
project_arg = str(self.project_root) if self.project_root else None
jobs = list_fn(project=project_arg)
except Exception as exc:
self._send_json({"jobs": [], "available": True, "error": str(exc)})
return
self._send_json({"jobs": jobs, "available": True})
def _send_json(self, data):
self.send_response(200)
self.send_header("Content-Type", "application/json")
+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:

Some files were not shown because too many files have changed in this diff Show More