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:
@@ -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)
|
_, _ = 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 {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
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)
|
createApproval(ctx, pool, id, targetID, "run", string(runParams), riskClass)
|
||||||
|
markSessionAwaitingApproval(ctx, pool, sessionID)
|
||||||
confirmNote := ""
|
confirmNote := ""
|
||||||
if riskClass == policy.RiskDestructive {
|
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\"."
|
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})
|
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
|
// inspectPathAcrossTargets is the bulk fact-gathering helper behind the
|
||||||
// inspect_path MCP tool (plans/2026-07-18-session-review-three-sessions.md
|
// 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
|
// P1.5). For each target slug, it runs a single read-only shell command
|
||||||
|
|||||||
Reference in New Issue
Block a user