diff --git a/internal/httpapi/approvals.go b/internal/httpapi/approvals.go index 3de1ed1..bd48c0b 100644 --- a/internal/httpapi/approvals.go +++ b/internal/httpapi/approvals.go @@ -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 } diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 2d479b0..f25ffaf 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -778,6 +778,7 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid. pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class=$2 WHERE entity_id=$1`, id, riskClass) createApproval(ctx, pool, id, targetID, "run", string(runParams), riskClass) + markSessionAwaitingApproval(ctx, pool, sessionID) confirmNote := "" if riskClass == policy.RiskDestructive { confirmNote = " This is classified DESTRUCTIVE — flag that clearly to the operator; it needs explicit confirmation, not just a casual \"go ahead\"." @@ -1065,6 +1066,49 @@ func createApproval(ctx context.Context, pool *db.Pool, execID, targetID uuid.UU map[string]any{"action": action, "params": params, "risk_class": riskClass}) } +// markSessionAwaitingApproval flips a session to awaiting_input the moment +// one of its gated executions is queued for approval — mirrors what +// askOperator does for session_questions (cmd/nomos/store.go's askOperator), +// so a pending execution approval reads as "needs input" to both the +// frontend's Overview board (which only checks agent_sessions.status) and +// the idle-sweep safety net (staleGoalSessions, cmd/nomos/store.go, which +// already excludes awaiting_input from its stale-task sweep). Before this, a +// task blocked on a config_mutation/destructive approval just sat at +// 'executing' — indistinguishable from a task still genuinely working — so +// the idle sweep would eventually nudge it and then auto-close it with +// outcome=partial while the approval was still sitting there undecided. +// The httpapi package's DecideApproval flips the session back out once the +// approval is approved/denied/revoked (internal/httpapi/approvals.go). +// +// No-op for sessionID=="" (a direct MCP call with no nomos session) or a +// session that's already terminal/already awaiting_input — the status IN +// guard makes this safe to call unconditionally from classifyAndGate. +func markSessionAwaitingApproval(ctx context.Context, pool *db.Pool, sessionID string) { + if sessionID == "" || sessionID == "ephemeral" { + return + } + tag, err := pool.Exec(ctx, ` + UPDATE agent_sessions SET status = 'awaiting_input', last_active_at = now() + WHERE id = $1 AND status IN ('active', 'planning', 'executing')`, sessionID) + if err != nil || tag.RowsAffected() == 0 { + return + } + _ = observability.Event(ctx, sqlcgen.New(pool), "task.status", sessionTaskEntity(ctx, pool, sessionID), + "info", "nomos", sessionID, map[string]any{"status": "awaiting_input", "reason": "execution_pending_approval"}) +} + +// sessionTaskEntity resolves a session's own task-entity id, for anchoring +// events to the right node in the graph — mirrors cmd/nomos/store.go's +// (unexported) taskEntityPtr; duplicated here since that's a different +// package's private method. +func sessionTaskEntity(ctx context.Context, pool *db.Pool, sessionID string) *uuid.UUID { + var id uuid.UUID + if err := pool.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, sessionID).Scan(&id); err != nil || id == uuid.Nil { + return nil + } + return &id +} + // inspectPathAcrossTargets is the bulk fact-gathering helper behind the // inspect_path MCP tool (plans/2026-07-18-session-review-three-sessions.md // P1.5). For each target slug, it runs a single read-only shell command