fix(agent+ui): whatsapp session audit — approvals, stuck indicator, stale execs
P1: add docker compose (logs|ps|top|config|images|port|cp) to read-only allowlist. docker compose logs was classified as config_mutation, causing individual approval cards for read-only inspection commands. P2: remove approval entries from activityLog. They were always status=running and never transitioned to done (the derived store builds from tool-call text, not execution status), causing AgentIndicator to latch onto a stale 'Approval: ...' entry and never clear — even after the session completed. P3: remove InlineApproval from Chat.svelte. The green 'Completed in 1s on lxc:...' boxes were noise in the chat stream. Approval UX belongs in the Operations page (already has it via Ops.svelte), not inline in the chat. P4: stale execution cleanup. Startup sweep (mark >1hr non-terminal as cancelled) + 5-min periodic sweep (mark >10min non-terminal as cancelled). 98 orphaned executions accumulated from eval testing (39 running from apt_upgrade:audit timeouts, 19 pending_approval, 3 approved). P5: refuse second config_mutation run when an approval is already pending for the session. Without this, the agent queues N individual approvals before the operator can respond — confirmed in session 20757eb9 (two approval cards for what should have been one plan-level approval). VERSION 0.7.0 → 0.7.1
This commit is contained in:
@@ -102,6 +102,21 @@ func main() {
|
||||
}
|
||||
})
|
||||
|
||||
// Stale execution sweep: cancels non-terminal executions older than
|
||||
// 10 minutes (orphaned by MCP timeouts — see cleanupStaleExecutions).
|
||||
safego.Go("nomos:stale-execution-sweeper", func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
st.cleanupStaleExecutions(ctx, 10*time.Minute)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
|
||||
@@ -41,7 +41,40 @@ func newStore(ctx context.Context, databaseURL string) (*store, error) {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping db: %w", err)
|
||||
}
|
||||
return &store{pool: pool}, nil
|
||||
s := &store{pool: pool}
|
||||
s.cleanupStaleExecutions(ctx, time.Hour)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// cleanupStaleExecutions marks non-terminal executions older than maxAge as
|
||||
// cancelled. Orphaned executions accumulate when the MCP client times out
|
||||
// (30s) before the run handler's error path can mark them failed — the
|
||||
// execution entity is created before the SSH call, and a timeout kills the
|
||||
// connection before the handler runs its UPDATE. Without this, stale
|
||||
// `running` and `pending_approval` executions pile up in the DB and pollute
|
||||
// the Operations page + session rail badges. Called at startup (maxAge=1h)
|
||||
// and periodically (maxAge=10m) by the sweep worker.
|
||||
func (s *store) cleanupStaleExecutions(ctx context.Context, maxAge time.Duration) int {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE executions SET status = 'cancelled',
|
||||
result = jsonb_build_object('message', 'cleaned up — stale non-terminal execution (older than ' || $1 || ')')
|
||||
WHERE status IN ('running', 'pending_approval', 'approved', 'queued')
|
||||
AND entity_id IN (
|
||||
SELECT entity_id FROM entities WHERE created_at < now() - ($2 * interval '1 second')
|
||||
)`,
|
||||
maxAge.String(), maxAge.Seconds())
|
||||
if err != nil {
|
||||
slog.Warn("nomos: stale execution cleanup failed", "error", err)
|
||||
return 0
|
||||
}
|
||||
n := int(tag.RowsAffected())
|
||||
if n > 0 {
|
||||
slog.Info("nomos: cleaned up stale executions", "count", n, "max_age", maxAge.String())
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *store) close() {
|
||||
|
||||
Reference in New Issue
Block a user