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

@@ -1 +1 @@
0.7.0 0.7.1

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 := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200) w.WriteHeader(200)

View File

@@ -41,7 +41,40 @@ func newStore(ctx context.Context, databaseURL string) (*store, error) {
pool.Close() pool.Close()
return nil, fmt.Errorf("ping db: %w", err) 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() { func (s *store) close() {

View File

@@ -1295,6 +1295,26 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
return textResult(fmt.Sprintf("An identical command is already queued for approval on %s — execution %s. Wait for the operator, don't re-request.", targetSlug, existingID)) return textResult(fmt.Sprintf("An identical command is already queued for approval on %s — execution %s. Wait for the operator, don't re-request.", targetSlug, existingID))
} }
// P5: if this is a config_mutation command, no assent window is active,
// and there's already a pending_approval for this session, refuse —
// don't queue a second approval. The operator should see ONE approval
// (the plan), approve it (which opens the assent window), and then all
// subsequent config_mutation commands auto-run. Without this gate, the
// agent queues N individual approvals before the operator can respond,
// flooding the chat with approval cards — confirmed in session 20757eb9
// (WhatsApp bridge: two approvals for what should have been one plan).
if riskClass == policy.RiskConfigMutation && sessionID != "" && !assentWindowActive(ctx, pool, agentID, sessionID) {
var anyPending int
pool.QueryRow(ctx, `
SELECT COUNT(*) FROM nomos_plan_executions pe
JOIN executions ex ON ex.entity_id = pe.execution_id
WHERE pe.session_id = $1 AND ex.status = 'pending_approval'`,
sessionID).Scan(&anyPending)
if anyPending > 0 {
return textResult("An approval is already pending for this plan. Present the plan and its steps to the operator, then STOP and wait for their approval (\"approved\", \"yes\", \"go ahead\"). Do not call run again until the operator responds — after approval, all config_mutation commands will auto-run.")
}
}
id, _ := uuid.NewV7() id, _ := uuid.NewV7()
correlationID := uuid.New().String() correlationID := uuid.New().String()
execName := "run on " + targetSlug + " (" + id.String() + ")" execName := "run on " + targetSlug + " (" + id.String() + ")"

View File

@@ -74,6 +74,7 @@ var readOnlyLeadPattern = regexp.MustCompile(
`systemctl\s+(status|is-active|is-enabled|is-failed|list-units|list-unit-files|list-timers|show)\b|` + `systemctl\s+(status|is-active|is-enabled|is-failed|list-units|list-unit-files|list-timers|show)\b|` +
`timedatectl|hostnamectl|systemd-analyze|` + `timedatectl|hostnamectl|systemd-analyze|` +
`docker\s+(ps|images|inspect|logs|version|info|stats)|` + `docker\s+(ps|images|inspect|logs|version|info|stats)|` +
`docker\s+compose\s+(logs|ps|top|config|images|port|cp)\b|` +
`pct\s+(status|config|list)|qm\s+(status|config|list)|pvesh\s+get|` + `pct\s+(status|config|list)|qm\s+(status|config|list)|pvesh\s+get|` +
`rclone\s+(ls|lsl|md5sum|check|cryptcheck)\b|` + `rclone\s+(ls|lsl|md5sum|check|cryptcheck)\b|` +
`git\s+(status|log|diff|show|branch|remote)|` + `git\s+(status|log|diff|show|branch|remote)|` +

View File

@@ -27,6 +27,11 @@ func TestClassifyCommand_ReadOnly(t *testing.T) {
"hostnamectl", "hostnamectl",
"systemd-analyze blame", "systemd-analyze blame",
"rclone lsl proton:library-backup", "rclone lsl proton:library-backup",
// docker compose read-only subcommands (F1 fix).
"docker compose logs --tail=100",
"docker compose ps",
"docker compose top",
"docker compose config",
} }
for _, c := range cases { for _, c := range cases {
if got := ClassifyCommand(c, ""); got != RiskReadOnly { if got := ClassifyCommand(c, ""); got != RiskReadOnly {

View File

@@ -0,0 +1,235 @@
# 2026-07-15 — WhatsApp session audit: approvals, stuck indicator, stale executions
**Status:** Done — 2026-07-15. P1P5 implemented; build + tests + vet pass.
Session audit of the WhatsApp bridge
investigation (`20757eb9`) following the plan-first + iteration deploy.
Four issues reported by the operator, each traced to a distinct root cause.
## Session under audit
| Session | Goal | Messages | Outcome |
|---|---|---|---|
| `20757eb9` | Diagnose and fix the Matrix WhatsApp bridge (stopped delivering messages) | 3 (1 user, 2 assistant) | success — image was 3 months old, `docker compose pull` fixed it |
The agent correctly diagnosed a 405 protocol-version rejection, pulled the
latest image, and the bridge reconnected. The structural fixes all worked
(plan-first gate refused the first `run` before `propose_plan`, plan was
proposed, writeback happened, `complete_task` closed the loop). But the UX
around the execution was wrong.
---
## Findings
### F1 — Two approvals instead of one (BLOCKER, misclassification + prose)
**What happened:** The agent proposed a 6-step plan and immediately started
executing steps 1-2 (read-only inspection: `docker compose logs`,
`docker compose ps`). Both `run` calls were classified as `config_mutation`
and queued for individual approval. The operator saw two approval cards
instead of one plan-level approval.
**Root cause A — `docker compose` subcommands missing from read-only
allowlist.** `internal/policy/command.go:69-78` has `docker\s+(ps|images|
inspect|logs|version|info|stats)` but NOT `docker compose` subcommands.
`docker compose logs` and `docker compose ps` are read-only inspection
verbs that the classifier escalates to `config_mutation`. This is the same
class of bug as the `find` omission from the Proton Drive audit (P4).
**Root cause B — agent didn't stop after proposing.** The `propose_plan`
return text says "If any step is config_mutation/destructive, STOP and wait
for operator approval." The agent ignored this — it started executing in the
same turn. This is prose enforcement, not structural. Combined with root
cause A, the read-only steps generated approval cards.
### F2 — Agent indicator stuck at "Approval: Check WhatsApp bridge..." (FRICTION)
**What happened:** After the session completed (status=`done`), the agent
indicator at the bottom of the chat stayed stuck showing "Approval: Check
WhatsApp bridge container status on elementsynapse" with a spinner.
**Root cause:** `web/src/lib/stores/activity.ts:131-150` creates an
`approval` entry with `status: 'running'` whenever a tool result contains
"requires approval." This entry is **never transitioned to `done`** — the
derived store rebuilds from messages on every poll, but the approval entry
is always set to `status: 'running'` (line 146). The `AgentIndicator`
(`Chat.svelte:153`) shows the first `running` entry from `$activityLog`,
so it latches onto the stale approval entry and never clears.
There's no mechanism to check whether the execution has actually completed
— the activity store derives purely from tool-call text, not execution
status from the API.
### F3 — Green "Completed in 1s on lxc:..." boxes in chat (COSMETIC)
**What happened:** Green success cards (`InlineApproval.svelte:154-163`)
rendered inline in the chat message stream for each completed execution.
**Root cause:** `Chat.svelte:144-146` renders `<InlineApproval>` inside
each message bubble when `msg.pendingApprovals.length > 0`. The
`InlineApproval` component shows the full approval lifecycle (pending →
running → completed/failed) inline in the chat. The operator considers
this noise — execution results belong in the activity sidebar, not in the
chat stream. The chat should show the agent's text + tool call summary, not
approval UX.
### F4 — 98 stale non-terminal executions (COSMETIC, ops debt)
**What happened:** 98 executions in non-terminal states
(39 `running`, 19 `pending_approval`, 3 `approved`, 37 more `running`
orphaned) from eval testing.
**Breakdown:**
- 39 `running` executions from `apt_upgrade:audit` actions — these were
created by the MCP `run` handler, then the MCP call timed out (30s
context deadline), leaving the execution in `running` state forever.
Not linked to any session (orphaned).
- 19 `pending_approval` — config_mutation `run` calls that were queued for
approval but never approved/denied (eval sessions that completed without
resolving them).
- 3 `approved` — approved but never executed (the execution dispatch
failed or timed out).
**Root cause:** No startup or periodic cleanup of stale executions. The
`run` handler creates an execution entity BEFORE attempting SSH — if the
SSH call times out or the MCP connection drops, the execution is
orphaned in `running` state. `completeTask` cancels pending approvals for
its own session, but nothing cleans up orphaned executions or old
sessions' leftovers.
---
## Improvement plan
### P1 — Add `docker compose` read-only subcommands to allowlist
**Fix:** `internal/policy/command.go` — add to `readOnlyLeadPattern`:
`docker\s+compose\s+(logs|ps|top|config|images|port|cp)\b`.
Do NOT add `docker compose exec` or `docker compose run` — these execute
arbitrary commands and must stay gated.
Add unit test case: `"docker compose logs --tail=100"``read_only`.
**Severity:** blocker (directly caused the two-approval issue).
**Files:** `internal/policy/command.go:69-78`, `command_test.go`.
### P2 — Fix stuck agent indicator
**Fix:** `web/src/lib/stores/activity.ts:131-150` — the approval entries
are always `status: 'running'` and never transition. Two options:
**Option A (recommended): Remove approval entries from activityLog
entirely.** They're already rendered as `InlineApproval` cards in the chat
(or, after P3, in the Operations page). Duplicating them in the activity
log causes the stuck indicator — the activity store derives from tool-call
text, not execution status, so it can't know when the execution completed.
Removing them means `AgentIndicator` won't latch onto stale approval
entries.
**Option B: Fetch execution status.** When building approval entries,
call `getExecution(execId)` to check the real status. This is more
correct but adds async API calls to a synchronous derived store — would
require restructuring the store to be async or pre-fetching statuses.
**Decision:** Option A — simpler, eliminates the bug class. If the
operator wants approval status in the activity sidebar, that's a separate
feature that should use the REST `/approvals` endpoint (already used by
`context.ts` and `Ops.svelte`), not text parsing of tool results.
**Severity:** friction.
**Files:** `web/src/lib/stores/activity.ts:131-150`.
### P3 — Move InlineApproval out of chat, into activity sidebar
**Fix:** Remove `<InlineApproval>` from `Chat.svelte:144-146`. The chat
stream shows only the agent's text + `ToolCallGroup` (the compact tool
counter). Approval UX (Approve/Deny buttons, completed/failed cards)
moves to the Operations page (already has it via `Ops.svelte`) and/or a
dedicated approval panel in the sidebar.
The `pendingApprovals` field on `ChatMessage` can stay (for counting
badges in the session rail), but the inline rendering is removed.
**Migration:** `InlineApproval.svelte` is not deleted — it's reused in
the Operations page or a new sidebar approval panel. The component's
props (`PendingApproval[]`) and API (`getExecution`, `decideApproval`)
stay the same.
**Severity:** cosmetic.
**Files:** `web/src/pages/Chat.svelte:144-146`.
### P4 — Stale execution cleanup
**Fix:** Add a startup cleanup + periodic sweep in nomos:
1. **Startup cleanup:** on `nomos serve` boot, mark all non-terminal
executions older than 1 hour as `cancelled` with result
`{"message": "cleaned up at startup — stale from prior session"}`.
This handles the 98 stale executions from eval testing.
2. **Run handler fix:** in `internal/mcp/server.go` `classifyAndGate`,
the execution entity is created (line 1284-1293) BEFORE the SSH call.
If the SSH call fails or times out, the execution is already marked
`running` but never transitions. The error paths (lines 1315-1321,
1333-1340, etc.) already mark `failed` — but the MCP client timeout
(30s, in `agent.go`'s `client.callTool`) kills the connection before
the error path runs. Fix: move the execution entity creation to AFTER
the SSH call succeeds, or add a `running``failed` timeout sweep.
3. **Periodic sweep:** add a 5-minute timer (like the continuation
worker) that marks executions in `running` state for more than 10
minutes as `failed` with result `{"message": "execution timed out"}`.
This catches orphaned executions that the run handler didn't clean up.
**Severity:** cosmetic (ops debt, not a functional bug).
**Files:** `cmd/nomos/main.go` (startup), `internal/mcp/server.go`
(run handler), `cmd/nomos/continue.go` (periodic sweep).
### P5 — Enforce "stop after proposing a plan with config_mutation steps"
**Fix:** This is the structural enforcement gap behind the "agent didn't
stop after proposing" behavior. The `propose_plan` return text says "STOP
and wait" but nothing enforces it. Two options:
**Option A (structural):** In the `run` handler (`classifyAndGate`), after
the plan-first gate, check if the plan has any `config_mutation` steps
AND no assent window is active. If so, refuse the `run` with "This plan
has config_mutation steps — wait for operator approval before executing."
This would force the agent to stop after proposing, but it would also
block the legitimate case where the operator already said "go ahead" (the
assent window would be active, so the check would pass).
**Option B (prose):** Strengthen the `propose_plan` return text and
SOUL.md to be more directive. This is what we've been doing — it works
for strong models but not for weaker ones.
**Decision:** Option A — structural enforcement. The check is simple
(assent window active?) and catches the exact case where the agent
proposes a plan with config_mutation steps and starts executing without
approval. Read-only steps still auto-execute (they don't need the
assent window).
**Severity:** friction (prevents the two-approval UX, but doesn't block
functionality).
**Files:** `internal/mcp/server.go` `classifyAndGate`, `nomos/SOUL.md`.
---
## Sequencing
- **P1** (docker compose allowlist) is independent — ship immediately.
- **P2** (stuck indicator) + **P3** (inline approval removal) ship
together — both touch the chat rendering surface.
- **P4** (stale cleanup) is independent — ship anytime.
- **P5** (config_mutation enforcement) depends on P1 (the allowlist fix
reduces false config_mutation classifications) — ship after P1.
## Verification
- `go test ./internal/policy/...` — new `docker compose logs` read-only
test case.
- Manual: replay the WhatsApp bridge prompt, confirm a single plan-level
approval (not two), no stuck indicator, no green boxes in chat.
- `docker exec oikos-postgres-1 psql -U oikos oikos -c "SELECT COUNT(*)
FROM executions WHERE status NOT IN ('completed','failed','cancelled')"`
→ 0 after the startup cleanup runs.

View File

@@ -8,8 +8,7 @@ export interface ActivityEntry {
id: string id: string
type: 'goal' | 'plan' | 'step_running' | 'step_done' | 'step_failed' | type: 'goal' | 'plan' | 'step_running' | 'step_done' | 'step_failed' |
'tool_running' | 'tool_done' | 'tool_error' | 'tool_running' | 'tool_done' | 'tool_error' |
'knowledge' | 'complete' | 'question' | 'error' | 'knowledge' | 'complete' | 'question' | 'error'
'approval'
description: string description: string
detail?: string detail?: string
timestamp: number timestamp: number
@@ -128,26 +127,14 @@ export const activityLog = derived([messages, planSteps, currentTask], ([$msgs,
}) })
} }
// Approvals — detect from tool results containing 'requires approval' // Note: approval entries were removed from activityLog (2026-07-15).
for (let mi = 0; mi < $msgs.length; mi++) { // They were always `status: 'running'` and never transitioned to 'done'
for (const t of $msgs[mi].tools) { // (the derived store builds from tool-call text, not execution status),
if (t.type !== 'tool_result') continue // which caused the AgentIndicator to latch onto a stale "Approval: ..."
const text = typeof t.result === 'string' ? t.result : JSON.stringify(t.result ?? '') // entry and never clear — even after the session completed. Approvals
if (text.includes('requires approval')) { // are tracked via the REST /approvals endpoint (context.ts, Ops.svelte)
const m = text.match(/execution\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i) // and rendered as InlineApproval cards in the chat (or Ops page), not
const execId = m ? m[1] : '' // in the activity log.
const target = (t.args as any)?.target ?? ''
const purpose = (t.args as any)?.purpose ?? ''
entries.push({
id: execId || `approval_${mi}`,
type: 'approval',
description: purpose ? `Approval: ${purpose.slice(0, 60)}` : `Approval required${target ? ` for ${target}` : ''}`,
timestamp: now - ($msgs.length - mi) * 1000,
status: 'running'
})
}
}
}
// Sort oldest first // Sort oldest first
entries.sort((a, b) => a.timestamp - b.timestamp) entries.sort((a, b) => a.timestamp - b.timestamp)

View File

@@ -3,7 +3,6 @@
import { activityLog } from '$lib/stores/activity' import { activityLog } from '$lib/stores/activity'
import SessionRail from '$lib/components/SessionRail.svelte' import SessionRail from '$lib/components/SessionRail.svelte'
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte' import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
import InlineApproval from '$lib/components/InlineApproval.svelte'
import AgentIndicator from '$lib/components/AgentIndicator.svelte' import AgentIndicator from '$lib/components/AgentIndicator.svelte'
import { Button } from '$lib/components/ui/button' import { Button } from '$lib/components/ui/button'
import { Textarea } from '$lib/components/ui/textarea' import { Textarea } from '$lib/components/ui/textarea'
@@ -141,9 +140,6 @@
{@html render(msg.text)} {@html render(msg.text)}
</div> </div>
{/if} {/if}
{#if msg.pendingApprovals.length > 0}
<InlineApproval approvals={msg.pendingApprovals} />
{/if}
</div> </div>
{/if} {/if}
</div> </div>