summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-26 05:10:56 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-26 05:10:56 +0000
commitb009880307298abea11efad92da2cd955afafe99 (patch)
treeabcbc11d9808f4fd2abea5f235b71d1e3bc33107
parent5410069ae36bc5df5d7cc950fce5d2c5a251618a (diff)
fix: expose drained state in agent status API; fix AgentEvent JSON casing
AgentStatusInfo was missing drained field so UI couldn't show drain lock. AgentEvent had no JSON tags so ev.agent/event/timestamp were undefined in the stats timeline. UI now shows "Drain locked" card state with undrain CTA. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--internal/executor/executor.go2
-rw-r--r--internal/storage/db.go12
-rw-r--r--web/app.js6
-rw-r--r--web/style.css5
4 files changed, 17 insertions, 8 deletions
diff --git a/internal/executor/executor.go b/internal/executor/executor.go
index 9052bd7..b8979a1 100644
--- a/internal/executor/executor.go
+++ b/internal/executor/executor.go
@@ -626,6 +626,7 @@ type AgentStatusInfo struct {
ActiveTasks int `json:"active_tasks"`
RateLimited bool `json:"rate_limited"`
Until *time.Time `json:"until,omitempty"`
+ Drained bool `json:"drained"`
}
// AgentStatuses returns the current status of all registered agents.
@@ -638,6 +639,7 @@ func (p *Pool) AgentStatuses() []AgentStatusInfo {
info := AgentStatusInfo{
Agent: agent,
ActiveTasks: p.activePerAgent[agent],
+ Drained: p.drained[agent],
}
if deadline, ok := p.rateLimited[agent]; ok && now.Before(deadline) {
info.RateLimited = true
diff --git a/internal/storage/db.go b/internal/storage/db.go
index 24a6cd3..ee5ee77 100644
--- a/internal/storage/db.go
+++ b/internal/storage/db.go
@@ -1031,12 +1031,12 @@ func (s *DB) SetSetting(key, value string) error {
// 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"
+ ID string `json:"id"`
+ Agent string `json:"agent"`
+ Event string `json:"event"` // "rate_limited" | "available"
+ Timestamp time.Time `json:"timestamp"`
+ Until *time.Time `json:"until,omitempty"` // non-nil for "rate_limited" events
+ Reason string `json:"reason"` // "transient" | "quota"
}
// RecordAgentEvent inserts an agent rate-limit event.
diff --git a/web/app.js b/web/app.js
index 25bdee6..c87c609 100644
--- a/web/app.js
+++ b/web/app.js
@@ -2873,7 +2873,7 @@ function renderStatsPanel(tasks, executions, agentData = { agents: [], events: [
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';
+ const statusClass = ag.drained ? 'agent-drained' : ag.rate_limited ? 'agent-rate-limited' : 'agent-available';
card.classList.add(statusClass);
const nameEl = document.createElement('span');
@@ -2882,7 +2882,9 @@ function renderStatsPanel(tasks, executions, agentData = { agents: [], events: [
const statusEl = document.createElement('span');
statusEl.className = 'stats-agent-status';
- if (ag.rate_limited && ag.until) {
+ if (ag.drained) {
+ statusEl.textContent = 'Drain locked — needs manual undrain';
+ } else 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`;
diff --git a/web/style.css b/web/style.css
index 2bba8dc..1aa6627 100644
--- a/web/style.css
+++ b/web/style.css
@@ -1727,6 +1727,11 @@ dialog label select:focus {
background: color-mix(in srgb, var(--state-failed) 8%, transparent);
}
+.stats-agent-card.agent-drained {
+ border-color: var(--state-cancelled);
+ background: color-mix(in srgb, var(--state-cancelled) 8%, transparent);
+}
+
.stats-agent-name {
font-weight: 600;
font-size: 0.9rem;