feat: event-driven auto-continuation — agent runs an approved plan to completion
The root cause behind "the agent stops at the first error and doesn't recover":
provisioning executions run ASYNCHRONOUSLY (pct_create fires the SSH work in a
goroutine and returns "running" immediately), so the agent's turn ENDS before
the result exists. The agent literally isn't running when the step fails — it
can't react to a failure it never observes. The only thing that fed results
back was the operator typing "continue" after every async step: the human was
the event loop. (In the flagged 18-message session the operator typed
continue/proceed/?? eight times while the agent correctly diagnosed each failure
but couldn't advance a step on its own.)
This makes the system the event loop instead:
- migrations/017: nomos_plan_executions links each gated execution to the chat
session that started it.
- cmd/nomos: after a tool result, any "execution <uuid>" it started is linked
to the session. A background worker (continue.go) polls for those executions
reaching a terminal state and — while the agent has an open assent window (an
approved plan is in flight) — re-invokes the agent with the result
("execution X completed/failed: <result>"), so it proceeds to the next step
or diagnoses+fixes the failure, with no operator tick. Guarded against loops
(mark-continued before running) and bounded by the 30-min window.
- chatWith(): chat() variant that injects the finished-execution note after
replayed history without persisting a fake user turn.
- DecideApproval: approving a step by ANY route (button or chat-assent) now
opens the assent window, so auto-continuation works regardless of how the
operator approved — previously only typing "go ahead" opened it.
- SOUL: the agent is told it will be auto-re-invoked when async steps finish —
don't poll get_execution_status, don't wait for "continue"; end the turn and
keep going step by step until the goal is verified or a genuine blocker.
This is the root fix, not another per-command patch: you can't enumerate every
failure of an unbounded action space, but you can give the agent a loop that
observes each result and adapts — because "do anything" always includes "the
first attempt failed."
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -196,6 +196,83 @@ func (s *store) resolveAgentID(ctx context.Context, slug string) uuid.UUID {
|
||||
return id
|
||||
}
|
||||
|
||||
// linkExecution records that a gated execution was initiated by a chat
|
||||
// session, so the auto-continuation worker can feed its result back to that
|
||||
// session when it finishes. Idempotent — the same execution may appear in
|
||||
// several tool results across a turn.
|
||||
func (s *store) linkExecution(ctx context.Context, execID uuid.UUID, sessionID string) {
|
||||
if s == nil || execID == uuid.Nil || sessionID == "" || sessionID == "ephemeral" {
|
||||
return
|
||||
}
|
||||
s.pool.Exec(ctx, `
|
||||
INSERT INTO nomos_plan_executions (execution_id, session_id)
|
||||
VALUES ($1, $2) ON CONFLICT (execution_id) DO NOTHING`, execID, sessionID)
|
||||
}
|
||||
|
||||
// pendingContinuation is one finished execution whose result hasn't yet been
|
||||
// fed back to its originating session.
|
||||
type pendingContinuation struct {
|
||||
ExecID uuid.UUID
|
||||
SessionID string
|
||||
Status string
|
||||
Result string
|
||||
Action string
|
||||
}
|
||||
|
||||
// pendingContinuations returns executions that have reached a terminal state
|
||||
// but haven't been continued yet — the worker's work list. Bounded so one
|
||||
// tick can't fan out unboundedly.
|
||||
func (s *store) pendingContinuations(ctx context.Context, limit int) []pendingContinuation {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT l.execution_id, l.session_id, e.status,
|
||||
COALESCE(e.result::text, ''), COALESCE(e.action, '')
|
||||
FROM nomos_plan_executions l
|
||||
JOIN executions e ON e.entity_id = l.execution_id
|
||||
WHERE l.continued_at IS NULL
|
||||
AND e.status IN ('completed', 'failed', 'cancelled', 'denied', 'revoked')
|
||||
ORDER BY l.created_at
|
||||
LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []pendingContinuation
|
||||
for rows.Next() {
|
||||
var p pendingContinuation
|
||||
if err := rows.Scan(&p.ExecID, &p.SessionID, &p.Status, &p.Result, &p.Action); err == nil {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// markContinued stamps an execution as fed-back so the worker won't process it
|
||||
// again (prevents an auto-continuation loop).
|
||||
func (s *store) markContinued(ctx context.Context, execID uuid.UUID) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.pool.Exec(ctx, `UPDATE nomos_plan_executions SET continued_at = now() WHERE execution_id = $1`, execID)
|
||||
}
|
||||
|
||||
// assentWindowActive reports whether this agent currently has an open assent
|
||||
// window — the scope gate for auto-continuation. We only auto-continue
|
||||
// executions that are part of an approved plan, never stray one-off actions.
|
||||
func (s *store) assentWindowActive(ctx context.Context, agentID uuid.UUID) bool {
|
||||
if s == nil || agentID == uuid.Nil {
|
||||
return false
|
||||
}
|
||||
var expires time.Time
|
||||
key := "assent_window.agent:" + agentID.String()
|
||||
if err := s.pool.QueryRow(ctx, `SELECT value::timestamptz FROM autonomy_settings WHERE key = $1`, key).Scan(&expires); err != nil {
|
||||
return false
|
||||
}
|
||||
return time.Now().Before(expires)
|
||||
}
|
||||
|
||||
// logActivity records a tool call. agent_id is the agent entity UUID and is
|
||||
// NOT NULL in the schema, so we skip logging when it can't be resolved.
|
||||
// The (nullable) session_id column carries the conversation id.
|
||||
|
||||
Reference in New Issue
Block a user