session reliability: reconnect, knowledge loop, retire request_execution
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

Phase 1 — crash recovery: SSE auto-reconnect + backoff, polling gate
during disconnect, connection banner with retry button, empty-response
retry 3x, non-terminal resume on empty response, persistent error cards.

Phase 2/4 — visibility + continuation: custom ExecutionStatus renderer,
approvals extracted on every tool_result (not just done), activity bar
with status/goal, SessionDigest live polling, Continue button.

Phase 3 — cleanup: complete_task auto-cancels orphaned approvals,
deletes assent/destructive window keys, propose_plan marks pending
steps as replaced, plan step seq-order enforcement.

Phase 5 — knowledge loop: list_lxcs state filter (active/destroyed),
SOUL.md unmissable writeback section, propose_plan validation nudge,
complete_task writeback check, upsert_knowledge about array support,
plan generation grouping in frontend, session approval count badge.

Retire request_execution — all mutations now route through run.
Updated SOUL.md, AGENTS.md, CLIENTS.md, skills, and agent system notes.

Migration 020: plan step generation column, audit_log session_id index,
nomos_plan_executions pending-approval index.
This commit is contained in:
2026-07-14 11:03:23 +02:00
parent b446909ea5
commit 60effcb2fe
25 changed files with 1894 additions and 323 deletions

View File

@@ -52,7 +52,7 @@ func (a *agent) runIdleSweepWorker(ctx context.Context) {
return
}
slog.Info("nomos: idle sweep worker started")
ticker := time.NewTicker(5 * time.Minute)
ticker := time.NewTicker(2 * time.Minute)
defer ticker.Stop()
for {
select {
@@ -143,11 +143,24 @@ func (a *agent) processContinuations(ctx context.Context) {
// multiple tasks in flight, one task's open window must never cover a
// pending continuation belonging to a different task.
if !a.store.assentWindowActive(ctx, a.agentID, p.SessionID) {
// A finished one-off execution with no window is left as-is
// (marked continued so we don't re-check it forever) — the
// operator decides what happens next, as today.
a.store.markContinued(ctx, p.ExecID)
continue
// Re-open the assent window if this session is genuinely
// executing (plan was approved, work is in progress) — the
// window may have expired while the execution ran. Don't
// penalize timing: the plan was approved, the work happened,
// the result should flow back.
sesh, seshErr := a.store.getSession(ctx, p.SessionID)
if seshErr == nil && sesh.Goal != "" && (sesh.Status == "executing" || sesh.Status == "planning") {
a.openAssentWindow(ctx, p.SessionID)
slog.Info("nomos: re-opened assent window for continuing session", "session", p.SessionID, "execution", p.ExecID)
} else {
// Genuinely no plan — inject a visible note so the
// operator knows WHY the agent didn't auto-continue.
note := fmt.Sprintf("[System: execution %s finished with status=%s, but the assent window for this session is not active. The agent will not auto-continue. Reply 'continue' or re-approve the plan to resume.]", p.ExecID, p.Status)
body, _ := json.Marshal(map[string]any{"role": "assistant", "text": note, "auto": true})
a.store.saveMessage(context.Background(), p.SessionID, "assistant", body)
a.store.markContinued(ctx, p.ExecID)
continue
}
}
a.store.markContinued(ctx, p.ExecID) // stamp first: a failure here must not cause a re-continue loop
safego.Go("nomos:continue-session:"+p.SessionID, func() { a.continueSession(ctx, p) })
@@ -213,7 +226,7 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) {
// without this outer retry the operator would see nothing at all.
cctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
for attempt := 0; attempt < 2; attempt++ {
for attempt := 0; attempt < 3; attempt++ {
toolCalls, finalText, errText = nil, "", ""
emit := func(ev agentEvent) {
if ev.Type == "tool_use" || ev.Type == "tool_result" {
@@ -241,21 +254,24 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) {
if errText != "" && finalText == "" {
slog.Error("nomos: resume produced no response after retry", "session", sessionID, "error", errText)
// Give the task a real, operator-visible terminal state instead of
// leaving it silently stuck at whatever status it was in (typically
// 'executing' or 'awaiting_input') forever. Before this, a
// permanently-failed resume was invisible beyond a log line — the
// task board just showed a task that never changed, with nothing
// telling the operator it needed attention. Marking it failed here
// doesn't prevent the operator from continuing to work the task via
// a fresh chat message afterward; it just stops the silent hang.
summary := fmt.Sprintf("Auto-resume failed after retrying: %s", errText)
if len(summary) > 200 {
summary = summary[:200] + "…"
}
if cerr := a.store.completeTask(context.Background(), sessionID, "failure", summary); cerr != nil {
slog.Error("nomos: failed to mark task failed after resume gave up", "session", sessionID, "error", cerr)
// Persist a visible system note in the transcript so the
// operator sees what happened, but do NOT auto-complete the
// task — leave it in 'executing' so a follow-up chat message
// can resume it. Before this fix, the task was marked 'failed'
// here, which ended it permanently and required starting over.
resumeFailedNote := fmt.Sprintf("[System: auto-resume failed after retrying: %s. The task is paused — send another message to continue.]", errText)
body, _ := json.Marshal(map[string]any{
"role": "assistant",
"text": resumeFailedNote,
"auto": true,
})
if msgID != uuid.Nil {
a.store.updateMessage(context.Background(), msgID, body)
} else {
// No placeholder was inserted (rare), save directly.
a.store.saveMessage(context.Background(), sessionID, "assistant", body)
}
return // do not call persist() again — already persisted above
}
persist() // final state — same row, updated one last time with the concluding text
}