fix(agent+ui): whatsapp session audit — approvals, stuck indicator, stale execs
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

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:
2026-07-15 22:19:30 +02:00
parent a9b3f844b2
commit 7ef8446825
9 changed files with 320 additions and 28 deletions

View File

@@ -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)

View File

@@ -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() {