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>
40 lines
1.6 KiB
Go
40 lines
1.6 KiB
Go
package main
|
|
|
|
import "testing"
|
|
|
|
func TestExtractExecutionIDs(t *testing.T) {
|
|
// Real tool-result phrasings that should yield an execution id.
|
|
pos := map[string]string{
|
|
`"pct_create on host:strong auto-approved via assent window — execution 019f4b19-eafd-74ed-baa6-d24a27b3f52c running."`: "019f4b19-eafd-74ed-baa6-d24a27b3f52c",
|
|
`"run on lxc:caddy requires approval (risk: config_mutation) — execution 019f4af7-7eff-7723-b38c-b540b267f407 queued."`: "019f4af7-7eff-7723-b38c-b540b267f407",
|
|
`"apt_upgrade on host:hubris auto-approved via assent window — execution 019f4b58-c88c-7767-87dd-044608ced913 running."`: "019f4b58-c88c-7767-87dd-044608ced913",
|
|
}
|
|
for in, want := range pos {
|
|
ids := extractExecutionIDs(in)
|
|
if len(ids) != 1 || ids[0].String() != want {
|
|
t.Errorf("extractExecutionIDs(%q) = %v, want [%s]", in, ids, want)
|
|
}
|
|
}
|
|
|
|
// Synchronous auto-run and read-only results carry no "execution <uuid>"
|
|
// phrasing — they've already completed inline and must NOT be linked for
|
|
// continuation.
|
|
neg := []string{
|
|
`"run on host:strong (read_only, auto): 09:30 up 8 days"`,
|
|
`"run on lxc:caddy (config_mutation, auto via assent window): done"`,
|
|
`[{"slug":"lxc:caddy","health":"healthy"}]`,
|
|
`"target not found: lxc:nope"`,
|
|
}
|
|
for _, in := range neg {
|
|
if ids := extractExecutionIDs(in); len(ids) != 0 {
|
|
t.Errorf("extractExecutionIDs(%q) = %v, want none", in, ids)
|
|
}
|
|
}
|
|
|
|
// De-dupes repeated ids in one result.
|
|
dup := `execution 019f4b19-eafd-74ed-baa6-d24a27b3f52c queued ... execution 019f4b19-eafd-74ed-baa6-d24a27b3f52c running`
|
|
if ids := extractExecutionIDs(dup); len(ids) != 1 {
|
|
t.Errorf("expected de-dup to 1 id, got %v", ids)
|
|
}
|
|
}
|