summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/api/executions.go25
-rw-r--r--internal/api/server.go1
-rw-r--r--internal/executor/container.go6
-rw-r--r--internal/executor/executor.go67
-rw-r--r--internal/executor/executor_test.go1
-rw-r--r--internal/executor/helpers.go24
-rw-r--r--internal/executor/ratelimit.go6
-rw-r--r--internal/executor/ratelimit_test.go30
-rw-r--r--internal/storage/db.go63
-rw-r--r--web/app.js187
-rw-r--r--web/style.css117
11 files changed, 518 insertions, 9 deletions
diff --git a/internal/api/executions.go b/internal/api/executions.go
index 114425e..29af139 100644
--- a/internal/api/executions.go
+++ b/internal/api/executions.go
@@ -86,6 +86,31 @@ func (s *Server) handleGetExecutionLog(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, content)
}
+// handleGetAgentStatus returns the current status of all agents and recent rate-limit events.
+// GET /api/agents/status?since=<RFC3339>
+func (s *Server) handleGetAgentStatus(w http.ResponseWriter, r *http.Request) {
+ since := time.Now().Add(-24 * time.Hour)
+ if v := r.URL.Query().Get("since"); v != "" {
+ if t, err := time.Parse(time.RFC3339, v); err == nil {
+ since = t
+ }
+ }
+
+ events, err := s.store.ListAgentEvents(since)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ if events == nil {
+ events = []storage.AgentEvent{}
+ }
+
+ writeJSON(w, http.StatusOK, map[string]interface{}{
+ "agents": s.pool.AgentStatuses(),
+ "events": events,
+ })
+}
+
// tailLogFile reads the last n lines from the file at path.
func tailLogFile(path string, n int) (string, error) {
data, err := os.ReadFile(path)
diff --git a/internal/api/server.go b/internal/api/server.go
index e5d0ba6..2d5c308 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -121,6 +121,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /api/tasks/{id}/subtasks", s.handleListSubtasks)
s.mux.HandleFunc("GET /api/tasks/{id}/executions", s.handleListExecutions)
s.mux.HandleFunc("GET /api/executions", s.handleListRecentExecutions)
+ s.mux.HandleFunc("GET /api/agents/status", s.handleGetAgentStatus)
s.mux.HandleFunc("GET /api/executions/{id}", s.handleGetExecution)
s.mux.HandleFunc("GET /api/executions/{id}/log", s.handleGetExecutionLog)
s.mux.HandleFunc("GET /api/tasks/{id}/logs/stream", s.handleStreamTaskLogs)
diff --git a/internal/executor/container.go b/internal/executor/container.go
index c43e201..ba0c03a 100644
--- a/internal/executor/container.go
+++ b/internal/executor/container.go
@@ -290,6 +290,12 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec
}
if waitErr != nil {
+ // Append the tail of stderr so error classifiers (isQuotaExhausted, isRateLimitError)
+ // can inspect agent-specific messages (e.g. Gemini TerminalQuotaError).
+ stderrTail := readFileTail(e.StderrPath, 4096)
+ if stderrTail != "" {
+ return fmt.Errorf("container execution failed: %w\n%s", waitErr, stderrTail)
+ }
return fmt.Errorf("container execution failed: %w", waitErr)
}
if streamErr != nil {
diff --git a/internal/executor/executor.go b/internal/executor/executor.go
index 219a40b..1f2c27d 100644
--- a/internal/executor/executor.go
+++ b/internal/executor/executor.go
@@ -30,6 +30,7 @@ type Store interface {
AppendTaskInteraction(taskID string, interaction task.Interaction) error
UpdateTaskAgent(id string, agent task.AgentConfig) error
UpdateExecutionChangestats(execID string, stats *task.Changestats) error
+ RecordAgentEvent(e storage.AgentEvent) error
}
// LogPather is an optional interface runners can implement to provide the log
@@ -273,16 +274,32 @@ func (p *Pool) handleRunResult(ctx context.Context, t *task.Task, exec *storage.
if isRateLimitError(err) || isQuotaExhausted(err) {
p.mu.Lock()
retryAfter := parseRetryAfter(err.Error())
- if retryAfter == 0 {
- if isQuotaExhausted(err) {
+ reason := "transient"
+ if isQuotaExhausted(err) {
+ reason = "quota"
+ if retryAfter == 0 {
retryAfter = 5 * time.Hour
- } else {
- retryAfter = 1 * time.Minute
}
+ } else if retryAfter == 0 {
+ retryAfter = 1 * time.Minute
}
- p.rateLimited[agentType] = time.Now().Add(retryAfter)
+ until := time.Now().Add(retryAfter)
+ p.rateLimited[agentType] = until
p.logger.Info("agent rate limited", "agent", agentType, "retryAfter", retryAfter, "quotaExhausted", isQuotaExhausted(err))
p.mu.Unlock()
+ go func() {
+ ev := storage.AgentEvent{
+ ID: uuid.New().String(),
+ Agent: agentType,
+ Event: "rate_limited",
+ Timestamp: time.Now(),
+ Until: &until,
+ Reason: reason,
+ }
+ if recErr := p.store.RecordAgentEvent(ev); recErr != nil {
+ p.logger.Warn("failed to record agent event", "error", recErr)
+ }
+ }()
}
var blockedErr *BlockedError
@@ -382,6 +399,34 @@ func (p *Pool) ActiveCount() int {
return p.active
}
+// AgentStatusInfo holds the current state of a single agent.
+type AgentStatusInfo struct {
+ Agent string `json:"agent"`
+ ActiveTasks int `json:"active_tasks"`
+ RateLimited bool `json:"rate_limited"`
+ Until *time.Time `json:"until,omitempty"`
+}
+
+// AgentStatuses returns the current status of all registered agents.
+func (p *Pool) AgentStatuses() []AgentStatusInfo {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ now := time.Now()
+ var out []AgentStatusInfo
+ for agent := range p.runners {
+ info := AgentStatusInfo{
+ Agent: agent,
+ ActiveTasks: p.activePerAgent[agent],
+ }
+ if deadline, ok := p.rateLimited[agent]; ok && now.Before(deadline) {
+ info.RateLimited = true
+ info.Until = &deadline
+ }
+ out = append(out, info)
+ }
+ return out
+}
+
// pickAgent selects the best agent from the given SystemStatus using explicit
// load balancing: prefer the available (non-rate-limited) agent with the fewest
// active tasks. If all agents are rate-limited, fall back to fewest active.
@@ -423,6 +468,18 @@ func (p *Pool) execute(ctx context.Context, t *task.Task) {
activeTasks[agent] = p.activePerAgent[agent]
if deadline, ok := p.rateLimited[agent]; ok && now.After(deadline) {
delete(p.rateLimited, agent)
+ agentName := agent
+ go func() {
+ ev := storage.AgentEvent{
+ ID: uuid.New().String(),
+ Agent: agentName,
+ Event: "available",
+ Timestamp: time.Now(),
+ }
+ if recErr := p.store.RecordAgentEvent(ev); recErr != nil {
+ p.logger.Warn("failed to record agent available event", "error", recErr)
+ }
+ }()
}
rateLimited[agent] = now.Before(p.rateLimited[agent])
}
diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go
index e91d435..91d0137 100644
--- a/internal/executor/executor_test.go
+++ b/internal/executor/executor_test.go
@@ -1056,6 +1056,7 @@ func (m *minimalMockStore) UpdateExecutionChangestats(execID string, stats *task
m.mu.Unlock()
return nil
}
+func (m *minimalMockStore) RecordAgentEvent(_ storage.AgentEvent) error { return nil }
func (m *minimalMockStore) lastStateUpdate() (string, task.State, bool) {
m.mu.Lock()
diff --git a/internal/executor/helpers.go b/internal/executor/helpers.go
index 9e4530b..aee7da0 100644
--- a/internal/executor/helpers.go
+++ b/internal/executor/helpers.go
@@ -143,6 +143,30 @@ func tailFile(path string, n int) string {
return strings.Join(lines, "\n")
}
+// readFileTail returns the last maxBytes bytes of the file at path as a string,
+// or empty string if the file cannot be read. Used to surface agent stderr on failure.
+func readFileTail(path string, maxBytes int64) string {
+ f, err := os.Open(path)
+ if err != nil {
+ return ""
+ }
+ defer f.Close()
+ fi, err := f.Stat()
+ if err != nil {
+ return ""
+ }
+ offset := fi.Size() - maxBytes
+ if offset < 0 {
+ offset = 0
+ }
+ buf := make([]byte, fi.Size()-offset)
+ n, err := f.ReadAt(buf, offset)
+ if err != nil && n == 0 {
+ return ""
+ }
+ return strings.TrimSpace(string(buf[:n]))
+}
+
func gitSafe(args ...string) []string {
return append([]string{"-c", "safe.directory=*"}, args...)
}
diff --git a/internal/executor/ratelimit.go b/internal/executor/ratelimit.go
index 1f38a6d..c916291 100644
--- a/internal/executor/ratelimit.go
+++ b/internal/executor/ratelimit.go
@@ -37,7 +37,11 @@ func isQuotaExhausted(err error) bool {
strings.Contains(msg, "you've hit your limit") ||
strings.Contains(msg, "you have hit your limit") ||
strings.Contains(msg, "rate limit reached (rejected)") ||
- strings.Contains(msg, "status: rejected")
+ strings.Contains(msg, "status: rejected") ||
+ // Gemini CLI quota exhaustion
+ strings.Contains(msg, "terminalquotaerror") ||
+ strings.Contains(msg, "exhausted your daily quota") ||
+ strings.Contains(msg, "generate_content_free_tier_requests")
}
// parseRetryAfter extracts a Retry-After duration from an error message.
diff --git a/internal/executor/ratelimit_test.go b/internal/executor/ratelimit_test.go
index f45216f..1434810 100644
--- a/internal/executor/ratelimit_test.go
+++ b/internal/executor/ratelimit_test.go
@@ -77,6 +77,36 @@ func TestParseRetryAfter_NoRetryInfo(t *testing.T) {
}
}
+// --- isQuotaExhausted tests ---
+
+func TestIsQuotaExhausted_GeminiDailyQuota(t *testing.T) {
+ err := errors.New("container execution failed: exit status 1\nTerminalQuotaError: You have exhausted your daily quota on this model.")
+ if !isQuotaExhausted(err) {
+ t.Error("want true for Gemini TerminalQuotaError, got false")
+ }
+}
+
+func TestIsQuotaExhausted_GeminiExhaustedMessage(t *testing.T) {
+ err := errors.New("container execution failed: exit status 1\nyou have exhausted your daily quota")
+ if !isQuotaExhausted(err) {
+ t.Error("want true for 'exhausted your daily quota', got false")
+ }
+}
+
+func TestIsQuotaExhausted_GeminiQuotaExceeded(t *testing.T) {
+ err := errors.New("container execution failed: exit status 1\nQuota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_requests")
+ if !isQuotaExhausted(err) {
+ t.Error("want true for Gemini free tier quota exceeded, got false")
+ }
+}
+
+func TestIsQuotaExhausted_NotQuota(t *testing.T) {
+ err := errors.New("container execution failed: exit status 1")
+ if isQuotaExhausted(err) {
+ t.Error("want false for generic exit status 1, got true")
+ }
+}
+
// --- runWithBackoff tests ---
func TestRunWithBackoff_SuccessOnFirstTry(t *testing.T) {
diff --git a/internal/storage/db.go b/internal/storage/db.go
index 8bc9864..0d11b4e 100644
--- a/internal/storage/db.go
+++ b/internal/storage/db.go
@@ -99,6 +99,16 @@ func (s *DB) migrate() error {
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)`,
+ `CREATE TABLE IF NOT EXISTS agent_events (
+ id TEXT PRIMARY KEY,
+ agent TEXT NOT NULL,
+ event TEXT NOT NULL,
+ timestamp DATETIME NOT NULL,
+ until DATETIME,
+ reason TEXT
+ )`,
+ `CREATE INDEX IF NOT EXISTS idx_agent_events_agent ON agent_events(agent)`,
+ `CREATE INDEX IF NOT EXISTS idx_agent_events_timestamp ON agent_events(timestamp)`,
}
for _, m := range migrations {
if _, err := s.db.Exec(m); err != nil {
@@ -858,3 +868,56 @@ func (s *DB) SetSetting(key, value string) error {
_, err := s.db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
return err
}
+
+// AgentEvent records a rate-limit state change for an agent.
+type AgentEvent struct {
+ ID string
+ Agent string
+ Event string // "rate_limited" | "available"
+ Timestamp time.Time
+ Until *time.Time // non-nil for "rate_limited" events
+ Reason string // "transient" | "quota"
+}
+
+// RecordAgentEvent inserts an agent rate-limit event.
+func (s *DB) RecordAgentEvent(e AgentEvent) error {
+ _, err := s.db.Exec(
+ `INSERT INTO agent_events (id, agent, event, timestamp, until, reason) VALUES (?, ?, ?, ?, ?, ?)`,
+ e.ID, e.Agent, e.Event, e.Timestamp.UTC(), timeOrNull(e.Until), e.Reason,
+ )
+ return err
+}
+
+// ListAgentEvents returns agent events since the given time, newest first.
+func (s *DB) ListAgentEvents(since time.Time) ([]AgentEvent, error) {
+ rows, err := s.db.Query(
+ `SELECT id, agent, event, timestamp, until, reason FROM agent_events WHERE timestamp >= ? ORDER BY timestamp DESC LIMIT 500`,
+ since.UTC(),
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var events []AgentEvent
+ for rows.Next() {
+ var e AgentEvent
+ var until sql.NullTime
+ var reason sql.NullString
+ if err := rows.Scan(&e.ID, &e.Agent, &e.Event, &e.Timestamp, &until, &reason); err != nil {
+ return nil, err
+ }
+ if until.Valid {
+ e.Until = &until.Time
+ }
+ e.Reason = reason.String
+ events = append(events, e)
+ }
+ return events, rows.Err()
+}
+
+func timeOrNull(t *time.Time) interface{} {
+ if t == nil {
+ return nil
+ }
+ return t.UTC()
+}
diff --git a/web/app.js b/web/app.js
index 6ddb23c..5d99984 100644
--- a/web/app.js
+++ b/web/app.js
@@ -1175,8 +1175,11 @@ function renderActiveTab(allTasks) {
renderAllPanel(allTasks);
break;
case 'stats':
- fetchRecentExecutions(BASE_PATH, fetch)
- .then(execs => renderStatsPanel(allTasks, execs))
+ Promise.all([
+ fetchRecentExecutions(BASE_PATH, fetch),
+ fetch(`${BASE_PATH}/api/agents/status?since=${encodeURIComponent(new Date(Date.now() - 24*60*60*1000).toISOString())}`).then(r => r.ok ? r.json() : { agents: [], events: [] }),
+ ])
+ .then(([execs, agentData]) => renderStatsPanel(allTasks, execs, agentData))
.catch(() => {});
break;
case 'drops':
@@ -2432,7 +2435,7 @@ function formatDurationMs(ms) {
return rm > 0 ? `${h}h ${rm}m` : `${h}h`;
}
-function renderStatsPanel(tasks, executions) {
+function renderStatsPanel(tasks, executions, agentData = { agents: [], events: [] }) {
const panel = document.querySelector('[data-panel="stats"]');
if (!panel) return;
@@ -2566,7 +2569,185 @@ function renderStatsPanel(tasks, executions) {
execSection.appendChild(chartSection);
}
+ // ── Per-execution detail table ─────────────────────────────────────────────
+ if (executions.length > 0) {
+ const tableWrap = document.createElement('div');
+ tableWrap.className = 'stats-exec-table-wrap';
+
+ const tableLabel = document.createElement('p');
+ tableLabel.className = 'stats-bar-chart-label';
+ tableLabel.textContent = 'Recent runs';
+ tableWrap.appendChild(tableLabel);
+
+ const table = document.createElement('table');
+ table.className = 'stats-exec-table';
+ table.innerHTML = '<thead><tr><th>Task</th><th>Outcome</th><th>Cost</th><th>Duration</th><th>Started</th></tr></thead>';
+ const tbody = document.createElement('tbody');
+ for (const ex of executions.slice(0, 20)) {
+ const tr = document.createElement('tr');
+ const durationMs = ex.duration_ms != null ? formatDurationMs(ex.duration_ms) : '—';
+ const cost = ex.cost_usd > 0 ? `$${ex.cost_usd.toFixed(3)}` : '—';
+ const started = ex.started_at ? new Date(ex.started_at).toLocaleTimeString() : '—';
+ const state = (ex.state || '').toUpperCase();
+ tr.innerHTML = `<td class="stats-exec-name">${ex.task_name || ex.task_id}</td><td><span class="state-badge" data-state="${state}">${state.replace(/_/g,' ')}</span></td><td>${cost}</td><td>${durationMs}</td><td>${started}</td>`;
+ tbody.appendChild(tr);
+ }
+ table.appendChild(tbody);
+ tableWrap.appendChild(table);
+ execSection.appendChild(tableWrap);
+ }
+
panel.appendChild(execSection);
+
+ // ── Agent Status ───────────────────────────────────────────────────────────
+ const agentSection = document.createElement('div');
+ agentSection.className = 'stats-section';
+
+ const agentHeading = document.createElement('h2');
+ agentHeading.textContent = 'Agent Status';
+ agentSection.appendChild(agentHeading);
+
+ const agents = agentData.agents || [];
+ const agentEvents = agentData.events || [];
+
+ if (agents.length === 0) {
+ const none = document.createElement('p');
+ none.className = 'task-meta';
+ none.textContent = 'No agents registered.';
+ agentSection.appendChild(none);
+ } else {
+ // Status cards row
+ const cardsRow = document.createElement('div');
+ cardsRow.className = 'stats-agent-cards';
+ for (const ag of agents) {
+ const card = document.createElement('div');
+ card.className = 'stats-agent-card';
+ const statusClass = ag.rate_limited ? 'agent-rate-limited' : 'agent-available';
+ card.classList.add(statusClass);
+
+ const nameEl = document.createElement('span');
+ nameEl.className = 'stats-agent-name';
+ nameEl.textContent = ag.agent;
+
+ const statusEl = document.createElement('span');
+ statusEl.className = 'stats-agent-status';
+ if (ag.rate_limited && ag.until) {
+ const untilDate = new Date(ag.until);
+ const minsLeft = Math.max(0, Math.round((untilDate - Date.now()) / 60000));
+ statusEl.textContent = `Rate limited — ${minsLeft}m remaining`;
+ } else {
+ statusEl.textContent = ag.active_tasks > 0 ? `Active (${ag.active_tasks} running)` : 'Available';
+ }
+
+ card.appendChild(nameEl);
+ card.appendChild(statusEl);
+ cardsRow.appendChild(card);
+ }
+ agentSection.appendChild(cardsRow);
+
+ // Availability timeline (last 24h)
+ const now = Date.now();
+ const windowMs = 24 * 60 * 60 * 1000;
+ const windowStart = now - windowMs;
+
+ const timelineHeading = document.createElement('p');
+ timelineHeading.className = 'stats-bar-chart-label';
+ timelineHeading.textContent = 'Availability last 24h';
+ agentSection.appendChild(timelineHeading);
+
+ // Group events by agent
+ const eventsByAgent = {};
+ for (const ev of agentEvents) {
+ if (!eventsByAgent[ev.agent]) eventsByAgent[ev.agent] = [];
+ eventsByAgent[ev.agent].push(ev);
+ }
+
+ for (const ag of agents) {
+ const evs = (eventsByAgent[ag.agent] || []).slice().sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
+
+ const row = document.createElement('div');
+ row.className = 'stats-timeline-row';
+
+ const label = document.createElement('span');
+ label.className = 'stats-timeline-label';
+ label.textContent = ag.agent;
+ row.appendChild(label);
+
+ const track = document.createElement('div');
+ track.className = 'stats-timeline-track';
+
+ // Build segments: walk events and produce [start, end, state] intervals
+ const segments = [];
+ let cursor = windowStart;
+ // Reconstruct: before first event, assume available unless currently rate-limited with an until before window
+ let inRateLimit = false;
+
+ for (const ev of evs) {
+ const evTime = Math.max(windowStart, new Date(ev.timestamp).getTime());
+ if (evTime > cursor) {
+ segments.push({ start: cursor, end: evTime, limited: inRateLimit });
+ }
+ cursor = evTime;
+ if (ev.event === 'rate_limited') {
+ inRateLimit = true;
+ } else if (ev.event === 'available') {
+ inRateLimit = false;
+ }
+ }
+ // Tail to now
+ if (cursor < now) {
+ // If currently rate limited use current agent state
+ segments.push({ start: cursor, end: now, limited: ag.rate_limited || inRateLimit });
+ }
+
+ for (const seg of segments) {
+ const pct = ((seg.end - seg.start) / windowMs) * 100;
+ if (pct < 0.01) continue;
+ const span = document.createElement('div');
+ span.className = 'stats-timeline-seg';
+ span.classList.add(seg.limited ? 'seg-limited' : 'seg-available');
+ span.style.width = `${pct.toFixed(2)}%`;
+ const mins = Math.round((seg.end - seg.start) / 60000);
+ span.title = `${seg.limited ? 'Rate limited' : 'Available'} — ${mins}m`;
+ track.appendChild(span);
+ }
+
+ row.appendChild(track);
+
+ // Legend labels
+ const timeLabels = document.createElement('div');
+ timeLabels.className = 'stats-timeline-timelabels';
+ timeLabels.innerHTML = '<span>24h ago</span><span>now</span>';
+ row.appendChild(timeLabels);
+
+ agentSection.appendChild(row);
+ }
+
+ // Rate-limit event log
+ if (agentEvents.length > 0) {
+ const evLogLabel = document.createElement('p');
+ evLogLabel.className = 'stats-bar-chart-label';
+ evLogLabel.textContent = 'Rate-limit events (last 24h)';
+ agentSection.appendChild(evLogLabel);
+
+ const evTable = document.createElement('table');
+ evTable.className = 'stats-exec-table';
+ evTable.innerHTML = '<thead><tr><th>Agent</th><th>Event</th><th>Reason</th><th>Until</th><th>Time</th></tr></thead>';
+ const evTbody = document.createElement('tbody');
+ for (const ev of agentEvents.slice(0, 30)) {
+ const tr = document.createElement('tr');
+ const until = ev.until ? new Date(ev.until).toLocaleTimeString() : '—';
+ const ts = new Date(ev.timestamp).toLocaleTimeString();
+ const eventClass = ev.event === 'rate_limited' ? 'state-badge" data-state="FAILED' : 'state-badge" data-state="COMPLETED';
+ tr.innerHTML = `<td>${ev.agent}</td><td><span class="${eventClass}">${ev.event.replace(/_/g,' ')}</span></td><td>${ev.reason || '—'}</td><td>${until}</td><td>${ts}</td>`;
+ evTbody.appendChild(tr);
+ }
+ evTable.appendChild(evTbody);
+ agentSection.appendChild(evTable);
+ }
+ }
+
+ panel.appendChild(agentSection);
}
// ── Web Push Notifications ────────────────────────────────────────────────────
diff --git a/web/style.css b/web/style.css
index 7a3eb71..37f3b61 100644
--- a/web/style.css
+++ b/web/style.css
@@ -1550,3 +1550,120 @@ dialog label select:focus {
width: 80px;
flex-shrink: 0;
}
+
+/* ── Execution detail table ─────────────────────────────────────────────── */
+.stats-exec-table-wrap {
+ margin-top: 1rem;
+}
+
+.stats-exec-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.8rem;
+}
+
+.stats-exec-table th,
+.stats-exec-table td {
+ padding: 0.35rem 0.5rem;
+ text-align: left;
+ border-bottom: 1px solid var(--border);
+}
+
+.stats-exec-table th {
+ color: var(--text-muted);
+ font-weight: 500;
+}
+
+.stats-exec-name {
+ max-width: 220px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ display: block;
+}
+
+/* ── Agent Status ───────────────────────────────────────────────────────── */
+.stats-agent-cards {
+ display: flex;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ margin-bottom: 1.25rem;
+}
+
+.stats-agent-card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ padding: 0.75rem 1rem;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ min-width: 160px;
+}
+
+.stats-agent-card.agent-available {
+ border-color: var(--state-completed);
+ background: color-mix(in srgb, var(--state-completed) 8%, transparent);
+}
+
+.stats-agent-card.agent-rate-limited {
+ border-color: var(--state-failed);
+ background: color-mix(in srgb, var(--state-failed) 8%, transparent);
+}
+
+.stats-agent-name {
+ font-weight: 600;
+ font-size: 0.9rem;
+ text-transform: capitalize;
+}
+
+.stats-agent-status {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+}
+
+/* ── Availability Timeline ─────────────────────────────────────────────── */
+.stats-timeline-row {
+ margin-bottom: 0.75rem;
+}
+
+.stats-timeline-label {
+ display: block;
+ font-size: 0.78rem;
+ color: var(--text-muted);
+ margin-bottom: 0.2rem;
+ text-transform: capitalize;
+}
+
+.stats-timeline-track {
+ display: flex;
+ height: 18px;
+ border-radius: 4px;
+ overflow: hidden;
+ background: var(--bg-card);
+ width: 100%;
+}
+
+.stats-timeline-seg {
+ height: 100%;
+ transition: opacity 0.1s;
+}
+
+.stats-timeline-seg:hover {
+ opacity: 0.8;
+}
+
+.seg-available {
+ background: var(--state-completed);
+}
+
+.seg-limited {
+ background: var(--state-failed);
+}
+
+.stats-timeline-timelabels {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.68rem;
+ color: var(--text-muted);
+ margin-top: 0.15rem;
+}