fix(nomos): sessions blocked on an execution approval now show "Needs input" and never idle-close
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user