feat(nomos): retry cap, vm: targets, inspect_path, goal supersession, runbooks
Session-review implementation for the three sessions audited in
plans/2026-07-18-session-review-three-sessions.md. v0.7.11 → v0.7.12.
P0.1 — retry cap + investigate-before-retry (cmd/nomos/retrycap.go,
agent.go): after 3 identical failing run calls in a single turn, refuse
to dispatch the call again and return a directive to investigate *why*
(ps/strace/lsof) or surface the blocker. Per-turn scope so a fresh turn
after the operator responds can retry once more. Session 1e9c7691's 20+
identical chown retries (knfsd held a kernel lock on the exported NFS
dir) is the direct motivation.
P0.2 + P1.8 + P2.10 — SOUL.md guidance: hung command is not a failed
command (investigate before retry); ask before proposing a multi-step
migration; multi-goal sessions summarize the arc not just the last goal.
P1.3 — two new runbook entities in seeds/knowledge.yaml:
- nfs-exported-dir-mutation-hang (the knfsd fchownat lock procedure:
killall → exportfs -u → mutate → exportfs -a → verify)
- netbird-mgmt-oidc-race-after-upgrade (docker restart netbird-mgmt
after ~30s for the traefik/authentik OIDC race)
P1.4 — setGoal emits task.superseded event when prior goal is overwritten
by a different goal (store.go, TestSetGoal_SupersededEvent). Session
55927f0a had two set_goal calls with the first silently abandoned.
P1.5 — inspect_path MCP tool: runs mount/df/ls/stat for one path across
up to 8 targets in one parallel call, replacing the 15+ run-call
fact-gathering fan-out sessions 1 and 2 each spent on cross-target path
tracing (tools.go, server.go: inspectPathAcrossTargets, inspectOneTarget).
P1.6 — vm: target support in run via qm guest exec (no more SSH-hop
with nested quoting). Extracted shared resolveProxmoxHostSlug for
LXC + VM, with hosts-relationship fallback when attributes.host is
absent (server.go, tools.go). Session 55927f0a's SSH-hop workarounds
for vm:zimaos are the direct motivation.
Deferred (documented in plan): P1.7 (approval window auto-extend on
timeout) and P2.9 (long-running command PENDING detection) — both
addressed at lower cost by the retry cap. Session 3's poll-after-timeout
pattern already works; the cap protects against the failure mode.
This commit is contained in:
@@ -168,10 +168,10 @@ type toolDef struct {
|
||||
}
|
||||
|
||||
type agentEvent struct {
|
||||
Type string `json:"type"`
|
||||
Data any `json:"data,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Iteration int `json:"iteration,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Data any `json:"data,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Iteration int `json:"iteration,omitempty"`
|
||||
}
|
||||
|
||||
func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(agentEvent)) {
|
||||
@@ -349,6 +349,15 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
messages = append(messages, openai.SystemMessage(systemInject))
|
||||
}
|
||||
|
||||
// Retry cap (P0.1 from plans/2026-07-18-session-review-three-sessions.md):
|
||||
// track failing `run` calls within this turn so an identical command that
|
||||
// keeps failing is refused after maxRunRetries attempts. Without this,
|
||||
// session 1e9c7691 retried the same `chown` ~20 times, each retry piling
|
||||
// up a zombie process on the target (knfsd was holding a kernel lock).
|
||||
// The tracker is per-turn — a fresh turn after the operator responds can
|
||||
// retry once more, so this doesn't permanently block recovery.
|
||||
retries := newRunRetryTracker()
|
||||
|
||||
for i := 0; i < maxIterations; i++ {
|
||||
params := openai.ChatCompletionNewParams{
|
||||
Model: openai.ChatModel(a.model),
|
||||
@@ -467,6 +476,33 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
sawCompleteTask = true
|
||||
}
|
||||
|
||||
// Retry cap: if this `run` call has already failed
|
||||
// maxRunRetries times this turn with the same (target,
|
||||
// command), refuse to dispatch it again. Return a synthetic
|
||||
// tool result directing the agent to investigate *why* the
|
||||
// command hangs instead of retrying. See retrycap.go and
|
||||
// plans/2026-07-18-session-review-three-sessions.md P0.1.
|
||||
if tc.Function.Name == "run" {
|
||||
t, _ := args["target"].(string)
|
||||
c, _ := args["command"].(string)
|
||||
key := runFailureKey(t, c)
|
||||
if n := retries.failures(key); n >= maxRunRetries {
|
||||
directive := runRetryDirective(t, c, n)
|
||||
slog.Warn("nomos: run retry cap hit — refusing dispatch",
|
||||
"target", t, "failures", n, "session", sessionID)
|
||||
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args,
|
||||
tc.Function.Arguments, directive, 0, false, correlationID)
|
||||
emit(agentEvent{
|
||||
Type: "tool_result",
|
||||
Data: map[string]any{"name": tc.Function.Name, "result": directive, "id": tc.ID, "retry_capped": true},
|
||||
SessionID: sessionID,
|
||||
Iteration: i + 1,
|
||||
})
|
||||
messages = append(messages, openai.ToolMessage(directive, tc.ID))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
emit(agentEvent{
|
||||
Type: "tool_use",
|
||||
Data: map[string]any{"name": tc.Function.Name, "args": args, "id": tc.ID},
|
||||
@@ -508,6 +544,22 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
if callErr != nil {
|
||||
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, callErr.Error(), elapsed, false, correlationID)
|
||||
|
||||
// Retry cap: dispatch errors (e.g. MCP client timeout)
|
||||
// count toward the cap too. A command that keeps timing
|
||||
// out at the gateway is exactly the pattern we want to
|
||||
// break — see session 1e9c7691's 20+ identical
|
||||
// `chown` timeouts.
|
||||
if tc.Function.Name == "run" {
|
||||
t, _ := args["target"].(string)
|
||||
c, _ := args["command"].(string)
|
||||
key := runFailureKey(t, c)
|
||||
n := retries.recordFailure(key)
|
||||
if n >= maxRunRetries {
|
||||
slog.Warn("nomos: run failure cap reached — next identical call will be refused",
|
||||
"target", t, "failures", n, "session", sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
emit(agentEvent{
|
||||
Type: "tool_result",
|
||||
Data: map[string]any{"name": tc.Function.Name, "error": callErr.Error(), "id": tc.ID},
|
||||
@@ -551,6 +603,25 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
messages = append(messages, openai.ToolMessage(string(resultJSON), tc.ID))
|
||||
slog.Info("nomos: tool success", "tool", tc.Function.Name, "ms", elapsed)
|
||||
|
||||
// Retry cap: record failures of `run` calls so the cap above
|
||||
// can refuse a repeated identical failure. A "failure" here
|
||||
// means the dispatch errored OR the MCP result text matches
|
||||
// the "run on <target>: ERROR …" signature — both indicate
|
||||
// the command actually ran and failed, not just that it
|
||||
// queued for approval (pending approvals are not failures).
|
||||
// Pass the RAW result text (not JSON-encoded) so the helper's
|
||||
// HasPrefix check sees "run on …" not "\"run on …\"".
|
||||
if isRunFailure(tc.Function.Name, runResultText(result), callErr) {
|
||||
t, _ := args["target"].(string)
|
||||
c, _ := args["command"].(string)
|
||||
key := runFailureKey(t, c)
|
||||
n := retries.recordFailure(key)
|
||||
if n >= maxRunRetries {
|
||||
slog.Warn("nomos: run failure cap reached — next identical call will be refused",
|
||||
"target", t, "failures", n, "session", sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// ask_operator pauses the task: the agent has posed a decision only
|
||||
// the operator can make. End the turn here so it doesn't barrel past
|
||||
// its own question — the answer (panel or chat reply) resumes it.
|
||||
|
||||
Reference in New Issue
Block a user