fix(nomos): sessions blocked on an execution approval now show "Needs input" and never idle-close
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

A config_mutation/destructive run() queued for approval never touched
agent_sessions.status — only ask_operator did that, setting
awaiting_input. So a task blocked on an execution approval was
indistinguishable from one still genuinely working: the frontend's
"Needs input" bucket only checks status===awaiting_input (never lit
up for these), and the idle-sweep safety net only excludes
awaiting_input from its stale-task query, so after ~30 minutes idle
it would nudge the agent and then auto-close the task with
outcome=partial while the approval was still sitting there undecided.

classifyAndGate now flips the session into awaiting_input the moment
an execution is queued (internal/mcp/server.go), and DecideApproval
flips it back to executing once the approval is approved, denied, or
revoked (internal/httpapi/approvals.go) — mirroring askOperator /
answerQuestion's existing pattern for session_questions. Both emit
task.status so the board updates live.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 12:28:52 +02:00
parent ef2956619f
commit ccbf6a8aac
2 changed files with 71 additions and 0 deletions

View File

@@ -229,6 +229,33 @@ func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalReque
_, _ = tx.Exec(ctx, `UPDATE executions SET status = $2, completed_at = now() WHERE approval_id = $1 AND status = 'pending_approval'`, id, status)
}
// If this execution belongs to a nomos session, flip it out of
// awaiting_input — the counterpart to classifyAndGate flipping it IN
// the moment the approval was created (internal/mcp/server.go's
// markSessionAwaitingApproval). Runs for all three decisions (approve/
// deny/revoke): each one is an operator answer to "what do I do about
// this?", same as answerQuestion's unconditional resume-to-executing
// (cmd/nomos/store.go) for a session_questions answer.
var awaitingSessionID string
_ = tx.QueryRow(ctx, `
SELECT pe.session_id FROM nomos_plan_executions pe
JOIN executions ex ON ex.entity_id = pe.execution_id
WHERE ex.approval_id = $1
LIMIT 1`, id).Scan(&awaitingSessionID)
if awaitingSessionID != "" {
if rtag, rerr := tx.Exec(ctx, `
UPDATE agent_sessions SET status = 'executing', last_active_at = now()
WHERE id = $1 AND status = 'awaiting_input'`, awaitingSessionID); rerr == nil && rtag.RowsAffected() > 0 {
var taskEntID *uuid.UUID
var e uuid.UUID
if qerr := tx.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, awaitingSessionID).Scan(&e); qerr == nil && e != uuid.Nil {
taskEntID = &e
}
_ = observability.Event(ctx, q, "task.status", taskEntID, "info", "api", awaitingSessionID,
map[string]any{"status": "executing", "reason": "approval_decided", "decision": status})
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}