feat: add /deploy-plugins webhook route + deploy-plugins script
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

This commit is contained in:
2026-08-17 00:05:55 +02:00
parent eca81ae9af
commit 22fe1526ca
42 changed files with 342 additions and 8156 deletions

View File

@@ -102,7 +102,7 @@ elsewhere; regenerate from `internal/mcp/` when tools change):
update_check(check_id, enabled) — enable or disable a health check update_check(check_id, enabled) — enable or disable a health check
list_checks(entity_slug, enabled) — list health checks with verdict, probe kind list_checks(entity_slug, enabled) — list health checks with verdict, probe kind
list_executions(entity_slug, status, limit=25) — cursor-paginated execution history list_executions(entity_slug, status, limit=25) — cursor-paginated execution history
list_entity_sessions(entity_slug) — active Nomos sessions linked to an entity list_entity_sessions(entity_slug) — active agent sessions linked to an entity
get_dashboard_summary() — fleet overview: counts, health, signals, approvals get_dashboard_summary() — fleet overview: counts, health, signals, approvals
list_approvals(status, entity_slug, limit) — list pending/recent approvals; filter by status (pending, approved, denied) or entity list_approvals(status, entity_slug, limit) — list pending/recent approvals; filter by status (pending, approved, denied) or entity
decide_approval(approval_id, decision) — approve or deny a pending execution; calls the same API endpoint as the Approve button in the UI decide_approval(approval_id, decision) — approve or deny a pending execution; calls the same API endpoint as the Approve button in the UI
@@ -186,10 +186,10 @@ The DB is the truth. The old wiki files were archived at `archive/knowledge/`
## 6. Acting on the homelab ## 6. Acting on the homelab
- **Read state**: use MCP tools. Nomos (the AI agent) is the primary - **Read state**: use MCP tools. dsh (DeepSeek Harness, the TypeScript agent
operator interface — it routes to the MCP tool list in §3 for sidecar that replaced Nomos) is the primary operator interface — it routes
observe/orient/decide/act. to the MCP tool list in §3 for observe/orient/decide/act.
- **Actions** (restart, logs, apt, pct exec, or anything else): Nomos calls - **Actions** (restart, logs, apt, pct exec, or anything else): dsh calls
`run` (the general execution primitive) via MCP. `reversible_low`/read-only actions execute `run` (the general execution primitive) via MCP. `reversible_low`/read-only actions execute
immediately; `config_mutation` and `destructive` actions are queued for immediately; `config_mutation` and `destructive` actions are queued for
operator approval via the App button in the control-room UI or via operator approval via the App button in the control-room UI or via

View File

@@ -14,7 +14,7 @@ repo, see [.agents/dev/CONTRIBUTING.md](.agents/dev/CONTRIBUTING.md).
there); this repo is backend-only since the hexagonal refactor Phase 1 there); this repo is backend-only since the hexagonal refactor Phase 1
```bash ```bash
# Start dependencies (Postgres + Redis). api/nomos require a shared bearer # Start dependencies (Postgres + Redis). api requires a shared bearer
# token — no dev-open bypass — so set one even for local dev. # token — no dev-open bypass — so set one even for local dev.
OIKOS_MCP_BEARER_TOKEN=dev-token docker compose --profile dev up -d OIKOS_MCP_BEARER_TOKEN=dev-token docker compose --profile dev up -d
@@ -46,7 +46,6 @@ before the split need one manual reinstall.
``` ```
cmd/oikos/ Single-binary entry point cmd/oikos/ Single-binary entry point
cmd/nomos/ Nomos MCP client gateway
cmd/webhook/ Gitea deploy-webhook receiver (push-to-deploy on mac-mini) cmd/webhook/ Gitea deploy-webhook receiver (push-to-deploy on mac-mini)
internal/ All Go packages internal/ All Go packages
core/ Hexagon core ([ADR 0016](docs/adr/0016-hexagonal-ports-adapters.md)): core/ Hexagon core ([ADR 0016](docs/adr/0016-hexagonal-ports-adapters.md)):
@@ -79,7 +78,6 @@ compose/ Dockerfiles + Caddy config
scripts/ Deploy, watchdog, rollback scripts/ Deploy, watchdog, rollback
checks/ Host health-check scripts run over SSH by the scheduler checks/ Host health-check scripts run over SSH by the scheduler
tools/ Client auto-setup scripts (checks) tools/ Client auto-setup scripts (checks)
nomos/ Nomos config, persona, skills
.agents/ Agent instruction files + skills .agents/ Agent instruction files + skills
plans/ Design documents plans/ Design documents
docs/adr/ Architecture decision records docs/adr/ Architecture decision records

View File

@@ -1,10 +1,12 @@
# Oikos # Oikos
Agentic homelab operating system written in Go. Single binary (`cmd/oikos`), Agentic homelab operating system written in Go. Single binary (`cmd/oikos`),
Docker-deployed on mac-mini, with a standalone Nomos MCP agent gateway Docker-deployed on mac-mini. Manages the **hubris** Proxmox homelab
(`cmd/nomos`). Manages the **hubris** Proxmox homelab autonomously — observes autonomously — observes state, classifies actions against policy, executes
state, classifies actions against policy, executes approved procedures over SSH, approved procedures over SSH, learns from outcomes, and escalates when
learns from outcomes, and escalates when uncertain. uncertain. The agent runtime is [dsh](https://github.com/deepseek-ai/deepseek-harness)
(DeepSeek Harness, TypeScript sidecar) — it replaced the Nomos gateway and
talks to this backend over MCP.
**For agents running on enrolled clients:** start with [AGENTS.md](AGENTS.md). **For agents running on enrolled clients:** start with [AGENTS.md](AGENTS.md).
**For client machines:** see [CLIENTS.md](CLIENTS.md). **For client machines:** see [CLIENTS.md](CLIENTS.md).
@@ -13,12 +15,12 @@ learns from outcomes, and escalates when uncertain.
## Quick start ## Quick start
```bash ```bash
# Dev stack (postgres + api + scheduler). The api/nomos # Dev stack (postgres + api + scheduler). The api
# services need a shared token — every route requires a real bearer # service needs a token — every route requires a real bearer
# credential, there's no dev-open bypass. # credential, there's no dev-open bypass.
OIKOS_MCP_BEARER_TOKEN=dev-token docker compose --profile dev up -d OIKOS_MCP_BEARER_TOKEN=dev-token docker compose --profile dev up -d
# Full stack (adds Nomos agent gateway) # Full stack (adds execution worker + Infisical)
OIKOS_MCP_BEARER_TOKEN=dev-token docker compose --profile full up -d OIKOS_MCP_BEARER_TOKEN=dev-token docker compose --profile full up -d
# Build standalone binary # Build standalone binary
@@ -39,8 +41,8 @@ OIKOS_DATABASE_URL="postgres://oikos:oikos_dev@localhost:5432/oikos?sslmode=disa
┌──────────────────────────────────┐ ┌──────────────────────────────────┐
│ mac-mini (Docker) │ │ mac-mini (Docker) │
│ │ │ │
Workstation ─── │ nomos (8092) ──MCP── api (8090) │ Workstation ─── │ dsh (3080) ──MCP── api (8090) │
(mesh) │ MCP gateway REST + MCP │ (mesh) │ agent runtime REST + MCP │
│ │ │ │
│ scheduler ─── postgres │ │ scheduler ─── postgres │
│ (observe) (Timescale) │ │ (observe) (Timescale) │
@@ -51,7 +53,7 @@ OIKOS_DATABASE_URL="postgres://oikos:oikos_dev@localhost:5432/oikos?sslmode=disa
|-----------|------|------| |-----------|------|------|
| `oikos api` | 8090 | REST API + MCP server (tool list in [AGENTS.md §3](AGENTS.md#3-the-mcp-server)) | | `oikos api` | 8090 | REST API + MCP server (tool list in [AGENTS.md §3](AGENTS.md#3-the-mcp-server)) |
| `oikos scheduler` | — | Probe runner, signal lifecycle, metrics | | `oikos scheduler` | — | Probe runner, signal lifecycle, metrics |
| `nomos serve` | 8092 | MCP client gateway, query routing | | dsh | 3080 | Agent runtime (DeepSeek Harness sidecar, own workspace) |
## Phases ## Phases
@@ -110,7 +112,7 @@ The control-room SPA and the Wails desktop wrapper live in their own repo,
[dtoro/oikos-web](https://git.hubris.network/dtoro/oikos-web) (local [dtoro/oikos-web](https://git.hubris.network/dtoro/oikos-web) (local
checkout `~/Projects/oikos-web`) — extracted in Phase 1 of checkout `~/Projects/oikos-web`) — extracted in Phase 1 of
[plans/2026-08-15-hexagonal-architecture.md](plans/2026-08-15-hexagonal-architecture.md). [plans/2026-08-15-hexagonal-architecture.md](plans/2026-08-15-hexagonal-architecture.md).
The SPA talks to `api`/`nomos` over HTTP with a bearer token entered on The SPA talks to `api` over HTTP with a bearer token entered on
first launch. It deploys as its own compose project publishing `8091:80`; first launch. It deploys as its own compose project publishing `8091:80`;
the outer Caddy (LXC 121) targets that published port, so serving and auth the outer Caddy (LXC 121) targets that published port, so serving and auth
are unchanged from the pre-split stack. are unchanged from the pre-split stack.
@@ -119,7 +121,6 @@ are unchanged from the pre-split stack.
``` ```
cmd/oikos/ Go entry point — single binary cmd/oikos/ Go entry point — single binary
cmd/nomos/ Nomos MCP client gateway
cmd/webhook/ Gitea deploy-webhook receiver (push-to-deploy on mac-mini) cmd/webhook/ Gitea deploy-webhook receiver (push-to-deploy on mac-mini)
internal/ Go packages (actuator, checkdefaults, config, core, db, internal/ Go packages (actuator, checkdefaults, config, core, db,
domain, httpapi, knowledge, learning, mcp, observability, domain, httpapi, knowledge, learning, mcp, observability,
@@ -133,7 +134,6 @@ checks/ Host health-check scripts run over SSH by the scheduler
tools/ Client auto-setup scripts (checks) tools/ Client auto-setup scripts (checks)
ssh/ Deploy keys + authorized_keys management ssh/ Deploy keys + authorized_keys management
vps/ Caddy/TURN config templates for the netbird VPS vps/ Caddy/TURN config templates for the netbird VPS
nomos/ Nomos config, persona, skills
.agents/ Agent instruction files, shared conventions, skills .agents/ Agent instruction files, shared conventions, skills
plans/ Design documents (active + done) plans/ Design documents (active + done)
docs/adr/ Architecture decision records docs/adr/ Architecture decision records

File diff suppressed because it is too large Load Diff

View File

@@ -1,402 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"regexp"
"strings"
"time"
"github.com/dtoro/oikos/internal/nomos/session"
"github.com/dtoro/oikos/internal/safego"
"github.com/google/uuid"
)
// execIDRe matches "execution <uuid>" in a tool result — the phrasing shared
// by request_execution / run when they queue or start a gated execution.
// Only these async executions need continuation; the synchronous auto-run
// path returns its output inline and is already observed in-turn.
var execIDRe = regexp.MustCompile(`(?i)execution\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})`)
func extractExecutionIDs(toolResult string) []uuid.UUID {
matches := execIDRe.FindAllStringSubmatch(toolResult, -1)
seen := map[uuid.UUID]bool{}
var out []uuid.UUID
for _, m := range matches {
if id, err := uuid.Parse(m[1]); err == nil && !seen[id] {
seen[id] = true
out = append(out, id)
}
}
return out
}
// idleTaskThreshold is how long a goal-bearing session can sit non-terminal
// with no activity before the idle sweep nudges it, per
// plans/2026-07-11-task-completion-safety-net.md. Arbitrary starting point,
// not measured against real task durations — long enough that it won't fire
// mid-turn, short enough the board doesn't lie for hours.
const idleTaskThreshold = 15 * time.Minute
// runIdleSweepWorker is the safety net for case 2 of
// plans/2026-07-11-task-completion-safety-net.md: sessions that called
// set_goal (so the inline safety net in agent.go correctly left them alone,
// since they framed themselves as a real task) but then stalled without
// ever calling complete_task. Coarser than runContinuationWorker's 4s tick
// since "gone idle" is a much slower signal than "an execution just
// finished." Blocks until ctx is cancelled.
func (a *agent) runIdleSweepWorker(ctx context.Context) {
if a.store == nil {
slog.Warn("nomos: idle sweep worker disabled (no store)")
return
}
slog.Info("nomos: idle sweep worker started")
ticker := time.NewTicker(2 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
a.processIdleSweep(ctx)
}
}
}
// processIdleSweep nudges a stalled goal-bearing session once; if it's still
// non-terminal on the NEXT sweep (meaning the nudge itself went unanswered,
// not just that the model is still working), auto-closes it with a
// visible "auto-closed" outcome instead of leaving it stuck forever — same
// reasoning resumeSession already applies below for a different failure
// mode (a resume that produces no response at all).
func (a *agent) processIdleSweep(ctx context.Context) {
stale := a.store.StaleGoalSessions(ctx, idleTaskThreshold, 5)
for _, s := range stale {
s := s
if s.CompletionNudges == 0 {
safego.Go("nomos:idle-nudge:"+s.ID, func() {
note := fmt.Sprintf("[System: this task ('%s') has been idle for %s with no complete_task call. "+
"If the goal is done (or can't be completed), call complete_task now with the outcome and a "+
"one-line summary. If you're still genuinely working through the plan, ignore this and continue.]",
s.Goal, idleTaskThreshold)
note = a.store.EnrichResumeNote(ctx, s.ID, note)
// P1: only count the nudge if it actually delivered. resumeSession
// skips (returns false) when a turn is already active; bumping the
// counter anyway would make the next sweep auto-close a merely-busy
// session as "unanswered."
if a.resumeSession(ctx, s.ID, note) {
if err := a.store.BumpCompletionNudge(ctx, s.ID); err != nil {
slog.Error("nomos: idle nudge bump failed", "session", s.ID, "error", err)
}
}
})
continue
}
safego.Go("nomos:idle-autoclose:"+s.ID, func() {
summary := fmt.Sprintf("Auto-closed after %s idle with no response to a completion nudge.", idleTaskThreshold)
if err := a.store.CompleteTask(ctx, s.ID, "partial", summary); err != nil {
slog.Error("nomos: idle auto-close failed", "session", s.ID, "error", err)
}
})
}
}
// runContinuationWorker is the event loop that replaces the human typing
// "continue". It polls for gated executions that (a) were initiated by a chat
// session and (b) have just finished, and — while that agent has an open assent
// window (an approved plan is in flight) — feeds each result back into the
// agent so it proceeds to the next step or recovers from the failure, all
// without an operator tick. Blocks until ctx is cancelled.
func (a *agent) runContinuationWorker(ctx context.Context) {
if a.store == nil {
slog.Warn("nomos: continuation worker disabled (no store)")
return
}
slog.Info("nomos: continuation worker started")
ticker := time.NewTicker(4 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
a.processContinuations(ctx)
}
}
}
// processContinuations dispatches each pending item as its OWN goroutine
// (safego.Go, so a panic deep in one task's resumed turn — JSON parsing of
// model output, an unexpected nil in a tool result — is recovered and logged
// instead of taking down this whole function, which used to run every
// item sequentially in the SAME goroutine as the ticker loop. Two problems
// that fixed: (1) throughput — task B's continuation no longer waits for
// task A's full (up to 10-minute) resumed turn to finish first, the exact
// per-task blocking this session's earlier concurrency work removed from the
// live-chat path but had left in place here; (2) survivability — since Go
// panics unwind the goroutine they occur in, an unrecovered one here used to
// mean this call (and every future tick, since the whole ticker loop runs in
// one goroutine) would simply stop — auto-continuation for every task would
// silently die until nomos restarted. Now a single bad item can only ever
// take down its own goroutine.
func (a *agent) processContinuations(ctx context.Context) {
pending := a.store.PendingContinuations(ctx, 5)
for _, p := range pending {
// Scope gate: only auto-continue while an approved plan is active FOR
// THIS SESSION. Checked per-item, not once for the whole batch — with
// 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) {
// 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
}
}
// markContinued now happens inside continueSession, AFTER resumeSession
// actually runs (P0). Pre-marking here consumed the item even when
// resumeSession skipped on a busy session, losing the result.
safego.Go("nomos:continue-session:"+p.SessionID, func() { a.continueSession(ctx, p) })
}
}
// continueSession re-invokes the agent for one finished execution. Persists
// progress LIVE — a placeholder row immediately, updated in place as each
// tool call completes — instead of only saving once the whole continuation
// finishes. The frontend polls (see chat.ts startPolling); without
// incremental persistence here, a continuation that runs several tool calls
// before concluding would look like total silence in the UI for however long
// that takes, which is exactly the "I just wait while nothing happens"
// complaint this exists to fix — polling alone only helps if there's
// something new to poll for.
func (a *agent) continueSession(ctx context.Context, p session.PendingContinuation) {
slog.Info("nomos: auto-continuing session", "session", p.SessionID, "execution", p.ExecID, "status", p.Status)
// P0 (plans/2026-08-03-nomos-chat-changes-review.md): mark the execution
// continued ONLY after the turn actually ran. resumeSession skips (returns
// false) when another turn is already active for this session; marking
// before that — as the old code did — consumed the item (continued_at set,
// never re-queued by pendingContinuations) and silently lost the result.
// On a skip, leave it pending so the next worker tick retries once the
// active turn frees the permit.
if !a.resumeSession(ctx, p.SessionID, buildContinuationNote(p)) {
slog.Info("nomos: continuation deferred — a turn is active; will retry next tick", "session", p.SessionID, "execution", p.ExecID)
return
}
a.store.MarkContinued(ctx, p.ExecID)
}
// resumeSession re-invokes the agent for a session with a system-injected note —
// a finished execution (continueSession) or an operator's answer to a question
// (handleAnswerQuestion) — persisting progress LIVE (a placeholder row updated
// in place as each tool call lands) so the frontend poller sees each step,
// instead of total silence until the whole resume concludes.
//
// F1 (plan 2026-08-03): this is the single entry point for EVERY background
// turn — the continuation worker, idle sweep, answer-question, /resume, and the
// empty-message reconnect all funnel through here. It acquires the session's
// turn permit non-blocking and SKIPS if a turn is already running. A duplicate
// resume while a turn (live or background) is active is exactly the
// interleaving that corrupted the activity panel and made tasks feel stuck.
//
// Returns whether the turn actually ran. Callers that mutate state before
// resuming (the continuation worker's markContinued, the idle sweep's nudge
// bump) MUST gate that mutation on a true return — otherwise a busy-skip leaves
// the state changed but the work undone (lost continuation / false auto-close).
// See plans/2026-08-03-nomos-chat-changes-review.md P0/P1.
func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool {
if !a.gate.Acquire(sessionID, 0) {
slog.Info("nomos: turn already active, skipping background resume", "session", sessionID)
return false
}
// Release the gate, then drain any operator message that was queued while
// this background turn ran (plan 2026-08-03 F2). Queued messages are run as
// real user turns server-side; resumeSession itself never enqueues.
defer func() {
a.gate.Release(sessionID)
safego.Go("nomos:drain:"+sessionID, func() { a.drainQueued(context.Background(), sessionID) })
}()
placeholder, _ := json.Marshal(map[string]any{
"role": "assistant",
"text": "",
"auto": true,
})
msgID, err := a.store.InsertMessageReturningID(ctx, sessionID, "assistant", placeholder)
if err != nil {
slog.Error("nomos: resume placeholder insert failed", "session", sessionID, "error", err)
}
var toolCalls []map[string]any
var finalText, errText string
var finalThinking string
persist := func() {
if msgID == uuid.Nil {
return
}
text := finalText
if text == "" && errText != "" {
text = fmt.Sprintf("(auto-continuation hit an internal error and did not respond: %s — the execution's own result is above; you may need to prompt the agent again)", errText)
}
body, _ := json.Marshal(map[string]any{
"role": "assistant",
"text": text,
"thinking": finalThinking,
"tool_calls": toolCalls,
"auto": true, // marks this as an autonomous continuation, not an operator turn
})
a.store.UpdateMessage(ctx, msgID, body)
}
// One retry if the LLM call itself produced nothing (transient flake /
// empty-response) — the whole point of this mechanism is "don't give up
// on the first error," which should apply to the continuation call
// itself, not just the homelab commands it's continuing. Found live: a
// destructive-recovery continuation hit an empty LLM response, its
// internal retry (chatWith's own maxLLMRetries=1) also came up empty, and
// without this outer retry the operator would see nothing at all.
cctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
// B.3: escalate the recovery note across attempts — a transient flake
// needs a different prompt than a model that's stuck no-op'ing. The
// final attempt is maximally directive ("do this specific thing now").
// B.5: back off between retries (4s, 8s) so a transient provider issue
// has time to clear — 3 identical calls in 3 seconds just get 3
// identical empties.
notes := []string{
note, // attempt 0: the original (already enriched per B.2) note
fmt.Sprintf("[System: your previous turn produced no response. %s. Produce a response now — call the next tool or report progress in one sentence.]", note),
fmt.Sprintf("[System: two consecutive empty responses. Stop trying to be clever. The next action is: pick the lowest-pending plan step, mark it running with update_plan_step, and call run for its target. Do that now.]"),
}
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
select {
case <-cctx.Done():
return true // a turn ran on an earlier attempt; consume, don't re-loop
case <-time.After(time.Duration(2<<attempt) * time.Second): // 4s, 8s
}
}
toolCalls, finalText, errText = nil, "", ""
finalThinking = ""
// P3: accumulate per-iteration reasoning instead of overwriting
// (same fix as main.go's chat handler). Without this, a resumed
// turn's intermediate thinking is lost on reload.
// (same fix as main.go's chat handler). Without this, a resumed
// turn's intermediate thinking is lost on reload.
var textParts []string
var thinkingParts []string
emit := func(ev agentEvent) {
if ev.Type == "tool_use" || ev.Type == "tool_result" {
if m, ok := ev.Data.(map[string]any); ok {
m["type"] = ev.Type
// One entry per tool call: tool_use creates it,
// tool_result merges the result into the same entry
// (matched by id). Before this fix, both events
// appended separate entries, doubling every tool call.
id, _ := m["id"].(string)
if id != "" && ev.Type == "tool_result" {
for _, existing := range toolCalls {
if eID, _ := existing["id"].(string); eID == id {
for k, v := range m {
existing[k] = v
}
break
}
}
} else {
toolCalls = append(toolCalls, m)
}
}
persist() // live: a poller sees this step land within seconds
}
if ev.Type == "text" {
if t, ok := ev.Data.(string); ok && t != "" {
if ev.IsThinking {
thinkingParts = append(thinkingParts, t)
finalThinking = strings.Join(thinkingParts, "\n\n")
} else {
textParts = append(textParts, t)
finalText = strings.Join(textParts, "\n\n")
}
persist()
}
}
if ev.Type == "error" {
errText, _ = ev.Data.(string)
}
}
a.chatWith(cctx, sessionID, "", notes[attempt], emit)
if finalText != "" || len(toolCalls) > 0 {
break
}
if attempt < 2 {
slog.Warn("nomos: resume produced nothing, retrying", "session", sessionID, "error", errText, "attempt", attempt+1)
}
}
if errText != "" && finalText == "" {
slog.Error("nomos: resume produced no response after retry", "session", sessionID, "error", errText)
// 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 true // do not call persist() again — already persisted above
}
persist() // final state — same row, updated one last time with the concluding text
return true
}
// buildContinuationNote frames the finished execution for the model: what
// happened, and what to do about it. The persist-through-errors instruction
// lives here (and in SOUL) so the agent recovers instead of stopping.
func buildContinuationNote(p session.PendingContinuation) string {
action := p.Action
if i := strings.IndexByte(action, ':'); i > 0 && len(action) > 40 {
action = action[:i] // keep just the action verb for brevity; params are in the DB
}
result := p.Result
if len(result) > 3000 {
result = result[:3000] + "…[truncated]"
}
var b strings.Builder
fmt.Fprintf(&b, "[System: execution %s (%s) finished with status=%s.\nResult: %s\n\n",
p.ExecID, action, p.Status, result)
switch p.Status {
case "completed":
b.WriteString("It SUCCEEDED. Continue the approved plan: run the next step. If this was the final step, verify the end goal actually works (e.g. curl the service) and then report success to the operator. Do NOT stop and wait for the operator to say 'continue'.")
case "failed", "cancelled":
b.WriteString("It FAILED. Do NOT give up or hand back to the operator. Diagnose the cause from the result above (and by running read-only inspection commands if needed), form a hypothesis, fix it, and retry or take an alternative approach. You have an active assent window, so config_mutation steps run without re-approval. Only stop and ask the operator if you are genuinely blocked (need information only they have) or the fix would require a destructive action they haven't approved.")
default: // denied / revoked
b.WriteString("The operator denied or revoked this step. Stop executing this plan and briefly acknowledge.")
}
b.WriteString("]")
return b.String()
}

View File

@@ -1,80 +0,0 @@
package main
import (
"context"
"testing"
"github.com/dtoro/oikos/internal/nomos/session"
"github.com/dtoro/oikos/internal/nomos/turngate"
"github.com/google/uuid"
)
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)
}
}
// TestResumeSession_SkipsWhenBusy guards the P0 fix
// (plans/2026-08-03-nomos-chat-changes-review.md): resumeSession must skip —
// return false, body never executed — when a turn is already active for the
// session. continueSession relies on this so it only marks a continuation
// "continued" after a turn really ran (otherwise the result is lost: marked
// continued, never re-queued by PendingContinuations).
//
// A minimal agent with only a gate is enough: if the body ever ran, chatWith
// would dereference the nil provider and panic. Returning false cleanly proves
// the body was skipped.
func TestResumeSession_SkipsWhenBusy(t *testing.T) {
a := &agent{gate: turngate.New()}
if !a.gate.Acquire("sess", 0) {
t.Fatal("precondition: initial acquire should succeed on a free session")
}
ran := a.resumeSession(context.Background(), "sess", "note")
if ran {
t.Fatal("resumeSession must return false (skip) while a turn is active for the session")
}
}
// TestContinueSession_DefersWhenBusy guards the other half of P0: when the
// session is busy, continueSession defers (leaves the execution pending for the
// next worker tick) instead of running or marking it. It must return cleanly
// without reaching resumeSession's body (nil provider → panic) or markContinued.
func TestContinueSession_DefersWhenBusy(t *testing.T) {
a := &agent{gate: turngate.New()}
if !a.gate.Acquire("sess", 0) {
t.Fatal("precondition: initial acquire should succeed on a free session")
}
p := session.PendingContinuation{ExecID: uuid.New(), SessionID: "sess", Status: "completed"}
a.continueSession(context.Background(), p) // must not panic; must not run/mark
}

View File

@@ -1,372 +0,0 @@
// Command nomos-eval runs golden conversation evals against a live nomos
// gateway. It loads a YAML manifest of conversations + assertions, sends
// each prompt to the chat endpoint, waits for the turn(s) to finish, and
// scores assertions against the persisted transcript.
//
// Usage:
//
// go run ./cmd/nomos/eval -gateway http://localhost:8092 -manifest evals/*.yaml
//
// The gateway must already be running (nomos serve, or the docker container).
// Each conversation costs real OpenRouter credits (~$0.010.05 each).
//
// Manifest format — see evals/example.yaml. Assertions are scored against the
// final transcript: tool calls made, plan steps, final session status, and
// whether the turn completed. The runner does NOT judge text quality — only
// structural properties that can be checked deterministically from the
// persisted state. This is deliberate: text quality is model-dependent and
// noisy; structure is what the Go gates + SOUL.md should enforce.
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
)
func main() {
gateway := flag.String("gateway", "http://localhost:8092", "nomos gateway URL")
manifestGlob := flag.String("manifest", "evals/*.yaml", "glob of manifest files to run")
timeout := flag.Duration("timeout", 2*time.Minute, "per-conversation timeout")
flag.Parse()
if err := health(*gateway); err != nil {
fmt.Fprintf(os.Stderr, "gateway not reachable at %s: %v\n", *gateway, err)
os.Exit(1)
}
files, err := filepath.Glob(*manifestGlob)
if err != nil {
fmt.Fprintf(os.Stderr, "glob %s: %v\n", *manifestGlob, err)
os.Exit(1)
}
if len(files) == 0 {
fmt.Fprintf(os.Stderr, "no manifests matched %s\n", *manifestGlob)
os.Exit(1)
}
total, passed, failed := 0, 0, 0
for _, f := range files {
convs, err := loadManifest(f)
if err != nil {
fmt.Fprintf(os.Stderr, "load %s: %v\n", f, err)
os.Exit(1)
}
for _, c := range convs {
total++
name := c.Name
if name == "" {
name = fmt.Sprintf("conversation-%d", total)
}
fmt.Printf("=== %s (from %s) ===\n", name, filepath.Base(f))
res := runConversation(context.Background(), *gateway, c, *timeout)
if res.Passed {
passed++
fmt.Printf(" ✅ PASS (%.1fs, %d tool calls)\n", res.Duration.Seconds(), res.ToolCallCount)
} else {
failed++
fmt.Printf(" ❌ FAIL (%.1fs, %d tool calls)\n", res.Duration.Seconds(), res.ToolCallCount)
}
for _, a := range res.Assertions {
mark := "✅"
if !a.Passed {
mark = "❌"
}
fmt.Printf(" %s %s: %s\n", mark, a.Name, a.Detail)
}
}
}
fmt.Printf("\n=== Summary: %d/%d passed, %d failed ===\n", passed, total, failed)
if failed > 0 {
os.Exit(1)
}
}
func health(gateway string) error {
resp, err := http.Get(gateway + "/healthz")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("healthz status %d", resp.StatusCode)
}
return nil
}
// runConversation sends the prompt (and any followup), waits for each turn to
// finish, then scores assertions against the final transcript.
func runConversation(ctx context.Context, gateway string, c conversation, timeout time.Duration) convResult {
start := time.Now()
deadline := time.Now().Add(timeout)
res := convResult{}
// Send the initial prompt (no session_id → creates a new session).
sid, err := sendChat(ctx, gateway, "", c.Prompt)
if err != nil {
res.Assertions = []assertionResult{{Name: "send_prompt", Passed: false, Detail: err.Error()}}
res.Duration = time.Since(start)
return res
}
res.SessionID = sid
// Wait for the first turn to finish.
if err := waitForTurn(ctx, gateway, sid, deadline); err != nil {
res.Assertions = []assertionResult{{Name: "turn_complete", Passed: false, Detail: err.Error()}}
res.Duration = time.Since(start)
return res
}
// Send followup if any.
for _, fu := range c.followups() {
if _, err := sendChat(ctx, gateway, sid, fu); err != nil {
res.Assertions = []assertionResult{{Name: "send_followup", Passed: false, Detail: err.Error()}}
res.Duration = time.Since(start)
return res
}
if err := waitForTurn(ctx, gateway, sid, deadline); err != nil {
res.Assertions = []assertionResult{{Name: "followup_turn_complete", Passed: false, Detail: err.Error()}}
res.Duration = time.Since(start)
return res
}
}
// Fetch the final transcript + session state.
transcript, session, err := fetchTranscript(ctx, gateway, sid)
if err != nil {
res.Assertions = []assertionResult{{Name: "fetch_transcript", Passed: false, Detail: err.Error()}}
res.Duration = time.Since(start)
return res
}
res.ToolCallCount = transcript.toolCallCount()
res.Duration = time.Since(start)
// Score assertions.
res.Assertions = scoreAssertions(c.Assertions, transcript, session)
res.Passed = true
for _, a := range res.Assertions {
if !a.Passed {
res.Passed = false
break
}
}
return res
}
// sendChat POSTs to /chat and extracts the session_id from the first SSE
// event, then KEEPS READING the stream until it ends (the `done` event or
// the connection closes). This is critical: the chat handler uses
// r.Context() which cancels when the HTTP connection closes — if we stop
// reading after the session event, the agent's work gets canceled mid-turn.
// We must drain the full stream so the agent completes its turn server-side.
func sendChat(ctx context.Context, gateway, sid, message string) (string, error) {
body, _ := json.Marshal(map[string]string{"session_id": sid, "message": message})
req, _ := http.NewRequestWithContext(ctx, "POST", gateway+"/chat", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 && resp.StatusCode != 202 {
b, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("chat status %d: %s", resp.StatusCode, string(b))
}
// For a reconnect (sid != ""), the body is 202 with no stream.
if sid != "" {
io.Copy(io.Discard, resp.Body)
return sid, nil
}
// Read the SSE stream, capturing the session_id from the first session
// event, and draining the rest so the agent's turn completes. The stream
// ends when the server closes it (after the `done` event) or when the
// request context cancels.
dec := newSSEReader(resp.Body)
sessionID := ""
for {
ev, err := dec.next()
if err != nil {
if sessionID == "" {
return "", fmt.Errorf("no session event before stream end: %w", err)
}
return sessionID, nil
}
if ev["type"] == "session" && sessionID == "" {
if s, ok := ev["session_id"].(string); ok {
sessionID = s
}
}
// Keep reading until the stream ends — don't return early.
}
}
// waitForTurn polls the session until its last_active_at stops advancing for
// 8 seconds (the turn ended) or the session reaches a terminal status. We
// can't rely on status=done alone because a trivial task may auto-complete
// while a plan-proposing task stays in 'executing' waiting for approval.
func waitForTurn(ctx context.Context, gateway, sid string, deadline time.Time) error {
var lastActive string
stableSince := time.Now()
for {
if time.Now().After(deadline) {
return fmt.Errorf("timeout waiting for turn to complete")
}
_, session, err := fetchTranscript(ctx, gateway, sid)
if err != nil {
time.Sleep(2 * time.Second)
continue
}
if session.LastActive != lastActive {
lastActive = session.LastActive
stableSince = time.Now()
}
if time.Since(stableSince) >= 8*time.Second {
return nil // turn is idle — consider it complete
}
if session.Status == "done" || session.Status == "failed" {
return nil
}
time.Sleep(2 * time.Second)
}
}
type transcript struct {
Messages []struct {
Role string `json:"role"`
Content struct {
Text string `json:"text"`
ToolCalls []map[string]any `json:"tool_calls"`
} `json:"content"`
} `json:"messages"`
// PlanSteps is fetched from /sessions/{id}/plan (P5 plan_generations
// assertion). Each step carries a `generation` int; distinctGenerations
// counts the unique values. nil when the endpoint returned no plan
// (e.g. a pure-DB Q&A with no propose_plan call).
PlanSteps []planStep `json:"steps"`
}
// planStep is one step from /sessions/{id}/plan, carrying only the fields the
// eval needs: the generation number (P2 iteration counter).
type planStep struct {
Generation int `json:"generation"`
Status string `json:"status"`
Title string `json:"title"`
}
func (t transcript) toolCallCount() int {
n := 0
for _, m := range t.Messages {
n += len(m.Content.ToolCalls)
}
return n
}
func (t transcript) toolNames() []string {
var names []string
for _, m := range t.Messages {
for _, tc := range m.Content.ToolCalls {
if name, ok := tc["name"].(string); ok {
names = append(names, name)
}
}
}
return names
}
// distinctGenerations counts unique plan generation values across all plan
// steps. Used by the `plan_generations` assertion (P2 iteration). Returns 0
// when there are no plan steps (no propose_plan was called).
func (t transcript) distinctGenerations() int {
seen := map[int]bool{}
for _, s := range t.PlanSteps {
seen[s.Generation] = true
}
return len(seen)
}
type sessionState struct {
ID string `json:"id"`
Status string `json:"status"`
Outcome string `json:"outcome"`
LastActive string `json:"last_active_at"`
}
// fetchTranscript fetches the messages from /sessions/{id} (which returns
// only session_id + messages) and the session metadata from /sessions
// (which returns status/outcome/last_active_at for each session). P5 also
// fetches /sessions/{id}/plan for the plan_generations assertion.
func fetchTranscript(ctx context.Context, gateway, sid string) (transcript, sessionState, error) {
var t transcript
resp, err := http.Get(gateway + "/sessions/" + sid)
if err != nil {
return t, sessionState{}, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return t, sessionState{}, err
}
if err := json.Unmarshal(b, &t); err != nil {
return t, sessionState{}, err
}
// Fetch the plan (steps with generation numbers) for the
// plan_generations assertion. A 404 or empty response is fine — a
// pure-DB Q&A with no propose_plan has no plan. ?all=true returns every
// generation so the assertion can count them (the default view returns
// only the current generation).
if planResp, perr := http.Get(gateway + "/sessions/" + sid + "/plan?all=true"); perr == nil {
if planResp.StatusCode == 200 {
pb, _ := io.ReadAll(planResp.Body)
_ = json.Unmarshal(pb, &t) // fills t.PlanSteps via "steps" field
}
planResp.Body.Close()
}
// The detail endpoint doesn't return status/outcome — fetch from the
// sessions list and find the matching id.
s, err := fetchSessionMeta(ctx, gateway, sid)
return t, s, err
}
// fetchSessionMeta fetches /sessions and extracts the one matching sid.
func fetchSessionMeta(ctx context.Context, gateway, sid string) (sessionState, error) {
resp, err := http.Get(gateway + "/sessions")
if err != nil {
return sessionState{}, err
}
defer resp.Body.Close()
var list struct {
Sessions []sessionState `json:"sessions"`
}
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
return sessionState{}, err
}
for _, s := range list.Sessions {
if s.ID == sid {
return s, nil
}
}
return sessionState{}, fmt.Errorf("session %s not found in list", sid)
}
// convResult is the outcome of one conversation.
type convResult struct {
SessionID string
Passed bool
Duration time.Duration
ToolCallCount int
Assertions []assertionResult
}
type assertionResult struct {
Name string
Passed bool
Detail string
}

View File

@@ -1,236 +0,0 @@
package main
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// conversation is one golden conversation from a manifest.
type conversation struct {
Name string `yaml:"name"`
Prompt string `yaml:"prompt"`
Followup string `yaml:"followup"` // backward compat: single followup
Followups []string `yaml:"followups"` // P5: multi-turn followups
Assertions []assertion `yaml:"assertions"`
}
// followups returns the full list of follow-up messages, supporting both
// the single `followup` field (backward compat) and the multi-turn
// `followups` list.
func (c conversation) followups() []string {
if len(c.Followups) > 0 {
return c.Followups
}
if c.Followup != "" {
return []string{c.Followup}
}
return nil
}
// assertion is one check against the final transcript. The `kind` field
// selects the scorer; the rest are scorer-specific parameters.
//
// Supported kinds:
//
// completes — session status reached done/failed (not stuck executing)
// outcome_is — session outcome == value (success/failure/partial)
// no_propose_plan — propose_plan was never called
// proposes_plan — propose_plan called >= 1 time (plan-always model; P1)
// proposes_plan_once — propose_plan was called exactly once
// no_duplicate_proposal — propose_plan called at most once
// plan_before_run — the first `run` call comes after the first `propose_plan` (P1 ordering gate)
// plan_generations — the persisted plan has exactly `value` distinct generations (P2 iteration: 1 = single, 2 = one followup)
// writes_back — update_entity_attributes or create_relationship was called
// max_tool_calls — total tool calls <= value
// max_run_calls — total `run` calls <= value
// no_run — `run` was never called
// calls_tool — the named tool appears in the transcript
// plan_step_count — the plan has exactly `value` steps
// no_duplicate_complete — complete_task called at most once
type assertion struct {
Kind string `yaml:"kind"`
Value any `yaml:"value"`
}
// loadManifest reads a YAML file containing a list of conversations.
func loadManifest(path string) ([]conversation, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var convs []conversation
if err := yaml.Unmarshal(b, &convs); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return convs, nil
}
// scoreAssertions evaluates each assertion against the transcript + session.
func scoreAssertions(asserts []assertion, t transcript, s sessionState) []assertionResult {
out := make([]assertionResult, 0, len(asserts))
for _, a := range asserts {
r := assertionResult{Name: a.Kind}
r.Passed, r.Detail = scoreOne(a, t, s)
if !r.Passed && r.Detail == "" {
r.Detail = "assertion failed"
}
out = append(out, r)
}
return out
}
func scoreOne(a assertion, t transcript, s sessionState) (bool, string) {
tools := t.toolNames()
switch a.Kind {
case "completes":
if s.Status == "done" || s.Status == "failed" {
return true, fmt.Sprintf("status=%s", s.Status)
}
return false, fmt.Sprintf("status=%s (not terminal)", s.Status)
case "outcome_is":
want, _ := a.Value.(string)
if s.Outcome == want {
return true, fmt.Sprintf("outcome=%s", s.Outcome)
}
return false, fmt.Sprintf("outcome=%s, want %s", s.Outcome, want)
case "no_propose_plan":
n := countTool(tools, "propose_plan")
if n == 0 {
return true, "propose_plan not called"
}
return false, fmt.Sprintf("propose_plan called %d time(s)", n)
case "proposes_plan":
// P1 plan-always: propose_plan called >= 1 time.
n := countTool(tools, "propose_plan")
if n >= 1 {
return true, fmt.Sprintf("propose_plan called %d time(s)", n)
}
return false, "propose_plan never called (plan-always requires >= 1)"
case "proposes_plan_once":
n := countTool(tools, "propose_plan")
if n == 1 {
return true, "propose_plan called once"
}
return false, fmt.Sprintf("propose_plan called %d time(s), want 1", n)
case "no_duplicate_proposal":
n := countTool(tools, "propose_plan")
if n <= 1 {
return true, fmt.Sprintf("propose_plan called %d time(s)", n)
}
return false, fmt.Sprintf("propose_plan called %d time(s), want <= 1", n)
case "plan_before_run":
// P1 ordering gate: the first `run` call's global index in the
// transcript is strictly greater than the first `propose_plan`
// index. Both indices are over the flat tool-call list (across all
// messages, in order).
planIdx, runIdx := -1, -1
for i, name := range tools {
if name == "propose_plan" && planIdx == -1 {
planIdx = i
}
if name == "run" && runIdx == -1 {
runIdx = i
}
}
if runIdx == -1 {
return true, "run never called (ordering trivially satisfied)"
}
if planIdx == -1 {
return false, "run called but propose_plan never called"
}
if planIdx < runIdx {
return true, fmt.Sprintf("propose_plan at index %d before run at index %d", planIdx, runIdx)
}
return false, fmt.Sprintf("run at index %d before propose_plan at index %d", runIdx, planIdx)
case "plan_generations":
// P2 iteration: counts distinct `generation` values in
// session_plan_steps. 1 = single sub-task, 2 = one follow-up
// sub-task, etc. Requires the plan endpoint to return generation
// values; the eval fetches /sessions/{id}/plan and passes it via
// the transcript's PlanSteps field.
want := toInt(a.Value)
gens := t.distinctGenerations()
if gens == want {
return true, fmt.Sprintf("%d plan generation(s)", gens)
}
return false, fmt.Sprintf("%d plan generation(s), want %d", gens, want)
case "writes_back":
n := countTool(tools, "update_entity_attributes") + countTool(tools, "create_relationship")
if n > 0 {
return true, fmt.Sprintf("%d writeback call(s)", n)
}
return false, "no update_entity_attributes or create_relationship calls"
case "max_tool_calls":
max := toInt(a.Value)
if t.toolCallCount() <= max {
return true, fmt.Sprintf("%d tool calls (<= %d)", t.toolCallCount(), max)
}
return false, fmt.Sprintf("%d tool calls, want <= %d", t.toolCallCount(), max)
case "max_run_calls":
max := toInt(a.Value)
n := countTool(tools, "run")
if n <= max {
return true, fmt.Sprintf("%d run calls (<= %d)", n, max)
}
return false, fmt.Sprintf("%d run calls, want <= %d", n, max)
case "no_run":
n := countTool(tools, "run")
if n == 0 {
return true, "run not called"
}
return false, fmt.Sprintf("run called %d time(s)", n)
case "calls_tool":
want, _ := a.Value.(string)
n := countTool(tools, want)
if n > 0 {
return true, fmt.Sprintf("%s called %d time(s)", want, n)
}
return false, fmt.Sprintf("%s not called", want)
case "no_duplicate_complete":
n := countTool(tools, "complete_task")
if n <= 1 {
return true, fmt.Sprintf("complete_task called %d time(s)", n)
}
return false, fmt.Sprintf("complete_task called %d time(s), want <= 1", n)
default:
return false, fmt.Sprintf("unknown assertion kind: %s", a.Kind)
}
}
func countTool(names []string, name string) int {
n := 0
for _, x := range names {
if x == name {
n++
}
}
return n
}
func toInt(v any) int {
switch x := v.(type) {
case int:
return x
case int64:
return int(x)
case float64:
return int(x)
}
return 0
}

View File

@@ -1,52 +0,0 @@
package main
import (
"bufio"
"encoding/json"
"io"
"strings"
)
// sseReader parses a text/event-stream into a sequence of JSON events.
// Each event is one or more "data: " lines; the lines are concatenated
// and parsed as a single JSON object. Blank lines separate events.
type sseReader struct {
r *bufio.Reader
}
func newSSEReader(r io.Reader) *sseReader {
return &sseReader{r: bufio.NewReader(r)}
}
func (s *sseReader) next() (map[string]any, error) {
var data strings.Builder
for {
line, err := s.r.ReadString('\n')
if err != nil {
if err == io.EOF && data.Len() > 0 {
return parseEvent(data.String())
}
return nil, err
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
if data.Len() > 0 {
return parseEvent(data.String())
}
continue // blank line, no event buffered yet
}
if strings.HasPrefix(line, "data: ") {
data.WriteString(strings.TrimPrefix(line, "data: "))
} else if strings.HasPrefix(line, "data:") {
data.WriteString(strings.TrimPrefix(line, "data:"))
}
}
}
func parseEvent(s string) (map[string]any, error) {
var ev map[string]any
if err := json.Unmarshal([]byte(s), &ev); err != nil {
return nil, err
}
return ev, nil
}

View File

@@ -1,348 +0,0 @@
package main
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"sync"
"time"
)
// ─── MCP Streamable HTTP client ────────────────────────────────────────
type mcpClient struct {
baseURL string
token string // OIKOS_MCP_BEARER_TOKEN — api's combinedAuth requires it on every request (no dev-open bypass)
sessionID string
http *http.Client
nextID int
mu sync.Mutex // one client serializes its own MCP calls (the pool gives each session its own client, so this never blocks another session)
// toolsCache holds the last tools/list result. The tool list is static
// for the lifetime of one MCP connection — it only changes when the api
// process (re)registers tools, i.e. on a restart, which this client
// already detects and reacts to via reconnectLocked. Without this,
// buildTools (called at the start of EVERY chat turn, including every
// auto-continuation resume) paid a full tools/list round-trip every
// single time for a list that's almost always identical to the last one.
// Guarded separately from mu (not reused) so a cache check never
// contends with an in-flight doRequest call for a different method.
toolsMu sync.Mutex
toolsCache []toolDef
}
func newMCPClient(baseURL, token string) (*mcpClient, error) {
c := &mcpClient{
baseURL: baseURL,
token: token,
http: &http.Client{Timeout: 120 * time.Second},
}
resp, err := c.doRequest("initialize", map[string]any{
"protocolVersion": "2024-11-05",
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "nomos", "version": "2.0"},
})
if err != nil {
return nil, fmt.Errorf("initialize: %w", err)
}
if resp.sessionID == "" {
return nil, fmt.Errorf("no session ID in initialize response")
}
c.sessionID = resp.sessionID
c.doRequest("notifications/initialized", map[string]any{})
slog.Info("nomos: mcp connected", "session", c.sessionID[:16]+"...")
return c, nil
}
type mcpJSONRPCResponse struct {
sessionID string
Result json.RawMessage `json:"result"`
Error json.RawMessage `json:"error"`
}
// errStaleSession signals that the MCP server rejected our session id (e.g.
// after an api/MCP restart), so the client should re-initialize and retry.
var errStaleSession = fmt.Errorf("mcp session stale")
// doRequest serializes MCP calls and transparently re-initializes the session
// if the server has forgotten it (common after an api redeploy), retrying the
// original call once. Without this, an api restart permanently breaks nomos
// until it is itself restarted.
func (c *mcpClient) doRequest(method string, params map[string]any) (*mcpJSONRPCResponse, error) {
c.mu.Lock()
defer c.mu.Unlock()
resp, err := c.send(method, params)
if err != nil && method != "initialize" && errors.Is(err, errStaleSession) {
slog.Warn("nomos: mcp session stale, reconnecting")
if rerr := c.reconnectLocked(); rerr != nil {
return nil, fmt.Errorf("mcp reconnect: %w (original: %v)", rerr, err)
}
return c.send(method, params)
}
return resp, err
}
// reconnectLocked re-initializes the MCP session. The caller must hold c.mu.
func (c *mcpClient) reconnectLocked() error {
c.sessionID = ""
// A reconnect means the api process was restarted (or forgot us) — its
// tool registration may have changed, so the cached list is no longer
// trustworthy.
c.toolsMu.Lock()
c.toolsCache = nil
c.toolsMu.Unlock()
resp, err := c.send("initialize", map[string]any{
"protocolVersion": "2024-11-05",
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "nomos", "version": "2.0"},
})
if err != nil {
return err
}
if resp.sessionID == "" {
return fmt.Errorf("no session ID on re-initialize")
}
c.sessionID = resp.sessionID
_, _ = c.send("notifications/initialized", map[string]any{})
slog.Info("nomos: mcp reconnected", "session", c.sessionID[:16]+"...")
return nil
}
// send performs one MCP round-trip. It does not lock; callers hold c.mu.
func (c *mcpClient) send(method string, params map[string]any) (*mcpJSONRPCResponse, error) {
c.nextID++
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"method": method,
"params": params,
"id": c.nextID,
})
req, err := http.NewRequest(http.MethodPost, c.baseURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
if c.sessionID != "" {
req.Header.Set("Mcp-Session-Id", c.sessionID)
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// A rejected/unknown session comes back as 4xx (commonly 400/404).
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusBadRequest {
return nil, errStaleSession
}
result := &mcpJSONRPCResponse{}
result.sessionID = resp.Header.Get("Mcp-Session-Id")
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
gotData := false
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "data: ") {
gotData = true
data := line[6:]
if err := json.Unmarshal([]byte(data), result); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
}
}
if result.Error != nil {
return nil, fmt.Errorf("rpc error: %s", string(result.Error))
}
// Empty body with no result and a session set: the server likely dropped
// our session. Notifications legitimately return no data, so exempt them.
if !gotData && result.Result == nil && method != "notifications/initialized" {
return nil, errStaleSession
}
if result.sessionID != "" {
c.sessionID = result.sessionID
}
return result, nil
}
func (c *mcpClient) callTool(name string, args map[string]any) (any, error) {
resp, err := c.doRequest("tools/call", map[string]any{
"name": name,
"arguments": args,
})
if err != nil {
return nil, err
}
var toolResult struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal(resp.Result, &toolResult); err != nil {
return string(resp.Result), nil
}
var texts []string
for _, c := range toolResult.Content {
if c.Type == "text" {
var parsed any
if json.Unmarshal([]byte(c.Text), &parsed) == nil {
return parsed, nil
}
texts = append(texts, c.Text)
}
}
if len(texts) == 1 {
return texts[0], nil
}
return texts, nil
}
func (c *mcpClient) listTools() ([]string, error) {
resp, err := c.doRequest("tools/list", map[string]any{})
if err != nil {
return nil, err
}
var tr struct {
Tools []struct {
Name string `json:"name"`
Description string `json:"description"`
} `json:"tools"`
}
if err := json.Unmarshal(resp.Result, &tr); err != nil {
return nil, err
}
var names []string
for _, t := range tr.Tools {
names = append(names, t.Name)
}
return names, nil
}
func (c *mcpClient) close() {
}
// ─── Per-session MCP client pool ────────────────────────────────────────
//
// A single shared mcpClient serializes EVERY tool call across EVERY
// concurrently-running task through one mutex (see mcpClient.mu) — `run`
// executes its SSH command synchronously inside that lock and is capped at
// up to 10 minutes, so one task mid-`run` stalled every other task's tool
// calls, even trivial reads, behind it. The MCP *server* has no per-
// connection state to protect (newServer in internal/mcp/server.go returns
// one shared *mcp.Server instance whose tool handlers close only over the DB
// pool, which is already safe for concurrent use) — the mutex existed purely
// because the *client* reused one stateful transport session, not because
// the server needed it. Giving each task's own session its own client
// removes the cross-task serialization entirely: a task's own tool calls
// stay sequential (which they already are — the agent loop calls tools one
// at a time within a turn), but no longer block anyone else's.
type mcpClientPool struct {
baseURL string
token string
mu sync.Mutex
clients map[string]*pooledMCPClient
}
type pooledMCPClient struct {
client *mcpClient
lastUsed time.Time
}
func newMCPClientPool(baseURL, token string) *mcpClientPool {
return &mcpClientPool{baseURL: baseURL, token: token, clients: make(map[string]*pooledMCPClient)}
}
// get returns the client for sessionID, creating and initializing one (a
// real MCP handshake) on first use. Session ids that don't identify a real
// persisted conversation ("" / "ephemeral", the no-DB-store path; "query",
// the structured /query endpoint) still get exactly one dedicated,
// reused client each via the same map — just keyed on a fixed string instead
// of a real session id — so that traffic doesn't pay a fresh handshake per
// request while still never sharing a connection with an actual task.
func (p *mcpClientPool) get(sessionID string) (*mcpClient, error) {
key := sessionID
if key == "" {
key = "ephemeral"
}
p.mu.Lock()
if pc, ok := p.clients[key]; ok {
pc.lastUsed = time.Now()
p.mu.Unlock()
return pc.client, nil
}
p.mu.Unlock()
// Initialize outside the lock — it's a network round-trip, and holding
// the pool mutex for it would serialize unrelated sessions' first calls
// behind each other, undermining the whole point of this pool.
c, err := newMCPClient(p.baseURL, p.token)
if err != nil {
return nil, err
}
p.mu.Lock()
// Another goroutine may have created one for the same key while we were
// initializing (two of this session's tool calls racing on a cold
// start); keep whichever won, close out the loser's connection (a no-op
// today, but future-proof if mcpClient.close ever does real teardown).
if existing, ok := p.clients[key]; ok {
p.mu.Unlock()
c.close()
return existing.client, nil
}
p.clients[key] = &pooledMCPClient{client: c, lastUsed: time.Now()}
p.mu.Unlock()
return c, nil
}
// mcpClientIdleTimeout is how long an idle session's MCP client is kept
// before eviction — long enough to outlive a single slow `run` (capped at 10
// minutes server-side) plus normal think-time between a task's tool calls,
// short enough not to accumulate one abandoned connection per finished task
// forever.
const mcpClientIdleTimeout = 20 * time.Minute
// sweep evicts clients idle past mcpClientIdleTimeout. Call on a ticker.
func (p *mcpClientPool) sweep() {
cutoff := time.Now().Add(-mcpClientIdleTimeout)
p.mu.Lock()
defer p.mu.Unlock()
for key, pc := range p.clients {
if pc.lastUsed.Before(cutoff) {
pc.client.close()
delete(p.clients, key)
}
}
}
func (p *mcpClientPool) closeAll() {
p.mu.Lock()
defer p.mu.Unlock()
for key, pc := range p.clients {
pc.client.close()
delete(p.clients, key)
}
}

View File

@@ -1,696 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/dtoro/oikos/internal/nomos/session"
"github.com/dtoro/oikos/internal/safego"
"github.com/dtoro/oikos/internal/secrets"
"github.com/jackc/pgx/v5"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: nomos serve")
os.Exit(1)
}
if os.Args[1] == "healthcheck" {
runHealthcheck()
return
}
mcpURL := os.Getenv("NOMOS_MCP_URL")
if mcpURL == "" {
mcpURL = "http://localhost:8090/mcp"
}
mcpToken := os.Getenv("OIKOS_MCP_BEARER_TOKEN")
agentSlug := os.Getenv("NOMOS_AGENT_SLUG")
if agentSlug == "" {
agentSlug = "agent:nomos"
}
databaseURL := os.Getenv("DATABASE_URL")
if databaseURL == "" {
databaseURL = os.Getenv("OIKOS_DATABASE_URL")
}
sec := secrets.NewManagerFromConfig(
os.Getenv("OIKOS_INFISICAL_SITE_URL"),
os.Getenv("OIKOS_INFISICAL_CLIENT_ID"),
os.Getenv("OIKOS_INFISICAL_CLIENT_SECRET"),
os.Getenv("OIKOS_INFISICAL_PROJECT_ID"),
os.Getenv("OIKOS_INFISICAL_ENV"),
os.Getenv("OIKOS_SECRETS_DIR"),
)
var openrouterAPIKey string
var secretsResolved int
if sec != nil {
resCtx, resCancel := context.WithTimeout(context.Background(), 10*time.Second)
if v := secrets.ResolveSecret(resCtx, sec, "mcp_bearer-token", ""); v != "" {
mcpToken = v
secretsResolved++
}
openrouterAPIKey = secrets.ResolveSecret(resCtx, sec, "openrouter_api-key", os.Getenv("OPENROUTER_API_KEY"))
if openrouterAPIKey != "" && openrouterAPIKey != os.Getenv("OPENROUTER_API_KEY") {
secretsResolved++
}
resCancel()
if secretsResolved > 0 {
slog.Info("nomos: secrets resolved from Infisical", "count", secretsResolved)
}
}
switch os.Args[1] {
case "serve":
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer cancel()
// One MCP client PER SESSION, not one shared client for the whole
// process — see mcpClientPool's doc comment. A dedicated client is
// created lazily on each session's first tool call.
clientPool := newMCPClientPool(mcpURL, mcpToken)
// Prove connectivity at startup the same way the old single-client
// constructor did, so a misconfigured/unreachable MCP endpoint still
// fails fast on boot instead of only on the first real chat. Doesn't
// reuse the pool (nothing to key it by yet) — just a throwaway probe.
if probe, err := newMCPClient(mcpURL, mcpToken); err != nil {
slog.Error("nomos: mcp connect", "url", mcpURL, "error", err)
os.Exit(1)
} else {
probe.close()
}
st, err := session.New(ctx, databaseURL)
if err != nil {
slog.Error("nomos: db connect", "error", err)
os.Exit(1)
}
if st != nil {
defer st.Close()
}
nAgent, err := newAgent(ctx, clientPool, st, agentSlug, openrouterAPIKey)
if err != nil {
slog.Error("nomos: agent init", "error", err)
os.Exit(1)
}
// Event-driven auto-continuation: feed finished async executions back
// into the agent so an approved plan runs to completion (and recovers
// from failures) without the operator ticking it forward each step.
safego.Go("nomos:continuation-worker", func() { nAgent.runContinuationWorker(ctx) })
// Idle sweep for stalled goal-bearing tasks (fix 2+3 of
// plans/2026-07-11-task-completion-safety-net.md) — a coarser,
// slower-ticking counterpart to the continuation worker above.
safego.Go("nomos:idle-sweep-worker", func() { nAgent.runIdleSweepWorker(ctx) })
safego.Go("nomos:mcp-pool-sweeper", func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
clientPool.sweep()
}
}
})
// Stale execution sweep: cancels non-terminal executions older than
// 10 minutes (orphaned by MCP timeouts — see cleanupStaleExecutions).
safego.Go("nomos:stale-execution-sweeper", func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
st.CleanupStaleExecutions(ctx, 10*time.Minute)
}
}
})
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte("ok"))
})
mux.HandleFunc("/query", func(w http.ResponseWriter, r *http.Request) {
handleQuery(w, r, clientPool, agentSlug, mcpURL)
})
mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
handleChat(w, r, nAgent, st)
})
mux.HandleFunc("/sessions", func(w http.ResponseWriter, r *http.Request) {
handleSessionsList(w, r, st)
})
mux.HandleFunc("/sessions/", func(w http.ResponseWriter, r *http.Request) {
handleSessionDetail(w, r, st, nAgent)
})
addr := os.Getenv("NOMOS_LISTEN")
if addr == "" {
addr = ":8092"
}
srv := &http.Server{Addr: addr, Handler: mux}
safego.Go("nomos:http-server", func() {
slog.Info("nomos: gateway listening", "addr", addr, "mcp", mcpURL, "db", databaseURL != "")
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
slog.Error("nomos: serve", "error", err)
}
})
<-ctx.Done()
slog.Info("nomos: shutting down")
srv.Shutdown(context.Background())
clientPool.closeAll()
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
os.Exit(1)
}
}
func runHealthcheck() {
addr := os.Getenv("NOMOS_LISTEN")
if addr == "" {
addr = ":8092"
}
host := addr
if strings.HasPrefix(host, ":") {
host = "127.0.0.1" + host
}
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get("http://" + host + "/healthz")
if err != nil {
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
os.Exit(1)
}
}
func sseEvent(w http.ResponseWriter, flusher http.Flusher, event agentEvent) {
data, _ := json.Marshal(event)
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
}
func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *session.Store) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405)
return
}
var req struct {
SessionID string `json:"session_id"`
Message string `json:"message"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request: "+err.Error(), 400)
return
}
if req.Message == "" && req.SessionID == "" {
http.Error(w, "message is required", 400)
return
}
// Empty message with an existing session = reconnect/resume. This path is
// defensive now — the frontend (post F2) recovers a dropped SSE via the
// poller + terminal task.status clearing, and no longer POSTs empty
// messages. If a client ever does, route into resumeSession so the agent
// reports current state — but SKIP a terminal session (done/failed/
// abandoned): there's nothing to resume, and running a "report state"
// turn there is just a spare turn the operator never asked for (P2.1).
if req.Message == "" && req.SessionID != "" {
if sess, err := st.GetSession(context.Background(), req.SessionID); err == nil {
switch sess.Status {
case "done", "failed", "abandoned":
slog.Info("nomos: reconnect skipped — session already terminal", "session", req.SessionID, "status", sess.Status)
w.WriteHeader(202)
return
}
}
slog.Info("nomos: reconnect", "session", req.SessionID)
safego.Go("nomos:reconnect:"+req.SessionID, func() {
base := "[System: the operator's connection was re-established. The task may have progressed in the background.]"
note := st.EnrichResumeNote(context.Background(), req.SessionID, base)
a.resumeSession(context.Background(), req.SessionID, note)
})
// Return 202 so the frontend doesn't try to consume an SSE stream
// from this POST — resumeSession writes to the DB directly and
// the poller picks it up.
w.WriteHeader(202)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", 500)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering
w.WriteHeader(200)
// All writes to w (events + the keepalive comment below) go through one
// mutex: http.ResponseWriter is NOT safe for concurrent use, and the
// keepalive ticker runs alongside the turn's event sink (plan 2026-08-03
// F3). Without this, interleaved writes corrupt the SSE stream.
var writeMu sync.Mutex
writeEvent := func(ev agentEvent) {
writeMu.Lock()
defer writeMu.Unlock()
sseEvent(w, flusher, ev)
}
ctx := r.Context()
sessionID := req.SessionID
// pctx (persistence context) is deliberately context.Background(), not
// ctx/r.Context(), for every DB write in this handler — ctx cancels the
// instant the client disconnects (Stop button, tab close, network blip),
// and a write made with an already-cancelled context fails. Before this
// fix, the assistant message was only ever saved ONCE, at the very end,
// using ctx — so a disconnect mid-turn silently lost the ENTIRE turn's
// tool-call history from the persisted transcript, even though real work
// (executions launched, knowledge written) had already happened
// server-side. The agent's own work (a.chat below) still correctly stops
// when ctx cancels — this only changes what happens to persistence.
pctx := context.Background()
if sessionID == "" {
title := truncate(req.Message, 80)
sess, err := st.CreateSession(pctx, title)
if err != nil {
slog.Error("nomos: create session", "error", err)
sessionID = "ephemeral"
} else {
sessionID = sess.ID
}
} else {
// P2 iteration: if the operator sends a follow-up on a session
// that already reached a terminal state (done/failed), reopen it
// so a new sub-task can be framed (set_goal → propose_plan →
// execute). reopenSession marks the prior plan's steps as
// `replaced` (proposePlan ignores those) and clears outcome/
// summary. Without this, propose_plan refuses the follow-up with
// ErrPlanInFlight because the prior steps are all `done`. If the
// session is still active, reopen is a no-op — the follow-up is
// just a continuation of in-flight work.
st.ReopenSession(pctx, sessionID)
st.TouchSession(pctx, sessionID)
}
slog.Info("nomos: chat", "session", sessionID, "message", truncate(req.Message, 100))
userMsg, _ := json.Marshal(map[string]any{"role": "user", "text": req.Message})
st.SaveMessage(pctx, sessionID, "user", userMsg)
// If this task has a pending operator question, the incoming message IS the
// answer — close it so the panel clears. No separate resume needed: this
// chat turn is the resume, and the agent sees the question + answer in its
// replayed history.
if qid := st.OpenQuestionID(pctx, sessionID); qid != "" {
st.AnswerQuestion(pctx, sessionID, qid, req.Message)
}
writeEvent(agentEvent{Type: "session", Data: sessionID, SessionID: sessionID})
// F1/F2 (plan 2026-08-03): serialize turns per session. The user message is
// already persisted above, so it is never lost. Wait briefly for a finishing
// background turn; if one is still running after that, QUEUE this message
// (don't reject it) and tell the client so it shows a "queued" state. The
// in-flight turn's release drains the queue (drainQueued) and runs it as a
// real turn server-side. This never stacks concurrent turns — the gate still
// guarantees one in-flight turn per session.
const turnWait = 5 * time.Second
if !a.gate.Acquire(sessionID, turnWait) {
a.queue.Enqueue(sessionID, req.Message)
slog.Info("nomos: turn already active, queued operator message", "session", sessionID)
writeEvent(agentEvent{Type: "queued", Data: sessionID, SessionID: sessionID})
writeEvent(agentEvent{Type: "done", Data: map[string]any{
"session_id": sessionID,
"queued": true,
}, SessionID: sessionID})
return
}
defer func() {
a.gate.Release(sessionID)
// Run any message that was queued while this turn held the gate. In a
// goroutine so the HTTP response finishes without waiting on the next
// turn; the queued turn has no SSE client of its own.
safego.Go("nomos:drain:"+sessionID, func() { a.drainQueued(context.Background(), sessionID) })
}()
// F3 (plan 2026-08-03): keep the SSE alive during long turns. A turn can
// run for many minutes (provisioning chains, deep research); the model
// often takes 20-40s between tool iterations, and with nothing flushed in
// that gap a proxy/browser idle timeout silently closes the stream. The
// client then sees streaming=false while the server keeps working — the
// "I can't tell it's working" desync. An SSE comment line (":keepalive") is
// ignored by EventSource but resets idle timers.
keepDone := make(chan struct{})
go func() {
t := time.NewTicker(12 * time.Second)
defer t.Stop()
for {
select {
case <-keepDone:
return
case <-t.C:
writeMu.Lock()
fmt.Fprintf(w, ":keepalive\n\n")
flusher.Flush()
writeMu.Unlock()
}
}
}()
// Defer the close (not a statement after runChatTurn) so the goroutine
// exits even if runChatTurn panics — net/http recovers handler panics, so
// a non-deferred close would be skipped and the ticker would keep writing
// to a dead ResponseWriter forever.
defer close(keepDone)
a.runChatTurn(pctx, ctx, sessionID, req.Message, func(ev agentEvent) {
writeEvent(ev)
})
}
func handleSessionsList(w http.ResponseWriter, r *http.Request, st *session.Store) {
if st == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"sessions": []any{}})
return
}
if r.Method == http.MethodOptions {
return
}
// P2.8 (2026-07-20): filtering + pagination. The audit script in
// .agents/skills/session-review/SKILL.md slices `.sessions[:10]`
// client-side; "show me partial sessions touching lxc:rclone"
// required fetching the full list and filtering in JS. Push the
// filters into SQL so the audit becomes a single `curl | jq`.
// Supported query params (all optional, composable):
// ?outcome=partial|success|failure — exact match on outcome
// ?status=active|done|failed|executing — exact match on status
// ?entity_id=<uuid> — exact match on entity_id
// ?since=<RFC3339 or duration> — last_active_at >= ...
// ?blocker=<reason> — exact match on blocker
// ?limit=<int> — default 50, max 200
// ?cursor=<iso timestamp> — last_active_at < cursor (page back)
q := r.URL.Query()
limit := 50
if v := q.Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
limit = n
}
}
sessions, err := st.ListSessionsFiltered(r.Context(), session.ListFilter{
Outcome: q.Get("outcome"),
Status: q.Get("status"),
EntityID: q.Get("entity_id"),
Blocker: q.Get("blocker"),
Since: q.Get("since"),
Cursor: q.Get("cursor"),
Limit: limit,
})
if err != nil {
http.Error(w, err.Error(), 500)
return
}
// Next-page cursor: the oldest last_active_at in this page. The next
// request passes it as ?cursor=... to get the page before it. Empty
// when the list is exhausted.
var nextCursor string
if len(sessions) > 0 {
oldest := sessions[len(sessions)-1].LastActiveAt
nextCursor = oldest.UTC().Format(time.RFC3339Nano)
if len(sessions) < limit {
nextCursor = "" // last page
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"sessions": sessions,
"next_cursor": nextCursor,
"limit": limit,
})
}
func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *session.Store, a *agent) {
if st == nil {
http.Error(w, "not found", 404)
return
}
rest := strings.TrimPrefix(r.URL.Path, "/sessions/")
parts := strings.Split(rest, "/")
id := parts[0]
if id == "" {
http.Error(w, "session id required", 400)
return
}
// POST /sessions/{id}/questions/{qid}/answer — the operator answers a
// pinned question from the context panel; resume the agent with the answer.
if len(parts) == 4 && parts[1] == "questions" && parts[3] == "answer" {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405)
return
}
handleAnswerQuestion(w, r, st, a, id, parts[2])
return
}
// POST /sessions/{id}/resume — the operator asks the agent to continue.
if len(parts) == 2 && parts[1] == "resume" && r.Method == http.MethodPost {
base := "[System: the operator wants you to continue. Pick up where you left off — execute the next step of the plan, diagnose and fix any failures, or report progress if everything is done.]"
note := st.EnrichResumeNote(context.Background(), id, base)
safego.Go("nomos:resume-session", func() { a.resumeSession(context.Background(), id, note) })
w.WriteHeader(202)
return
}
// GET /sessions/{id}/plan and /sessions/{id}/questions — REST hydration for
// the context panel when it first opens a task; live events carry deltas
// from there.
// GET /sessions/{id}/tool_calls — flat view of every tool call in the
// session, without the two-level message-shell nesting. The audit at
// plans/2026-07-20-session-review-ten-sessions.md P2.10 had to write
// Python to walk messages[].content.tool_calls[]; this endpoint makes
// it a single `curl | jq`.
if len(parts) == 2 && r.Method == http.MethodGet {
switch parts[1] {
case "plan":
all := r.URL.Query().Has("all") && r.URL.Query().Get("all") != "0" && r.URL.Query().Get("all") != "false"
steps, err := st.GetPlanSteps(r.Context(), id, all)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"steps": steps})
return
case "questions":
questions, err := st.GetQuestions(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"questions": questions})
return
case "tool_calls":
calls, err := st.GetSessionToolCalls(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"session_id": id, "tool_calls": calls})
return
}
}
switch r.Method {
case http.MethodDelete:
if err := st.DeleteSession(r.Context(), id); err != nil {
http.Error(w, err.Error(), 500)
return
}
w.WriteHeader(204)
case http.MethodGet:
// P2.7 (2026-07-20): return BOTH session metadata and messages
// from GET /sessions/{id}. Previously this endpoint returned only
// {session_id, messages} — the operator had to merge with the
// /sessions list view to get title/goal/outcome. The eval harness
// at cmd/nomos/eval/main.go:302-303 already carries a comment
// about this leaky abstraction. The session field carries the
// full metadata: title, goal, outcome, summary, blocker,
// pending_approvals, message_count, tool_call_count, etc. The
// messages field is unchanged. Clients that only read
// `messages` keep working.
sess, err := st.GetSession(r.Context(), id)
if err != nil {
if err == pgx.ErrNoRows {
http.Error(w, "session not found", 404)
return
}
http.Error(w, err.Error(), 500)
return
}
messages, err := st.GetMessages(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"session_id": id,
"session": sess,
"messages": messages,
})
default:
http.Error(w, "method not allowed", 405)
}
}
// handleAnswerQuestion records the operator's answer to a pinned question and
// resumes the agent in the background with that answer injected. Returns 202 —
// the agent's response lands via the normal message-polling path, not this POST.
func handleAnswerQuestion(w http.ResponseWriter, r *http.Request, st *session.Store, a *agent, sessionID, questionID string) {
var req struct {
Answer string `json:"answer"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Answer) == "" {
http.Error(w, "answer is required", 400)
return
}
prompt, _, _ := st.GetQuestion(r.Context(), questionID)
if err := st.AnswerQuestion(r.Context(), sessionID, questionID, req.Answer); err != nil {
http.Error(w, err.Error(), 500)
return
}
if a != nil {
base := fmt.Sprintf("[System: the operator answered your question %q with: %q. "+
"Continue the task from here — do not re-ask.]", prompt, req.Answer)
note := st.EnrichResumeNote(context.Background(), sessionID, base)
safego.Go("nomos:resume-session", func() { a.resumeSession(context.Background(), sessionID, note) })
}
w.WriteHeader(202)
}
func handleQuery(w http.ResponseWriter, r *http.Request, pool *mcpClientPool, agentSlug, mcpURL string) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405)
return
}
var req struct {
Query string `json:"query"`
Tool string `json:"tool"`
Args map[string]any `json:"args"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request: "+err.Error(), 400)
return
}
// The structured /query endpoint is stateless/session-less — "query" is a
// fixed pool key (not a real session id) so repeated calls reuse one
// dedicated connection instead of paying a fresh MCP handshake every time,
// while still never sharing a connection with an actual chat task.
client, err := pool.get("query")
if err != nil {
http.Error(w, "mcp unavailable: "+err.Error(), 502)
return
}
start := time.Now()
if req.Tool != "" {
result, err := client.callTool(req.Tool, req.Args)
duration := time.Since(start).Milliseconds()
if err != nil {
slog.Error("nomos: query failed", "tool", req.Tool, "error", err)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"error": err.Error(),
"elapsed_ms": duration,
"agent_slug": agentSlug,
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"result": result,
"elapsed_ms": duration,
"agent_slug": agentSlug,
"mcp_url": mcpURL,
})
return
}
if req.Query != "" {
if strings.Contains(strings.ToLower(req.Query), "what can you do") ||
strings.Contains(strings.ToLower(req.Query), "help") {
tools, err := client.listTools()
duration := time.Since(start).Milliseconds()
if err != nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"error": err.Error(),
"elapsed_ms": duration,
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"message": "natural language queries belong to /chat. Use structured /query with 'tool' for direct MCP calls.",
"tools": tools,
"elapsed_ms": duration,
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"message": "natural language queries belong to /chat. Use structured /query with 'tool' for direct MCP calls.",
"elapsed_ms": time.Since(start).Milliseconds(),
})
return
}
http.Error(w, "either 'tool' or 'query' required", 400)
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}

View File

@@ -1,510 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"regexp"
"strings"
"time"
"github.com/dtoro/oikos/internal/nomos/session"
)
// Task tools are nomos-LOCAL, not MCP tools. They are session-scoped, and the
// shared MCP server (api:8090/mcp) has no session id — so these are handled
// in-process by nomos, which knows the session/task and holds the store.
// buildTools appends these to the model's tool list; the agent loop routes a
// call whose name isTaskTool to handleTaskTool instead of the MCP client.
//
// Phase 3 ships complete_task; set_goal / propose_plan / update_plan_step /
// ask_operator land in later phases through the same mechanism.
func taskToolDefs() []toolDef {
return []toolDef{
{
Name: "set_goal",
Description: "State the goal of this task in one sentence, as early as you " +
"can. This is what the task is trying to achieve (e.g. 'Deploy TypeType " +
"as an LXC on strong'); it heads the task on the board and the context " +
"panel. Call it once you understand what the operator wants.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"goal": map[string]any{"type": "string", "description": "The task's goal, one sentence."},
},
"required": []string{"goal"},
},
},
{
Name: "propose_plan",
Description: "Propose the full ordered plan for this task. Call ONCE, before any " +
"execution, with EVERY step end-to-end (not one step at a time). FIRST step: " +
"research (prior knowledge, relations, blast radius). If your plan runs `run` " +
"against any target, include a LAST step: write back " +
"(update_entity_attributes + create_relationship + upsert_knowledge) — if you " +
"omit it, one is auto-appended. After this call: STOP and wait for operator " +
"approval (approval vocabulary: approved, yes, go, proceed, continue, ok, " +
"go ahead). Once a step has started (running/done/...), this tool REFUSES " +
"further calls — advance with update_plan_step + run instead. Re-propose only " +
"if the operator explicitly asks you to revise the whole plan. complete_task " +
"with outcome=success is REFUSED if you ran `run` but didn't call " +
"update_entity_attributes/create_relationship — write back before completing.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"steps": map[string]any{
"type": "array",
"description": "Ordered steps, first to last.",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"title": map[string]any{"type": "string", "description": "Short imperative step title (e.g. 'Create the LXC')."},
"detail": map[string]any{"type": "string", "description": "Optional one-line detail."},
"target_slug": map[string]any{"type": "string", "description": "Optional entity slug this step acts on (e.g. lxc:typetype)."},
},
"required": []string{"title"},
},
},
},
"required": []string{"steps"},
},
},
{
Name: "update_plan_step",
Description: "Advance a plan step as you work it. Set status to 'running' when " +
"you start it (pass execution_id if the step queued a gated action, so " +
"the board can auto-close it when that finishes), then 'done' / 'failed' " +
"/ 'skipped' / 'blocked' when it resolves. Keeps the operator's progress " +
"view honest.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"seq": map[string]any{"type": "integer", "description": "1-based step number from propose_plan."},
"status": map[string]any{"type": "string", "enum": []string{"running", "done", "failed", "skipped", "blocked"}, "description": "New status for the step."},
"execution_id": map[string]any{"type": "string", "description": "Optional execution UUID this step is running, so it auto-closes on completion."},
},
"required": []string{"seq", "status"},
},
},
{
Name: "ask_operator",
Description: "Ask the operator a question when you hit a real decision only " +
"they can make — an ambiguous target, a trade-off, missing information, " +
"or a destructive choice not already approved. This pins a structured " +
"question card in the context panel (with your options and the entities " +
"involved) and PAUSES the task until they answer; their answer resumes " +
"you automatically. Do NOT use it for things you can determine yourself " +
"with tools — only for genuine decisions.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"prompt": map[string]any{"type": "string", "description": "The question, stated plainly."},
"why": map[string]any{"type": "string", "description": "Why you're asking / what's at stake."},
"options": map[string]any{
"type": "array", "items": map[string]any{"type": "string"},
"description": "The choices, if it's a pick-one decision.",
},
"context_entities": map[string]any{
"type": "array", "items": map[string]any{"type": "string"},
"description": "Entity slugs relevant to the decision (shown as chips).",
},
},
"required": []string{"prompt"},
},
},
{
Name: "complete_task",
Description: "Mark the current task finished. Call this once the goal is " +
"verified done — or when you've genuinely failed or only partially " +
"succeeded. Sets the task's outcome and a one-line summary shown on the " +
"task board. Record what you learned with upsert_knowledge BEFORE " +
"completing, so future tasks on the same entities benefit.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"outcome": map[string]any{
"type": "string",
"enum": []string{"success", "failure", "partial"},
"description": "Did the task achieve its goal?",
},
"summary": map[string]any{
"type": "string",
"description": "One line describing the result (shown on the task card).",
},
},
"required": []string{"outcome", "summary"},
},
},
}
}
// toInt coerces a JSON tool-arg number (float64 after unmarshal) to int.
func toInt(v any) int {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
default:
return 0
}
}
// toStringSlice coerces a JSON tool-arg array to a non-empty []string.
func toStringSlice(v any) []string {
arr, ok := v.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(arr))
for _, e := range arr {
if s, ok := e.(string); ok && strings.TrimSpace(s) != "" {
out = append(out, s)
}
}
return out
}
// handleTaskTool executes a nomos-local task tool. Returns (result, true) if it
// handled the call, or (nil, false) if name is not a local task tool (so the
// caller forwards it to the MCP client).
func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args map[string]any) (any, bool) {
switch name {
case "set_goal":
goal, _ := args["goal"].(string)
if strings.TrimSpace(goal) == "" {
return "error: set_goal needs a goal", true
}
if err := a.store.SetGoal(ctx, sessionID, goal); err != nil {
return fmt.Sprintf("error setting goal: %v", err), true
}
// P1: the plan window is NOT opened here. Opening it on set_goal
// meant any config_mutation `run` auto-executed with zero operator
// approval, before a plan was even proposed (let alone approved) —
// a safety regression confirmed live in session d0d562e0. The
// window is now opened only when the operator approves a plan
// (chat-assent grant or explicit approval in agent.go), which is
// what the SOUL.md "approve the plan, not each step" model actually
// describes. set_goal records the goal + flips status to executing
// and nothing more.
response := "Goal set: " + goal + ". NEXT: pre-plan with read-only tools (search_knowledge, get_entity, list_lxcs, get_relations), then propose_plan (mandatory — even read-only tasks need a one-step plan; the run handler refuses without one). After propose_plan: if all steps are read-only, execute immediately (no approval needed). If any step is config_mutation/destructive, stop and wait for operator approval."
// P1.3 (2026-07-20): surface prior partial/failed sessions for the
// same problem so the agent can pick up the thread instead of
// rediscovering it. Three rclone sessions (a51e2086, 8acea2e3,
// cb8c8a4a) all bounced off the classifier because each new session
// started from scratch. The agent gets a hint with the prior
// goal + summary; if it looks related, search_knowledge or open
// the prior session's transcript (GET /sessions/{id}) before
// re-planning. See plans/2026-07-20-session-review-ten-sessions.md.
prior, _ := a.store.RecentPartialSessions(ctx, sessionID, 24*time.Hour)
if len(prior) > 0 {
var b strings.Builder
b.WriteString("\n\nNOTE — recent unfinished sessions (last 24h, outcome=partial/failed):")
for i, p := range prior {
if i >= 5 {
b.WriteString(fmt.Sprintf("\n ...and %d more", len(prior)-5))
break
}
sum := p.Summary
if sum == "" {
sum = "(no summary)"
}
if len(sum) > 200 {
sum = sum[:200] + "..."
}
b.WriteString(fmt.Sprintf("\n - %s (sid %s, outcome=%s): %s",
p.Goal, p.ID[:8], p.Outcome, sum))
}
b.WriteString("\nIf any of these looks like the same problem, search_knowledge for the prior investigation or read it via GET /sessions/{id} before re-planning — don't rediscover what was already learned.")
response += b.String()
}
return response, true
case "propose_plan":
raw, _ := args["steps"].([]any)
var steps []session.PlanStepInput
for _, r := range raw {
m, ok := r.(map[string]any)
if !ok {
continue
}
title, _ := m["title"].(string)
if strings.TrimSpace(title) == "" {
continue
}
detail, _ := m["detail"].(string)
target, _ := m["target_slug"].(string)
steps = append(steps, session.PlanStepInput{Title: title, Detail: detail, TargetSlug: target})
}
if len(steps) == 0 {
return "error: propose_plan needs at least one step with a title", true
}
// D.2: auto-append a writeback step if the agent didn't include one.
// The agent consistently writes vague last steps ("record findings")
// and then skips update_entity_attributes entirely (the #1 cause of
// knowledge-graph drift). Appending an explicit writeback step makes
// the seq-order enforcement (5.6) require it to be completed last,
// and D.1's complete_task gate enforces the actual calls. Together
// they close the loop structurally — neither relies on the agent
// reading SOUL.md. The match is broadened past the literal tool
// names so a natural-language step ("Write back: update entity
// attributes…") isn't doubled by an auto-appended duplicate (P1.2).
hasWritebackStep := false
for _, st := range steps {
t := strings.ToLower(st.Title + " " + st.Detail)
if strings.Contains(t, "update_entity_attributes") ||
strings.Contains(t, "create_relationship") ||
strings.Contains(t, "upsert_knowledge") ||
strings.Contains(t, "write back") ||
strings.Contains(t, "writeback") {
hasWritebackStep = true
break
}
}
appendedNote := ""
if !hasWritebackStep {
steps = append(steps, session.PlanStepInput{
Title: "Write back: update_entity_attributes + create_relationship + upsert_knowledge",
Detail: "Call update_entity_attributes for every entity you ran against (versions, states, counts, timestamps). Call create_relationship for any edge you discovered. Then upsert_knowledge about the affected entities (pass `about` as an array).",
})
appendedNote = fmt.Sprintf(" (appended a writeback step — your plan didn't include one; step %d)", len(steps))
}
persisted, err := a.store.ProposePlan(ctx, sessionID, steps)
if err != nil {
if errors.Is(err, session.ErrPlanInFlight) {
// The plan is already in flight — refuse the re-proposal.
// The agent must advance the existing plan with
// update_plan_step + run. This is the structural fix for
// the "plan added twice" sidebar drift the operator
// reported: instead of appending (which duplicated) or
// wiping (which lost progress), we refuse and direct.
return "Plan already in flight — refusing duplicate proposal. Steps exist and at least one has started (running/done/...). To advance: call update_plan_step(seq=K, status=\"running\") then run(...) for step K's target, then update_plan_step(seq=K, status=\"done\"). Do not call propose_plan again. Re-propose only if the operator explicitly asks you to revise the whole plan (the session is reopened on a follow-up — prior steps are marked `replaced` and a fresh generation is started), and say so in your reply before calling it.", true
}
return fmt.Sprintf("error proposing plan: %v", err), true
}
// The writeback step is now always present (D.2 auto-appends it if
// the agent forgot), so the old advisory nudge is replaced by the
// structural gate: D.1 refuses complete_task without the actual
// update_entity_attributes/create_relationship calls. Enumerate the
// step seqs so the model knows exactly which numbers to address with
// update_plan_step (seq is 1-based within this plan — the addressing
// key, not a global counter).
var seqs strings.Builder
for i, p := range persisted {
if i > 0 {
seqs.WriteString("; ")
}
title := fmt.Sprint(p["title"])
fmt.Fprintf(&seqs, "%v=%s", p["seq"], title)
}
result := fmt.Sprintf("Plan set (%d steps): %s.%s Address them with update_plan_step(seq=N). If all steps are read-only, execute now — call update_plan_step(running) + run for each step, no approval needed. If any step is config_mutation/destructive, STOP and wait for operator approval (\"approved\", \"yes\", \"go\", \"proceed\", \"continue\", \"ok\", \"go ahead\"). Do not call propose_plan again.", len(persisted), seqs.String(), appendedNote)
return result, true
case "update_plan_step":
seq := toInt(args["seq"])
status, _ := args["status"].(string)
execID, _ := args["execution_id"].(string)
if seq <= 0 || status == "" {
return "error: update_plan_step needs seq (>=1) and status", true
}
reason, _ := args["replaced_reason"].(string)
if err := a.store.UpdatePlanStep(ctx, sessionID, seq, status, execID, reason); err != nil {
if errors.Is(err, session.ErrPlanStepNotFound) {
// The seq doesn't address a step in the CURRENT plan — most
// often a stale 1-based number the model carried across a
// re-plan, or an out-of-range seq. seq is generation-relative
// (1..N within the latest propose_plan), so a superseded
// generation's row is never touched (P0.1 fix 3). Direct the
// model instead of silently no-op'ing.
return fmt.Sprintf("Step %d is not in the current plan. seq is 1-based within your latest propose_plan (a re-plan resets it to 1..N, so an old step number no longer applies). The plan was not changed. Re-address with the correct 1-based seq, or if you've lost track, re-read the plan.", seq), true
}
return fmt.Sprintf("error updating step %d: %v", seq, err), true
}
return fmt.Sprintf("Step %d → %s. (Advance with update_plan_step + run; do not re-propose.)", seq, status), true
case "ask_operator":
prompt, _ := args["prompt"].(string)
if strings.TrimSpace(prompt) == "" {
return "error: ask_operator needs a prompt", true
}
qctx := map[string]any{}
if why, _ := args["why"].(string); strings.TrimSpace(why) != "" {
qctx["why"] = why
}
if opts := toStringSlice(args["options"]); len(opts) > 0 {
qctx["options"] = opts
}
if ents := toStringSlice(args["context_entities"]); len(ents) > 0 {
qctx["entities"] = ents
}
if _, err := a.store.AskOperator(ctx, sessionID, prompt, qctx); err != nil {
return fmt.Sprintf("error posting question: %v", err), true
}
return "Question posted to the operator; the task is paused until they answer. " +
"Do not continue or call more tools — end your turn now and wait for their answer.", true
case "complete_task":
outcome, _ := args["outcome"].(string)
summary, _ := args["summary"].(string)
switch outcome {
case "":
outcome = "success" // no outcome given at all — assume success, the common case
case "success", "failure", "partial":
// valid, use as-is
default:
// The tool schema declares an enum, but a weaker model (or a
// typo) can still send anything — an unrecognized value used to
// persist as-is, silently, with only "failure" special-cased
// (store.completeTask derives status='failed' from it; anything
// else became status='done' regardless of what the value
// actually said). Default to "partial" rather than silently
// treating an unrecognized value as "success" — safer to
// under-claim than over-claim a task's outcome.
slog.Warn("nomos: complete_task got an unrecognized outcome, defaulting to partial",
"session", sessionID, "outcome", outcome)
outcome = "partial"
}
// D.1: refuse success when discovery ran but no writeback followed.
// The prior advisory warning (below) was ignorable — the agent
// saw it and ended the task anyway. This gate fires BEFORE
// completeTask runs, so the session stays in 'executing' state
// and the agent must call update_entity_attributes/create_relationship
// then retry complete_task. Only blocks `success`; an explicit
// `failure` or `partial` is allowed through (the agent is
// acknowledging it didn't finish — no reason to force writeback).
if outcome == "success" && a.store.HadDiscovery(ctx, sessionID) && !a.store.HadEntityWriteback(ctx, sessionID) {
return "Refused: this session ran `run` against live targets (discovery) but did not call update_entity_attributes or create_relationship to persist what you learned. The knowledge graph will drift if you complete without writeback. Call update_entity_attributes for each entity you ran against (versions, states, counts, timestamps), and create_relationship for any edge you discovered, then call complete_task again. Outcome is held at 'executing' until you do.", true
}
// D.2: refuse success when the goal mentions a reachability/uptime
// check but no verification was done. The agent can't claim "X is
// reachable" based on a shell command alone — the proxy (Caddy) can
// return 200 for a terminal page (ttyd) or fallback while the actual
// dashboard is still down. Must call ping_service or run a successful
// curl before claiming success.
if outcome == "success" && a.store.HadDiscovery(ctx, sessionID) {
goal := a.store.SessionGoal(ctx, sessionID)
if mentionsReachability(goal) && !a.store.HadRecentVerification(ctx, sessionID) {
return "Refused: the goal involves a reachability or uptime check (\"make X reachable\", \"get X up\", etc.), but no ping_service call or successful curl/HTTP request against the target was detected. Caddy can return 200 for a terminal or fallback page while the actual service is still down — you must verify the service itself, not just the proxy. Call ping_service(target) or run a curl against the actual service URL, then call complete_task again. Outcome held until verified.", true
}
}
if err := a.store.CompleteTask(ctx, sessionID, outcome, summary); err != nil {
if errors.Is(err, session.ErrTaskAlreadyComplete) {
return "Task is already complete. Do not call complete_task again. If the operator pointed out a UI/sidebar inconsistency, fix it with update_plan_step (reconcile step states) or summarize the panel in your reply — do not re-execute the work.", true
}
return fmt.Sprintf("error completing task: %v", err), true
}
result := fmt.Sprintf("Task marked %s: %s", outcome, summary)
if !a.store.HadEntityWriteback(ctx, sessionID) {
result += "\n\n⚠ No entity attributes or relationships were updated in this session. Call update_entity_attributes and create_relationship to persist what you learned about entities before the next session starts from scratch."
}
return result, true
default:
return nil, false
}
}
// reachabilityPatterns matches goal text that involves making something
// reachable/accessible/working. Used by complete_task to surface a soft
// warning when the session goal was about reachability but no verification
// occurred before marking success.
var reachabilityPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)https?://[^\s]+`),
regexp.MustCompile(`(?i)\.hubris\.net\w+`),
regexp.MustCompile(`(?i)(un)?reachable`),
regexp.MustCompile(`(?i)(not?\s+)?(accessible|reachable|responding|resolving)`),
regexp.MustCompile(`(?i)diagnose\s+why`),
regexp.MustCompile(`(?i)(fix|restore|bring\s+back).*(accessible|reachable|online)`),
}
func mentionsReachability(goal string) bool {
for _, p := range reachabilityPatterns {
if p.MatchString(goal) {
return true
}
}
return false
}
// autoCompleteTrivialTask is the case-1 fix from
// plans/2026-07-11-task-completion-safety-net.md: a session that never
// called set_goal never framed itself as a structured task, so a turn that
// ends with a plain-text answer and no further tool calls IS the task
// ending — but the model consistently skips complete_task for exactly this
// case (confirmed live: 43/50 production sessions were a single trivial
// Q&A exchange, none of which ever reached a terminal status). Rather than
// leave agent_sessions.status stuck at its creation-time default forever,
// close it out mechanically here: no judgment call needed, since SOUL.md
// already treats a one-shot answered question as done by definition.
func (a *agent) autoCompleteTrivialTask(ctx context.Context, sessionID, responseText string) {
summary := strings.TrimSpace(responseText)
summary = strings.SplitN(summary, "\n", 2)[0] // first line only — the board shows one line
const maxLen = 120
if len(summary) > maxLen {
summary = summary[:maxLen] + "…"
}
if summary == "" {
summary = "Answered without further action needed."
}
if err := a.store.CompleteTask(ctx, sessionID, "success", summary); err != nil {
slog.Error("nomos: auto-complete trivial task failed", "session", sessionID, "error", err)
}
}
// autoCompleteIfPlanDone is the structural safety net for "the agent did the
// work but forgot to call complete_task" — the #1 remaining model reliability
// gap after D.1's writeback gate. After a turn ends, if the session has a goal,
// the agent never called complete_task this turn, and either (a) all plan
// steps are terminal OR (b) the agent did discovery (ran `run`), auto-complete.
// Path (b) catches the common case where the agent skips update_plan_step
// bookkeeping but still does the actual work — the D.1 gate already enforces
// writeback before `complete_task`, so if the agent forgot to complete at all,
// we close it out mechanically. If writeback happened → success; if not →
// partial (honest: work was done but knowledge graph wasn't updated).
func (a *agent) autoCompleteIfPlanDone(ctx context.Context, sessionID, responseText string) {
if a.store == nil || sessionID == "" || sessionID == "ephemeral" {
return
}
sess, err := a.store.GetSession(ctx, sessionID)
if err != nil || sess.Status != "executing" {
return
}
// Don't auto-complete if there are pending approvals — the agent is
// blocked waiting for the operator, not done. Auto-completing here
// would close the session and the operator's approval would land on a
// dead task. Confirmed in eval: agent hits P5 approval gate, turn
// ends, auto-complete fires incorrectly because the approval-queue
// `run` responses were logged as success=true in agent_activity.
if a.store.HasPendingApprovals(ctx, sessionID) {
return
}
discovery := a.store.HadDiscovery(ctx, sessionID)
writeback := a.store.HadEntityWriteback(ctx, sessionID)
// (a) all plan steps terminal, OR (b) agent did discovery (ran `run`).
shouldComplete := a.store.AllPlanStepsTerminal(ctx, sessionID)
if !shouldComplete && discovery {
shouldComplete = true
}
if !shouldComplete {
return
}
outcome := "success"
if discovery && !writeback {
outcome = "partial" // honest: work done, knowledge graph not updated
}
summary := strings.TrimSpace(responseText)
summary = strings.SplitN(summary, "\n", 2)[0]
const maxLen = 120
if len(summary) > maxLen {
summary = summary[:maxLen] + "…"
}
if summary == "" {
summary = "All plan steps completed."
}
if err := a.store.CompleteTask(ctx, sessionID, outcome, summary); err != nil {
slog.Error("nomos: auto-complete plan-done task failed", "session", sessionID, "error", err)
} else {
slog.Info("nomos: auto-completed task — agent didn't call complete_task", "session", sessionID, "outcome", outcome)
}
}

View File

@@ -1,152 +0,0 @@
package main
import (
"context"
"encoding/json"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
)
// runChatTurn is the shared core of an operator-initiated turn: insert an
// assistant placeholder, run a.chat with incremental persistence (so whatever
// happened before an abort is never lost), finalize the row, and derive a
// title. It is agnostic to the transport: `sink` receives every agent event
// for delivery (SSE for a live handleChat, a no-op for a queued turn that has
// no client attached — the frontend learns about those via the poller + the
// status-driven "working" signal). The caller MUST already hold the session's
// turn-gate permit.
func (a *agent) runChatTurn(pctx, ctx context.Context, sessionID, message string, sink func(agentEvent)) {
toolCalls := []map[string]any{}
// P3: accumulate per-iteration reasoning instead of overwriting with the
// final `text` event (see the original inline comment in handleChat).
var textParts []string
var thinkingParts []string
var finalText string
var finalThinking string
placeholder, _ := json.Marshal(map[string]any{"role": "assistant", "text": ""})
msgID, err := a.store.InsertMessageReturningID(pctx, sessionID, "assistant", placeholder)
if err != nil {
slog.Error("nomos: chat placeholder insert failed", "session", sessionID, "error", err)
}
persist := func() {
if msgID == uuid.Nil {
return
}
body, _ := json.Marshal(map[string]any{
"role": "assistant",
"text": finalText,
"thinking": finalThinking,
"tool_calls": toolCalls,
})
a.store.UpdateMessage(pctx, msgID, body)
}
a.chat(ctx, sessionID, message, func(ev agentEvent) {
if ev.Type == "tool_use" || ev.Type == "tool_result" {
if m, ok := ev.Data.(map[string]any); ok {
m["type"] = ev.Type
// One entry per tool call: tool_use creates it, tool_result
// merges the result into the same entry (matched by id).
id, _ := m["id"].(string)
if id != "" && ev.Type == "tool_result" {
for _, existing := range toolCalls {
if eID, _ := existing["id"].(string); eID == id {
for k, v := range m {
existing[k] = v
}
break
}
}
} else {
toolCalls = append(toolCalls, m)
}
}
persist() // live: survives even if the client disconnects right after
}
if ev.Type == "text" {
if t, ok := ev.Data.(string); ok && t != "" {
if ev.IsThinking {
thinkingParts = append(thinkingParts, t)
finalThinking = strings.Join(thinkingParts, "\n\n")
} else {
textParts = append(textParts, t)
finalText = strings.Join(textParts, "\n\n")
}
persist()
}
}
sink(ev)
})
// B.6: if the turn ended with no text and no tool calls (the model
// empty-response'd and all retries failed), delete the placeholder row
// instead of persisting an empty bubble.
if finalText == "" && len(toolCalls) == 0 && msgID != uuid.Nil {
a.store.DeleteMessage(pctx, msgID)
} else {
persist() // final state — same row, updated one last time
}
// Title: prefer the goal once set; else the first assistant answer.
if finalText != "" && sessionID != "ephemeral" {
var goalTitle string
if sess, gerr := a.store.GetSession(pctx, sessionID); gerr == nil && sess.Goal != "" {
goalTitle = truncate(sess.Goal, 120)
}
title := goalTitle
if title == "" {
title = truncate(finalText, 80)
}
if title != "" {
a.store.UpdateSessionTitle(pctx, sessionID, title)
}
}
}
// drainAcquireWait is how long drainQueued blocks for a busy gate before
// re-queuing and deferring to the holder's own release-drain. A package var so
// tests can shorten it; in production it just needs to outlast the brief
// release→drain handoff window.
var drainAcquireWait = 5 * time.Second
// drainQueued runs every queued operator message for a session as its own turn,
// one at a time, under the turn gate. Called (in a goroutine) whenever a turn
// releases the gate — from handleChat (live) and resumeSession (background) —
// so a message queued while the agent was busy is acted on as soon as it's
// free, without the operator re-sending. See messagequeue.go (plan 2026-08-03
// F2).
//
// Each queued turn is persisted incrementally and has no SSE client (the
// browser detached after receiving the `queued` event); the frontend sees the
// result via the 3s poller and the status-driven "working" indicator.
func (a *agent) drainQueued(ctx context.Context, sessionID string) {
for {
msg, ok := a.queue.Dequeue(sessionID)
if !ok {
return
}
// Block briefly for the gate. If a live turn grabbed it first, put the
// message back — that turn's release will drain it again. Never stack.
if !a.gate.Acquire(sessionID, drainAcquireWait) {
a.queue.RequeueFront(sessionID, msg)
return
}
slog.Info("nomos: running queued operator message", "session", sessionID)
pctx := context.Background()
// Run the turn inside a per-iteration closure so the gate release is
// deferred to the end of THIS turn (and runs even if runChatTurn
// panics — safego recovers the panic at the goroutine boundary, so a
// non-deferred release would be skipped and the session's permit held
// forever, deadlocking all future turns). A bare `defer release` in
// the loop would be wrong too: Go defers run at function exit, not
// iteration exit, so the gate would stay held across iterations.
func() {
defer a.gate.Release(sessionID)
a.runChatTurn(pctx, ctx, sessionID, msg, func(agentEvent) {})
}()
}
}

View File

@@ -150,7 +150,7 @@ Roles:
knowledge Convert wiki to knowledge seed (one-shot) knowledge Convert wiki to knowledge seed (one-shot)
version Print version info version Print version info
The operator interface is Nomos (MCP agent) — no CLI needed. The operator interface is dsh (MCP agent) — no CLI needed.
Environment: Environment:
OIKOS_DATABASE_URL Postgres connection string OIKOS_DATABASE_URL Postgres connection string
OIKOS_API_LISTEN API listen address (default :8090) OIKOS_API_LISTEN API listen address (default :8090)

View File

@@ -17,21 +17,84 @@ import (
"github.com/dtoro/oikos/internal/safego" "github.com/dtoro/oikos/internal/safego"
) )
type webhookHandler struct {
secret string
script string
}
func (h *webhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read body failed", 400)
return
}
sigHex := r.Header.Get("X-Hub-Signature-256")
if sigHex == "" {
http.Error(w, "missing signature", 401)
return
}
mac := hmac.New(sha256.New, []byte(h.secret))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(sigHex), []byte(expected)) {
slog.Warn("webhook: invalid signature")
http.Error(w, "invalid signature", 401)
return
}
slog.Info("webhook: deploy triggered", "script", h.script)
w.WriteHeader(http.StatusAccepted)
w.Write([]byte(`{"status":"deploy started"}`))
safego.Go("webhook:"+h.script, func() {
apiToken := ""
if sec != nil {
apiToken = secrets.ResolveSecret(ctx, sec, "api_token", "")
}
cmd := exec.Command(h.script)
cmd.Dir = repoDir
cmd.Env = append(os.Environ(),
"REPO_DIR="+repoDir,
"PROFILE=full",
"OIKOS_API_TOKEN="+apiToken,
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
start := time.Now()
if err := cmd.Run(); err != nil {
slog.Error("webhook: deploy failed", "script", h.script, "error", err, "duration", time.Since(start))
return
}
slog.Info("webhook: deploy succeeded", "script", h.script, "duration", time.Since(start))
})
}
var ctx context.Context
var sec *secrets.Manager
var repoDir string
func main() { func main() {
ctx := context.Background() ctx = context.Background()
port := os.Getenv("WEBHOOK_LISTEN") port := os.Getenv("WEBHOOK_LISTEN")
if port == "" { if port == "" {
port = ":9797" port = ":9797"
} }
repoDir := os.Getenv("WEBHOOK_REPO_DIR") repoDir = os.Getenv("WEBHOOK_REPO_DIR")
if repoDir == "" { if repoDir == "" {
repoDir = os.Getenv("HOME") + "/Projects/oikos" repoDir = os.Getenv("HOME") + "/Projects/oikos"
} }
// Create secrets manager once, share between HMAC resolution and deploy sec = newSecrets()
sec := newSecrets()
secret := resolveWebhookHMAC(ctx, sec) secret := resolveWebhookHMAC(ctx, sec)
if secret == "" { if secret == "" {
fmt.Fprintln(os.Stderr, "WEBHOOK_HMAC_SECRET must be set (env var or Infisical webhook_hmac-secret)") fmt.Fprintln(os.Stderr, "WEBHOOK_HMAC_SECRET must be set (env var or Infisical webhook_hmac-secret)")
@@ -39,60 +102,8 @@ func main() {
} }
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/deploy", func(w http.ResponseWriter, r *http.Request) { mux.Handle("/deploy", &webhookHandler{secret: secret, script: repoDir + "/scripts/deploy.sh"})
if r.Method != http.MethodPost { mux.Handle("/deploy-plugins", &webhookHandler{secret: secret, script: repoDir + "/scripts/deploy-plugins.sh"})
http.Error(w, "method not allowed", 405)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read body failed", 400)
return
}
sigHex := r.Header.Get("X-Hub-Signature-256")
if sigHex == "" {
http.Error(w, "missing signature", 401)
return
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(sigHex), []byte(expected)) {
slog.Warn("webhook: invalid signature")
http.Error(w, "invalid signature", 401)
return
}
slog.Info("webhook: deploy triggered")
w.WriteHeader(http.StatusAccepted)
w.Write([]byte(`{"status":"deploy started"}`))
safego.Go("webhook:deploy", func() {
apiToken := ""
if sec != nil {
apiToken = secrets.ResolveSecret(ctx, sec, "api_token", "")
}
cmd := exec.Command(repoDir + "/scripts/deploy.sh")
cmd.Dir = repoDir
cmd.Env = append(os.Environ(),
"REPO_DIR="+repoDir,
"PROFILE=full",
"OIKOS_API_TOKEN="+apiToken,
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
start := time.Now()
if err := cmd.Run(); err != nil {
slog.Error("webhook: deploy failed", "error", err, "duration", time.Since(start))
return
}
slog.Info("webhook: deploy succeeded", "duration", time.Since(start))
})
})
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200) w.WriteHeader(200)
@@ -106,7 +117,6 @@ func main() {
} }
} }
// newSecrets creates the Infisical secrets manager from env vars.
func newSecrets() *secrets.Manager { func newSecrets() *secrets.Manager {
return secrets.NewManagerFromConfig( return secrets.NewManagerFromConfig(
os.Getenv("OIKOS_INFISICAL_SITE_URL"), os.Getenv("OIKOS_INFISICAL_SITE_URL"),
@@ -118,8 +128,6 @@ func newSecrets() *secrets.Manager {
) )
} }
// resolveWebhookHMAC fetches the webhook HMAC secret from Infisical,
// falling back to the WEBHOOK_HMAC_SECRET env var.
func resolveWebhookHMAC(ctx context.Context, sec *secrets.Manager) string { func resolveWebhookHMAC(ctx context.Context, sec *secrets.Manager) string {
envFallback := os.Getenv("WEBHOOK_HMAC_SECRET") envFallback := os.Getenv("WEBHOOK_HMAC_SECRET")
if sec == nil { if sec == nil {

View File

@@ -1,26 +0,0 @@
# Nomos agent container — standalone MCP client gateway (Phase 4)
FROM golang:1.26-alpine AS builder
RUN apk add --no-cache git ca-certificates
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /nomos -tags timetzdata -ldflags="-s -w" ./cmd/nomos
FROM gcr.io/distroless/static:nonroot
COPY --from=builder /nomos /nomos
COPY nomos/ /app/nomos/
ENV NOMOS_MCP_URL=http://api:8090/mcp
ENV NOMOS_AGENT_SLUG=agent:nomos
ENV NOMOS_LISTEN=:8092
ENV NOMOS_MODEL=deepseek/deepseek-v4-pro
EXPOSE 8092
ENTRYPOINT ["/nomos", "serve"]

View File

@@ -2,11 +2,9 @@
# Usage: docker compose up -d postgres (just the DB) # Usage: docker compose up -d postgres (just the DB)
# make dev (full dev stack) # make dev (full dev stack)
# #
# The SPA isn't embedded in the oikos binary (see # The control-room SPA lives in its own repo (dtoro/oikos-web) with its own
# plans/2026-07-12-wails-desktop-app.md 0.1/0.6) but it IS part of this # compose project; the agent runtime (dsh) runs outside this stack and talks
# stack as its own `web` service (compose/web/Dockerfile), so it deploys # to api's /mcp like any MCP client.
# through the same push-to-main pipeline as everything else. `npm run dev`
# in web/ is still the fast local-iteration path.
services: services:
postgres: postgres:
@@ -78,13 +76,12 @@ services:
OIKOS_ENV: dev OIKOS_ENV: dev
OIKOS_DEBUG: "true" OIKOS_DEBUG: "true"
# No dev-open auth bypass (plans/2026-07-12-wails-desktop-app.md 0.4) — # No dev-open auth bypass (plans/2026-07-12-wails-desktop-app.md 0.4) —
# every request needs this token. nomos uses the same value to call # every request needs this token. dsh uses the same value to call
# back into api's /mcp and /api/v1/approvals/*/decision. # into api's /mcp and /api/v1/approvals/*/decision.
OIKOS_MCP_BEARER_TOKEN: ${OIKOS_MCP_BEARER_TOKEN:-dev-token} OIKOS_MCP_BEARER_TOKEN: ${OIKOS_MCP_BEARER_TOKEN:-dev-token}
OIKOS_OIDC_ISSUER: ${OIKOS_OIDC_ISSUER:-https://auth.hubris.network/application/o/oikos/} OIKOS_OIDC_ISSUER: ${OIKOS_OIDC_ISSUER:-https://auth.hubris.network/application/o/oikos/}
OIKOS_OIDC_CLIENT_ID: ${OIKOS_OIDC_CLIENT_ID:-otkHBSueHJsYtOHstL6rn5izeGgyOsavp1qA1hod} OIKOS_OIDC_CLIENT_ID: ${OIKOS_OIDC_CLIENT_ID:-otkHBSueHJsYtOHstL6rn5izeGgyOsavp1qA1hod}
OIKOS_NOMOS_AGENT_SLUG: ${OIKOS_NOMOS_AGENT_SLUG:-agent:nomos} OIKOS_NOMOS_AGENT_SLUG: ${OIKOS_NOMOS_AGENT_SLUG:-agent:nomos}
NOMOS_PROXY_URL: http://nomos:8092
# Rate limiting (plan D3). Default off; set OIKOS_API_RATE_LIMIT to a # Rate limiting (plan D3). Default off; set OIKOS_API_RATE_LIMIT to a
# requests/sec value to throttle runaway agent loops per source IP. # requests/sec value to throttle runaway agent loops per source IP.
OIKOS_API_RATE_LIMIT: ${OIKOS_API_RATE_LIMIT:-} OIKOS_API_RATE_LIMIT: ${OIKOS_API_RATE_LIMIT:-}
@@ -104,10 +101,9 @@ services:
stop_grace_period: 30s stop_grace_period: 30s
mem_limit: 512m mem_limit: 512m
cpus: 1.0 cpus: 1.0
# Exists so nomos can wait for the API to actually answer rather than just # Self-probe so `docker compose ps` reports genuine readiness: /healthz
# for its container to exist — see nomos's depends_on below. wget is # pings the DB, so "healthy" means actually answering. wget is BusyBox's,
# BusyBox's, already in the alpine runtime image, so this adds no # already in the alpine runtime image, so this adds no dependency.
# dependency. /healthz pings the DB, so "healthy" means genuinely ready.
healthcheck: healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8090/healthz"] test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8090/healthz"]
interval: 5s interval: 5s
@@ -118,7 +114,7 @@ services:
# The api's NewHandler stalls on TWO unreachable external deps at startup # The api's NewHandler stalls on TWO unreachable external deps at startup
# before binding :8090: Infisical (4x auth retries, ~40s) and OIDC # before binding :8090: Infisical (4x auth retries, ~40s) and OIDC
# discovery (auth.hubris.network, ~35s of timeouts). Total ~90-95s, so # discovery (auth.hubris.network, ~35s of timeouts). Total ~90-95s, so
# the start period must clear it or nomos (depends_on: api-healthy) fails. # the start period must clear it.
start_period: 180s start_period: 180s
# Scheduler (Phase 3) — observe loop # Scheduler (Phase 3) — observe loop
@@ -188,52 +184,11 @@ services:
retries: 3 retries: 3
start_period: 90s start_period: 90s
# Nomos agent gateway (Phase 4) — mesh-published :8092 # Nomos agent gateway (Phase 4) — decommissioned. Replaced by dsh
nomos: # (DeepSeek Harness) as the agent runtime; see
image: oikos-nomos:${OIKOS_VERSION:-latest} # plans/2026-08-16-dsh-as-agent-replace-nomos.md section 5. dsh runs
build: # outside this stack (Node.js sidecar, dev at http://127.0.0.1:3080)
context: . # and talks to api's /mcp like any MCP client.
dockerfile: compose/nomos/Dockerfile
restart: unless-stopped
profiles: ["full"]
depends_on:
api:
# service_started only waits for the container to exist, so nomos came
# up while the API was still binding :8090, failed its MCP initialize,
# exited 1, and crash-looped for ~25s on every single deploy. It always
# recovered, which is exactly why it went unnoticed. service_healthy
# waits for the API to actually answer.
condition: service_healthy
environment:
NOMOS_MCP_URL: http://api:8090/mcp
NOMOS_AGENT_SLUG: agent:nomos
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
NOMOS_MODEL: ${NOMOS_MODEL:-deepseek/deepseek-v4-pro}
DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable
# Must match api's OIKOS_MCP_BEARER_TOKEN above — api's combinedAuth
# rejects every request without it now (no dev-open bypass).
OIKOS_MCP_BEARER_TOKEN: ${OIKOS_MCP_BEARER_TOKEN:-dev-token}
# Infisical secret store (Phase 5) — nomos resolves mcp_bearer-token
# and openrouter_api-key from here, overriding the env values above.
OIKOS_INFISICAL_SITE_URL: ${OIKOS_INFISICAL_SITE_URL:-}
OIKOS_INFISICAL_CLIENT_ID: ${OIKOS_INFISICAL_CLIENT_ID:-}
OIKOS_INFISICAL_CLIENT_SECRET: ${OIKOS_INFISICAL_CLIENT_SECRET:-}
OIKOS_INFISICAL_PROJECT_ID: ${OIKOS_INFISICAL_PROJECT_ID:-}
OIKOS_INFISICAL_ENV: ${OIKOS_INFISICAL_ENV:-dev}
ports:
- "8092:8092"
stop_signal: SIGTERM
stop_grace_period: 10s
mem_limit: 512m
cpus: 1.0
# nomos runs on a distroless image (no shell/wget), so the healthcheck
# uses the binary's own `healthcheck` subcommand to self-probe /healthz.
healthcheck:
test: ["CMD", "/nomos", "healthcheck"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
# The control-room SPA moved to its own repo (dtoro/oikos-web, Phase 1 of # The control-room SPA moved to its own repo (dtoro/oikos-web, Phase 1 of
# plans/2026-08-15-hexagonal-architecture.md) with its own compose project # plans/2026-08-15-hexagonal-architecture.md) with its own compose project

View File

@@ -1,55 +0,0 @@
# Golden conversation evals for the nomos agent.
# Run: go run ./cmd/nomos/eval -gateway http://localhost:8092 -manifest evals/*.yaml
#
# Each conversation costs real OpenRouter credits (~$0.010.05). The runner
# sends the prompt, waits for the turn to finish, optionally sends a followup,
# and scores assertions against the final persisted transcript.
#
# These are STRUCTURAL assertions only — tool-call sequences, plan steps,
# writeback, completion. Text quality is model-dependent and not scored.
# --- eval 1: trivial read-only task (degenerate case) ---
- name: trivial_readonly
prompt: "What is the state of lxc:dns? One line, no plan needed."
assertions:
- kind: completes
- kind: no_propose_plan # trivial — no ceremony
- kind: max_tool_calls
value: 5 # get_entity + complete_task + maybe one more
- kind: no_run # read-only, no `run` needed
# --- eval 2: the operator's original bug — plan + proceed ---
- name: plan_advances_on_proceed
prompt: "Check the uptime of lxc:gitea. Plan it out, propose the plan, then wait for my approval before running anything."
followup: "proceed with the rest"
assertions:
- kind: completes
- kind: proposes_plan_once # propose_plan called exactly once
- kind: no_duplicate_proposal # the original bug: re-propose on "proceed"
- kind: writes_back # ran `run` → must update_entity_attributes (D.1)
- kind: no_duplicate_complete # C.1 — complete_task called at most once
# --- eval 3: UI complaint should not re-execute (C.2) ---
- name: ui_complaint_no_rerun
prompt: "Check the uptime of lxc:dns. Plan it out and wait for my approval."
followup: "go ahead"
assertions:
- kind: completes
- kind: proposes_plan_once
- kind: writes_back
# (The followup "go ahead" is approval, not a UI complaint — we'd test the
# complaint path separately with a second followup, but that needs the
# session to stay open after completion, which the runner doesn't support yet.
# For now this validates the approval-vocabulary path.)
# --- eval 4: knowledge preferred over fleet re-execution (E.1) ---
# A same-day fleet audit knowledge entry exists in the DB. The agent should
# search_knowledge first and NOT run `run` against 20 LXCs.
- name: knowledge_preferred_over_rerun
prompt: "Give me an overview of what needs updating across the homelab, categorize by criticality. There may be a recent audit already."
assertions:
- kind: completes
- kind: calls_tool
value: search_knowledge # E.1 — must check the knowledge base first
- kind: max_run_calls
value: 4 # NOT 20+ — a targeted refresh only

View File

@@ -1,18 +0,0 @@
# P5 eval: iteration. A read-only task completes; the follow-up asks the
# agent to act on what it found (a config_mutation). The second followup
# is the approval ("go ahead") so the plan can execute. Asserts the session
# reopens, a second plan generation is created, and the agent completes
# both sub-tasks.
- name: iteration-followup
prompt: "Check the live systemd timer on lxc:rclone — when did the last backup to Proton Drive actually run, and when is the next one scheduled? Inspect the actual service state, don't just read the DB."
followups:
- "The repos folder failed last time. Reset the failed service and re-run the backup."
- "go ahead"
assertions:
- kind: completes
- kind: plan_generations
value: 2
- kind: proposes_plan
- kind: writes_back
- kind: max_run_calls
value: 40

View File

@@ -1,17 +0,0 @@
# P5 eval: two read-only sub-tasks back-to-back. The follow-up is also
# read-only (no approval needed) so the agent can execute immediately after
# proposing the second plan. Asserts the session reopens and a second plan
# generation is created.
- name: iteration-readonly
prompt: "Check the live systemd timer on lxc:rclone — when did the last backup to Proton Drive actually run, and when is the next one scheduled?"
followups:
- "Now check the uptime of lxc:dns."
assertions:
- kind: completes
- kind: plan_generations
value: 2
- kind: proposes_plan
- kind: calls_tool
value: run
- kind: max_run_calls
value: 6

View File

@@ -1,10 +0,0 @@
# P5 eval: a pure-DB Q&A that calls NO run. This is the ONLY remaining
# carve-out from plan-first: a task that never touches a live target via
# `run` doesn't need propose_plan (the gate only fires on run). Asserts
# the agent answers directly and completes without ceremony.
- name: no-plan-no-run
prompt: "List all LXC containers and their current health."
assertions:
- kind: completes
- kind: no_run
- kind: no_propose_plan

View File

@@ -1,14 +0,0 @@
# P5 eval: a read-only question that requires live inspection (not just DB
# lookup). Asserts the plan-first gate works: the agent must propose_plan
# before run, even for a trivial read-only task.
- name: plan-always-readonly
prompt: "Check the live systemd timer on lxc:rclone — when did the last backup to Proton Drive actually run, and when is the next one scheduled? Inspect the actual service state, don't just read the DB."
assertions:
- kind: completes
- kind: proposes_plan
- kind: plan_before_run
- kind: calls_tool
value: run
- kind: writes_back
- kind: max_run_calls
value: 6

View File

@@ -64,8 +64,9 @@ func (g *GovernanceRepo) AssentWindowActive(ctx context.Context, agentID domain.
} }
// DestructiveWindowActive checks the target+session-scoped destructive // DestructiveWindowActive checks the target+session-scoped destructive
// window key. Key format must match cmd/nomos/store.go's // window key. Key format must match the writer in approvals.go
// openDestructiveWindow — both processes read/write the same rows. // (openDestructiveWindow)same rows, same format:
// "destructive_window.agent:<id>.target:<slug>".
func (g *GovernanceRepo) DestructiveWindowActive(ctx context.Context, agentID domain.UUID, targetSlug, sessionID string) bool { func (g *GovernanceRepo) DestructiveWindowActive(ctx context.Context, agentID domain.UUID, targetSlug, sessionID string) bool {
if agentID == "" || targetSlug == "" || sessionID == "" { if agentID == "" || targetSlug == "" || sessionID == "" {
return false // fail closed return false // fail closed

View File

@@ -20,7 +20,7 @@ type Config struct {
// Auth (Phase 2: static bearer tokens + OIDC JWT) // Auth (Phase 2: static bearer tokens + OIDC JWT)
APIToken string // operator/CI bearer token for the REST API APIToken string // operator/CI bearer token for the REST API
MCPBearerToken string // shared secret for Nomos→API MCP calls MCPBearerToken string // shared secret for dsh→API MCP calls
OIDCIssuer string // OIDC issuer URL for JWT validation (e.g. https://authentik.example.com/application/o/oikos/) OIDCIssuer string // OIDC issuer URL for JWT validation (e.g. https://authentik.example.com/application/o/oikos/)
OIDCClientID string // OIDC client ID (aud claim expected in JWT) OIDCClientID string // OIDC client ID (aud claim expected in JWT)
OIDCClientSecret string // optional client secret for token endpoint proxy (confidential clients) OIDCClientSecret string // optional client secret for token endpoint proxy (confidential clients)
@@ -62,7 +62,9 @@ type Config struct {
// Learning (Phase 3) // Learning (Phase 3)
LearningInterval time.Duration // pattern extraction interval (default 3600s) LearningInterval time.Duration // pattern extraction interval (default 3600s)
// Nomos agent entity ID (Phase 4) // Agent entity MCP activity is attributed to (slug agent:nomos, seeded).
// dsh calls MCP without per-agent identity, so this static entity is the
// attribution anchor.
NomosAgentID string NomosAgentID string
NomosAgentSlug string NomosAgentSlug string

View File

@@ -16,9 +16,7 @@ import (
"log/slog" "log/slog"
"math/big" "math/big"
"net/http" "net/http"
"net/http/httputil"
"net/url" "net/url"
"os"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -350,16 +348,6 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, entities
} }
r.With(combinedAuth(cfg, false)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID, s.secretsManager, s.entities, s.relService, s.execSvc)) r.With(combinedAuth(cfg, false)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID, s.secretsManager, s.entities, s.relService, s.execSvc))
if nomosURL := os.Getenv("NOMOS_PROXY_URL"); nomosURL != "" {
target, _ := url.Parse(nomosURL)
proxy := httputil.NewSingleHostReverseProxy(target)
// Was unauthenticated (pre-existing gap, predates the client/server
// split — this mount was never wrapped in combinedAuth, unlike every
// other custom route below). Harmless while dev-open was in effect;
// a real hole now that every route needs a real credential.
r.Mount("/agent", combinedAuth(cfg, false)(http.StripPrefix("/agent", proxy)))
}
return r return r
} }

View File

@@ -555,7 +555,7 @@ func httpGet(ctx context.Context, rawURL string) *mcp.CallToolResult {
if err != nil { if err != nil {
return textResult(fmt.Sprintf("error: %v", err)) return textResult(fmt.Sprintf("error: %v", err))
} }
hreq.Header.Set("User-Agent", "oikos-nomos/1.0 (+homelab agent)") hreq.Header.Set("User-Agent", "oikos-mcp/1.0 (+homelab)")
hreq.Header.Set("Accept", "text/plain, text/html, application/json;q=0.9, */*;q=0.5") hreq.Header.Set("Accept", "text/plain, text/html, application/json;q=0.9, */*;q=0.5")
client := &http.Client{Timeout: 20 * time.Second} client := &http.Client{Timeout: 20 * time.Second}

View File

@@ -1,82 +0,0 @@
package messagequeue
import (
"log/slog"
"sync"
)
// MaxQueuedPerSession caps a session's queue. A held turn plus unbounded
// enqueues would grow memory without limit; an operator nudging a long
// autonomous turn realistically queues only a handful, so a generous cap is
// pure insurance. Overflow drops the newest Enqueue and logs (the message is
// already persisted in the DB by handleChat before Enqueue, so it isn't lost
// from the transcript — it just won't auto-run).
const MaxQueuedPerSession = 20
// MessageQueue holds operator messages that arrived while a turn was already
// running for a session. Plan 2026-08-03 (F2): instead of rejecting the
// operator's message with "Nomos is still finishing a previous step… send it
// again", the message is queued and auto-run when the in-flight turn releases
// the session's turn-gate permit.
//
// The queue only schedules WHEN a turn runs, not WHETHER the message is stored
// — handleChat persists the user message before acquiring the gate, so a queued
// message is already in the transcript; this just makes sure a turn eventually
// acts on it.
//
// Draining is strictly one-at-a-time under the turn gate (see drainQueued in
// main.go), so this cannot stack concurrent turns — the exact hazard the gate
// itself exists to prevent. Background resumeSession callers never touch this
// queue; they keep their non-blocking skip.
type MessageQueue struct {
mu sync.Mutex
queue map[string][]string
}
func New() *MessageQueue {
return &MessageQueue{queue: map[string][]string{}}
}
// Enqueue appends a message to the back of the session's FIFO. Returns false
// (and logs) if the session is already at MaxQueuedPerSession — the caller's
// message is already persisted in the DB, so this only skips auto-running it.
func (q *MessageQueue) Enqueue(sessionID, msg string) bool {
q.mu.Lock()
defer q.mu.Unlock()
if len(q.queue[sessionID]) >= MaxQueuedPerSession {
slog.Warn("nomos: message queue full; dropping auto-run for operator message", "session", sessionID, "cap", MaxQueuedPerSession)
return false
}
q.queue[sessionID] = append(q.queue[sessionID], msg)
return true
}
// Dequeue pops the next message from the front of the session's FIFO. Returns
// ok=false when empty.
func (q *MessageQueue) Dequeue(sessionID string) (string, bool) {
q.mu.Lock()
defer q.mu.Unlock()
xs := q.queue[sessionID]
if len(xs) == 0 {
return "", false
}
m := xs[0]
q.queue[sessionID] = xs[1:]
return m, true
}
// RequeueFront pushes a message back to the front — used when a drainer popped
// a message but lost the race for the gate to a live turn; that turn's own
// release will drain it again.
func (q *MessageQueue) RequeueFront(sessionID, msg string) {
q.mu.Lock()
defer q.mu.Unlock()
q.queue[sessionID] = append([]string{msg}, q.queue[sessionID]...)
}
// Peek reports the queued depth for a session (test/diagnostic helper).
func (q *MessageQueue) Peek(sessionID string) int {
q.mu.Lock()
defer q.mu.Unlock()
return len(q.queue[sessionID])
}

View File

@@ -1,98 +0,0 @@
package messagequeue
import (
"sync"
"testing"
)
func TestMessageQueue_FIFO(t *testing.T) {
q := New()
q.Enqueue("s", "first")
q.Enqueue("s", "second")
q.Enqueue("s", "third")
want := []string{"first", "second", "third"}
for _, w := range want {
got, ok := q.Dequeue("s")
if !ok || got != w {
t.Fatalf("Dequeue = %q,%v want %q,true", got, ok, w)
}
}
if _, ok := q.Dequeue("s"); ok {
t.Fatal("Dequeue on drained queue should return ok=false")
}
}
func TestMessageQueue_RequeueFront(t *testing.T) {
q := New()
q.Enqueue("s", "a")
q.Enqueue("s", "b")
// Pop "a", then push it back to the front; "a" must come out before "b".
a, _ := q.Dequeue("s")
q.RequeueFront("s", a)
got, _ := q.Dequeue("s")
if got != "a" {
t.Fatalf("after RequeueFront, Dequeue = %q want %q", got, "a")
}
got2, _ := q.Dequeue("s")
if got2 != "b" {
t.Fatalf("next Dequeue = %q want %q", got2, "b")
}
}
func TestMessageQueue_IsolatedPerSession(t *testing.T) {
q := New()
q.Enqueue("s1", "one")
q.Enqueue("s2", "two")
if got, _ := q.Dequeue("s1"); got != "one" {
t.Fatalf("s1 = %q want one", got)
}
if got, _ := q.Dequeue("s2"); got != "two" {
t.Fatalf("s2 = %q want two", got)
}
if q.Peek("s1") != 0 || q.Peek("s2") != 0 {
t.Fatal("both sessions should be drained")
}
}
func TestMessageQueue_Concurrent(t *testing.T) {
q := New()
const n = MaxQueuedPerSession // stay under the cap so every Enqueue lands
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
q.Enqueue("s", "m")
}(i)
}
wg.Wait()
if q.Peek("s") != n {
t.Fatalf("Peek = %d want %d (all enqueues must be counted)", q.Peek("s"), n)
}
seen := 0
for {
if _, ok := q.Dequeue("s"); !ok {
break
}
seen++
}
if seen != n {
t.Fatalf("drained %d want %d", seen, n)
}
}
func TestMessageQueue_CapsOverflow(t *testing.T) {
q := New()
for i := 0; i < MaxQueuedPerSession; i++ {
if !q.Enqueue("s", "m") {
t.Fatalf("Enqueue #%d within cap should succeed", i)
}
}
if q.Enqueue("s", "overflow") {
t.Fatal("Enqueue past the cap should return false (dropped)")
}
if got := q.Peek("s"); got != MaxQueuedPerSession {
t.Fatalf("Peek = %d want %d (overflow must not append)", got, MaxQueuedPerSession)
}
}

View File

@@ -1,170 +0,0 @@
package retrycap
import (
"crypto/sha256"
"encoding/hex"
"strings"
"sync"
)
// MaxRunRetries is the per-turn cap on identical failing `run` tool calls.
// After this many Failures with the same (target, command) key, the agent
// loop refuses to dispatch the call again and instead surfaces a directive
// to investigate *why* (ps/strace/lsof) or escalate to the operator.
//
// Background: session 1e9c7691 (2026-07-18) retried the same
// `chown :10000 /mnt/media_local && chmod 2775 …` ~20 times across direct
// runs, SSH-hop-via-hubris, wrapping in a shell script, and bare `echo test`
// sanity checks. Each retry piled up another zombie process on the target
// (knfsd was holding a kernel lock on the exported directory). The agent
// only investigated *why* after the operator explicitly asked
// "the command just keeps running?" — see
// plans/2026-07-18-session-review-three-sessions.md P0.1.
const MaxRunRetries = 3
// RunRetryTracker deduplicates failing `run` calls within a single chat
// turn (chatWith invocation). It is NOT persisted across turns — the cap
// is per-turn, so a fresh turn after the operator responds can retry once
// more. The intent is to break a tight retry loop within one turn, not to
// permanently block the agent from ever attempting the operation again.
//
// Threading: the agent loop is single-goroutine per turn, but the tracker
// is guarded by a mutex so future callers (e.g. concurrent tool dispatch)
// stay safe. The mutex is uncontended on the current hot path.
type RunRetryTracker struct {
mu sync.Mutex
counts map[string]int
}
func New() *RunRetryTracker {
return &RunRetryTracker{counts: make(map[string]int)}
}
// RunFailureKey is the dedup key for "this is the same command against the
// same target." Whitespace is collapsed so trivial reformatting
// (newlines vs spaces, trailing whitespace) doesn't escape the cap. The
// purpose field is intentionally NOT part of the key: the agent often
// rephrases purpose between retries while issuing the same command.
func RunFailureKey(target, command string) string {
collapsed := strings.Join(strings.Fields(command), " ")
target = strings.TrimSpace(target)
h := sha256.Sum256([]byte(target + "\x00" + collapsed))
return hex.EncodeToString(h[:])
}
// RecordFailure increments the failure count for the given key and returns
// the new count. The caller should check `count > MaxRunRetries` BEFORE
// dispatching to decide whether to skip the call.
func (r *RunRetryTracker) RecordFailure(key string) int {
r.mu.Lock()
defer r.mu.Unlock()
r.counts[key]++
return r.counts[key]
}
// Failures returns the current failure count for a key (0 if unseen).
func (r *RunRetryTracker) Failures(key string) int {
r.mu.Lock()
defer r.mu.Unlock()
return r.counts[key]
}
// IsRunFailure reports whether a `run` tool call's outcome should count
// as a failure for retry-cap purposes. A call counts as failed when:
// - the dispatch itself errored (callErr != nil), OR
// - the result text starts with "run on <target>: ERROR" — the
// shape classifyAndGate/sshExec produce when SSH or the command fails.
//
// Approvals queued ("requires approval") do NOT count as Failures: they
// are pending operator action, not a command execution failure. A read
// of the existing code paths (classifyAndGate in internal/mcp/server.go)
// confirms the "ERROR" prefix is the stable failure signature for `run`.
//
// The resultText parameter is the MCP tool's RAW text result (not JSON-
// re-encoded): when classifyAndGate returns a textResult like
// "run on host:strong: ERROR ...", the MCP client unwraps it back to a
// plain Go string (see mcpClient.callTool). The caller should pass that
// raw string, not json.Marshal's output (which would quote-wrap it).
func IsRunFailure(toolName string, resultText string, callErr error) bool {
if callErr != nil {
return true
}
if toolName != "run" {
return false
}
// "run on host:strong: ERROR ..." or "run on lxc:caddy: ERROR ..."
// Both shapes start with "run on ".
if !strings.HasPrefix(resultText, "run on ") {
return false
}
return strings.Contains(resultText, ": ERROR")
}
// RunResultText extracts the raw text from a `run` tool's result value as
// returned by mcpClient.callTool — typically a Go string, but may also be
// a []string (multi-content result) or other JSON-decoded shape. Returns
// "" for shapes we don't recognize. Used by the retry-cap path so
// IsRunFailure receives the un-quoted text form (see its doc comment).
func RunResultText(result any) string {
switch v := result.(type) {
case string:
return v
case []string:
if len(v) > 0 {
return v[0]
}
case []any:
var b strings.Builder
for _, e := range v {
if s, ok := e.(string); ok {
b.WriteString(s)
}
}
return b.String()
}
return ""
}
// RunRetryDirective is the synthetic tool result returned to the model
// when the retry cap is hit, in place of dispatching the call again. It
// directs the agent to investigate *why* the command keeps failing before
// retrying, or to surface the blocker to the operator.
func RunRetryDirective(target, command string, Failures int) string {
return "Refused: this `run` against " + target + " has failed " +
itoa(Failures) + " times this turn — retry cap hit. The command:\n " +
command + "\nis almost certainly blocked by something on the target " +
"(a hung process, a kernel lock, an unexported FS, a stuck SSH " +
"session, …) — NOT a transient gateway issue. Do NOT retry with " +
"different routing or quoting. Instead, BEFORE calling `run` again, " +
"investigate *why* the command hangs: e.g. `ps aux | grep <cmd>`, " +
"`lsof <path>`, `strace -f -p <pid>` or `strace -f <cmd>`, " +
"`mount | grep <path>`, `dmesg | tail`. If you find a structural " +
"blocker (e.g. a kernel lock on an exported NFS directory → " +
"unexport → mutate → re-export), say so to the operator and fix it " +
"with a different command. If you genuinely cannot diagnose, " +
"surface the blocker to the operator with what you've tried — do " +
"not just retry the same command."
}
// itoa is a tiny strconv.Itoa to keep this file dependency-free.
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}

View File

@@ -1,129 +0,0 @@
package retrycap
import (
"strings"
"testing"
)
func TestRunFailureKey_StableAcrossWhitespace(t *testing.T) {
cases := []struct{ a, b string }{
{"chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local",
"chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local"},
{"chown :10000 /mnt/media_local\n&& chmod 2775 /mnt/media_local",
"chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local"},
{"chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local ",
" chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local"},
}
for i, c := range cases {
ka := RunFailureKey("host:strong", c.a)
kb := RunFailureKey("host:strong", c.b)
if ka != kb {
t.Errorf("case %d: keys differ for whitespace-equivalent commands:\n a=%q\n b=%q", i, c.a, c.b)
}
}
}
func TestRunFailureKey_DiffersByTarget(t *testing.T) {
a := RunFailureKey("host:strong", "echo hi")
b := RunFailureKey("host:hubris", "echo hi")
if a == b {
t.Error("keys should differ when target differs")
}
}
func TestRunFailureKey_DiffersByCommand(t *testing.T) {
a := RunFailureKey("host:strong", "echo hi")
b := RunFailureKey("host:strong", "echo bye")
if a == b {
t.Error("keys should differ when command differs")
}
}
func TestRunRetryTracker_CountsAndCaps(t *testing.T) {
r := New()
key := RunFailureKey("host:strong", "chown :10000 /mnt/media_local")
for i := 1; i <= MaxRunRetries; i++ {
if got := r.RecordFailure(key); got != i {
t.Errorf("RecordFailure #%d = %d, want %d", i, got, i)
}
}
// At the cap, Failures() should report MaxRunRetries, and the next
// identical call should be refused by the agent loop (Failures() >=
// MaxRunRetries).
if got := r.Failures(key); got != MaxRunRetries {
t.Errorf("Failures = %d, want %d", got, MaxRunRetries)
}
if r.Failures(key) < MaxRunRetries {
t.Errorf("cap should be enforced at MaxRunRetries=%d", MaxRunRetries)
}
}
func TestRunRetryTracker_PerTurnIsolation(t *testing.T) {
// Different keys don't interfere.
r := New()
k1 := RunFailureKey("host:strong", "echo a")
k2 := RunFailureKey("host:strong", "echo b")
r.RecordFailure(k1)
r.RecordFailure(k1)
if got := r.Failures(k2); got != 0 {
t.Errorf("k2 Failures = %d, want 0 (keys are isolated)", got)
}
}
func TestIsRunFailure(t *testing.T) {
cases := []struct {
desc string
tool string
result string
callErr error
want bool
}{
{"run with ERROR prefix", "run", "run on host:strong: ERROR ssh: signal: killed", nil, true},
{"run with exit error", "run", "run on lxc:caddy: ERROR exit status 1", nil, true},
{"run success (read-only auto)", "run", "run on host:strong (read_only, auto): hello", nil, false},
{"run success (assent window)", "run", "run on host:strong (config_mutation, auto via assent window): done", nil, false},
{"run queued for approval", "run", "run on host:strong requires approval (risk: config_mutation) — execution 019f4930 queued. Present the command and purpose to the operator and wait; do not re-request.", nil, false},
{"non-run tool", "get_entity", "lxc list result", nil, false},
{"callErr set (dispatch failure)", "run", "", errFake{}, true},
{"callErr set on non-run tool", "get_entity", "some result", errFake{}, true}, // callErr trumps name
}
for i, c := range cases {
got := IsRunFailure(c.tool, c.result, c.callErr)
if got != c.want {
t.Errorf("case %d (%s): IsRunFailure = %v, want %v", i, c.desc, got, c.want)
}
}
}
type errFake struct{}
func (errFake) Error() string { return "fake dispatch error" }
func TestRunRetryDirective_Content(t *testing.T) {
d := RunRetryDirective("host:strong", "chown :10000 /mnt/media_local", 3)
for _, want := range []string{
"Refused:",
"host:strong",
"3 times",
"retry cap hit",
"Do NOT retry",
"strace",
"ps aux",
"lsof",
"surface the blocker",
} {
if !strings.Contains(d, want) {
t.Errorf("directive missing %q; got:\n%s", want, d)
}
}
}
func TestItoa(t *testing.T) {
cases := map[int]string{0: "0", 1: "1", 9: "9", 10: "10", 42: "42",
100: "100", -1: "-1", -42: "-42"}
for in, want := range cases {
if got := itoa(in); got != want {
t.Errorf("itoa(%d) = %q, want %q", in, got, want)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,523 +0,0 @@
package session
// Integration tests against a real Postgres, mirroring
// internal/db/integration_test.go's pattern: guarded by
// OIKOS_TEST_DATABASE_URL (skipped when unset), throwaway database per run,
// full migrations applied, dropped on cleanup. Run with:
//
// docker compose up -d postgres
// OIKOS_TEST_DATABASE_URL="postgres://oikos:oikos_dev@localhost:5432/oikos?sslmode=disable" go test ./cmd/nomos/
import (
"context"
"encoding/json"
"errors"
"fmt"
"math/rand"
"os"
"strings"
"testing"
"github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// newTestStore creates a throwaway, fully-migrated database and returns a
// *Store connected to it, cleaned up (including a matching task:<session>
// entity type in the ontology, needed by createTaskEntity/proposePlan tests)
// via t.Cleanup.
func newTestStore(t *testing.T) *Store {
t.Helper()
baseURL := os.Getenv("OIKOS_TEST_DATABASE_URL")
if baseURL == "" {
t.Skip("OIKOS_TEST_DATABASE_URL not set — skipping integration test")
}
ctx := context.Background()
admin, err := pgx.Connect(ctx, baseURL)
if err != nil {
t.Fatalf("connect admin: %v", err)
}
dbName := fmt.Sprintf("oikos_test_nomos_%08x", rand.Int63())
if _, err := admin.Exec(ctx, "CREATE DATABASE "+dbName); err != nil {
admin.Close(ctx)
t.Fatalf("create test db: %v", err)
}
admin.Close(ctx)
testURL := swapTestDatabase(baseURL, dbName)
pool, err := db.New(ctx, testURL)
if err != nil {
t.Fatalf("connect test db: %v", err)
}
t.Cleanup(func() {
pool.Close()
admin, err := pgx.Connect(ctx, baseURL)
if err == nil {
admin.Exec(ctx, "DROP DATABASE IF EXISTS "+dbName+" WITH (FORCE)")
admin.Close(ctx)
}
})
if err := pool.Migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
// session_plan_steps/session_questions tests don't need the ontology
// seed, but createTaskEntity's INSERT INTO entities (type='task') has an
// FK to entity_types — seed the minimal rows it needs directly rather
// than pulling in the full seeds/ontology.yaml ingest path.
if _, err := pool.Exec(ctx, `
INSERT INTO entity_types (name, domain, layer) VALUES ('entity', 'meta', 'meta')
ON CONFLICT DO NOTHING;
INSERT INTO entity_types (name, parent_type, domain, layer) VALUES ('task', 'entity', 'cognition', 'cognition')
ON CONFLICT DO NOTHING;`); err != nil {
t.Fatalf("seed minimal ontology: %v", err)
}
return &Store{pool: pool.Pool}
}
func swapTestDatabase(url, dbName string) string {
qi := strings.Index(url, "?")
params, base := "", url
if qi >= 0 {
params = url[qi:]
base = url[:qi]
}
si := strings.LastIndex(base, "/")
return base[:si+1] + dbName + params
}
// TestGetRecentMessages_Truncation is the concrete proof for fix A2 of
// plans/2026-07-11-nomos-agent-code-review.md: chatWith used to replay a
// session's ENTIRE history on every turn with no bound. getRecentMessages
// caps that; this test checks both sides — under the limit, nothing is
// dropped and truncated=false; over it, only the most recent `limit` come
// back, in chronological order, with truncated=true.
func TestGetRecentMessages_Truncation(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
sess, err := s.CreateSession(ctx, "history window test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
const total = 35
const limit = 30
for i := 0; i < total; i++ {
role := "user"
if i%2 == 1 {
role = "assistant"
}
body := fmt.Appendf(nil, `{"role":%q,"text":"msg-%d"}`, role, i)
if err := s.SaveMessage(ctx, sess.ID, role, body); err != nil {
t.Fatalf("saveMessage %d: %v", i, err)
}
}
msgs, truncated, err := s.GetRecentMessages(ctx, sess.ID, limit)
if err != nil {
t.Fatalf("getRecentMessages: %v", err)
}
if !truncated {
t.Errorf("truncated = false, want true (%d messages > limit %d)", total, limit)
}
if len(msgs) != limit {
t.Fatalf("got %d messages, want %d", len(msgs), limit)
}
// Chronological order: the oldest of the RETAINED messages should be the
// (total-limit)-th one saved (msg-5, since msg-0..4 were dropped), and
// the last should be the most recently saved (msg-34).
wantFirst := fmt.Sprintf("msg-%d", total-limit)
wantLast := fmt.Sprintf("msg-%d", total-1)
if got := sessionText(msgs[0].Content); got != wantFirst {
t.Errorf("first retained message = %q, want %q", got, wantFirst)
}
if got := sessionText(msgs[len(msgs)-1].Content); got != wantLast {
t.Errorf("last retained message = %q, want %q", got, wantLast)
}
// Under the limit: nothing dropped.
sess2, err := s.CreateSession(ctx, "small session")
if err != nil {
t.Fatalf("createSession: %v", err)
}
for i := 0; i < 5; i++ {
body := fmt.Appendf(nil, `{"role":"user","text":"msg-%d"}`, i)
if err := s.SaveMessage(ctx, sess2.ID, "user", body); err != nil {
t.Fatalf("saveMessage: %v", err)
}
}
msgs2, truncated2, err := s.GetRecentMessages(ctx, sess2.ID, limit)
if err != nil {
t.Fatalf("getRecentMessages (small): %v", err)
}
if truncated2 {
t.Errorf("truncated = true for a 5-message session under a %d limit, want false", limit)
}
if len(msgs2) != 5 {
t.Errorf("got %d messages, want 5", len(msgs2))
}
}
// TestProposePlan_RefuseInFlight is the concrete proof for the plan-drift
// fix (2026-07-14, "plan added twice in the sidebar"): proposePlan must
// REPLACE the step list only while every existing step is still 'pending'
// (a genuine pre-execution revision), and REFUSE the call once any step has
// started. The prior append-mode safety net (commit 5384499) preserved
// history but duplicated the plan in the sidebar when the agent re-proposed
// on "proceed". Refusing is the correct default — the agent must advance
// with update_plan_step + run.
func TestProposePlan_RefuseInFlight(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
sess, err := s.CreateSession(ctx, "plan refuse test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
// First call: no steps exist yet — must persist as-is (replace mode,
// trivially: nothing to replace).
out1, err := s.ProposePlan(ctx, sess.ID, []PlanStepInput{{Title: "Step A"}})
if err != nil {
t.Fatalf("proposePlan #1: %v", err)
}
if len(out1) != 1 || out1[0]["seq"] != 1 {
t.Fatalf("proposePlan #1 = %+v, want one step at seq 1", out1)
}
if out1[0]["generation"] != 1 {
t.Fatalf("proposePlan #1 generation = %v, want 1", out1[0]["generation"])
}
// Mark step 1 as started.
if err := s.UpdatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil {
t.Fatalf("updatePlanStep: %v", err)
}
// Second call, simulating a model that re-proposes mid-flight (the
// operator-reported "proceed" bug): since step 1 has left 'pending',
// this MUST refuse with ErrPlanInFlight, not append or replace.
_, err = s.ProposePlan(ctx, sess.ID, []PlanStepInput{{Title: "Step B"}})
if !errors.Is(err, ErrPlanInFlight) {
t.Fatalf("proposePlan #2: err = %v, want ErrPlanInFlight (refuse mid-flight re-proposal)", err)
}
// The original step 1 must be untouched — not erased, not appended to.
steps, err := s.GetPlanSteps(ctx, sess.ID, false)
if err != nil {
t.Fatalf("getPlanSteps: %v", err)
}
if len(steps) != 1 {
t.Fatalf("got %d persisted steps, want 1 (refused call must not mutate the plan)", len(steps))
}
if steps[0].Title != "Step A" || steps[0].Status != "running" {
t.Errorf("step 1 = %+v, want Step A still running (refused call must not touch it)", steps[0])
}
// Third call BEFORE anything runs on a fresh session: every step is
// still pending, so this must REPLACE (mark the prior plan `replaced`),
// not refuse. The new plan becomes generation 2.
sess2, err := s.CreateSession(ctx, "plan replace test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
if _, err := s.ProposePlan(ctx, sess2.ID, []PlanStepInput{{Title: "Original"}}); err != nil {
t.Fatalf("proposePlan (initial): %v", err)
}
if _, err := s.ProposePlan(ctx, sess2.ID, []PlanStepInput{{Title: "Revised"}}); err != nil {
t.Fatalf("proposePlan (revise before execution): %v", err)
}
// Default (current generation) view: only the revised step.
revisedSteps, err := s.GetPlanSteps(ctx, sess2.ID, false)
if err != nil {
t.Fatalf("getPlanSteps: %v", err)
}
if len(revisedSteps) != 1 || revisedSteps[0].Title != "Revised" {
t.Fatalf("got %+v, want a single 'Revised' step (current-generation view)", revisedSteps)
}
if revisedSteps[0].Seq != 1 {
t.Fatalf("revised step seq = %d, want 1 (seq is generation-relative, resets to 1..N)", revisedSteps[0].Seq)
}
if revisedSteps[0].Generation != 2 {
t.Fatalf("revised step generation = %d, want 2 (prior pending plan is replaced, not deleted, so the counter increments)", revisedSteps[0].Generation)
}
// all=true audit view: both generations, the original marked `replaced`.
allSteps, err := s.GetPlanSteps(ctx, sess2.ID, true)
if err != nil {
t.Fatalf("getPlanSteps(all): %v", err)
}
if len(allSteps) != 2 {
t.Fatalf("all=true got %d steps, want 2 (Original replaced gen1 + Revised gen2)", len(allSteps))
}
if allSteps[0].Title != "Original" || allSteps[0].Status != "replaced" || allSteps[0].Generation != 1 {
t.Errorf("gen1 step = %+v, want Original/replaced/gen1", allSteps[0])
}
if allSteps[1].Title != "Revised" || allSteps[1].Generation != 2 || allSteps[1].Seq != 1 {
t.Errorf("gen2 step = %+v, want Revised/gen2/seq1", allSteps[1])
}
}
// TestUpdatePlanStep_GenerationRelative is the P0.1 regression proof: after a
// re-plan, update_plan_step(seq=N) — using the 1-based number the model
// naturally carries — must address the CURRENT generation and never resurrect
// a superseded generation's `replaced` row. Before the fix, seq was globally
// increasing across generations, so seq=1 after a re-plan flipped the gen-1
// `replaced` step back to `running`/`done` while the real gen-2 work went
// unrecorded.
func TestUpdatePlanStep_GenerationRelative(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
sess, err := s.CreateSession(ctx, "gen-relative seq test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
// Generation 1: two steps.
if _, err := s.ProposePlan(ctx, sess.ID, []PlanStepInput{{Title: "A"}, {Title: "B"}}); err != nil {
t.Fatalf("proposePlan #1: %v", err)
}
// Re-plan: setGoal marks the gen-1 plan `replaced`, proposePlan starts gen 2.
if err := s.SetGoal(ctx, sess.ID, "follow-up sub-task"); err != nil {
t.Fatalf("setGoal: %v", err)
}
if _, err := s.ProposePlan(ctx, sess.ID, []PlanStepInput{{Title: "C"}, {Title: "D"}}); err != nil {
t.Fatalf("proposePlan #2: %v", err)
}
// The model addresses the new plan with 1-based seq. seq=1 must hit
// gen-2 "C", leaving gen-1 "A" (replaced) untouched.
if err := s.UpdatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil {
t.Fatalf("updatePlanStep(seq=1, running): %v", err)
}
if err := s.UpdatePlanStep(ctx, sess.ID, 1, "done", "", ""); err != nil {
t.Fatalf("updatePlanStep(seq=1, done): %v", err)
}
all, err := s.GetPlanSteps(ctx, sess.ID, true)
if err != nil {
t.Fatalf("getPlanSteps(all): %v", err)
}
byTitle := map[string]planStep{}
for _, st := range all {
byTitle[st.Title] = st
}
// gen-1 steps stay `replaced` — NOT resurrected to running/done.
if byTitle["A"].Status != "replaced" || byTitle["A"].Generation != 1 {
t.Errorf("A = %+v, want replaced/gen1 (a superseded row must never be touched)", byTitle["A"])
}
if byTitle["B"].Status != "replaced" || byTitle["B"].Generation != 1 {
t.Errorf("B = %+v, want replaced/gen1", byTitle["B"])
}
// gen-2 seq=1 advanced; seq=2 untouched.
if byTitle["C"].Status != "done" || byTitle["C"].Generation != 2 || byTitle["C"].Seq != 1 {
t.Errorf("C = %+v, want done/gen2/seq1 (the 1-based update must address the current generation)", byTitle["C"])
}
if byTitle["D"].Status != "pending" || byTitle["D"].Seq != 2 {
t.Errorf("D = %+v, want pending/seq2", byTitle["D"])
}
// Out-of-range seq must be refused (no current-gen step there).
if err := s.UpdatePlanStep(ctx, sess.ID, 99, "running", "", ""); !errors.Is(err, ErrPlanStepNotFound) {
t.Fatalf("updatePlanStep(seq=99) err = %v, want ErrPlanStepNotFound", err)
}
}
// TestCompleteTask_AutoCloseEmitsEvents is the P1.1 regression proof:
// completeTask's bulk auto-close of in-flight steps must emit one
// plan.step.finished event per closed step (so the live panel converges
// instead of freezing on "running" after the task completes) and must stamp
// started_at so no closed step is left un-timestamped (P0.1 fix 5).
func TestCompleteTask_AutoCloseEmitsEvents(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
sess, err := s.CreateSession(ctx, "auto-close events test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
if _, err := s.ProposePlan(ctx, sess.ID, []PlanStepInput{{Title: "A"}, {Title: "B"}}); err != nil {
t.Fatalf("proposePlan: %v", err)
}
// A is running, B still pending at completion time.
if err := s.UpdatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil {
t.Fatalf("updatePlanStep(1, running): %v", err)
}
if err := s.CompleteTask(ctx, sess.ID, "success", "done"); err != nil {
t.Fatalf("completeTask: %v", err)
}
// Every auto-closed step should now carry both a started_at and a
// finished_at (no NULL-started `done` step).
steps, err := s.GetPlanSteps(ctx, sess.ID, true)
if err != nil {
t.Fatalf("getPlanSteps: %v", err)
}
for _, st := range steps {
if st.Status == "done" && st.StartedAt == nil {
t.Errorf("step %q done but started_at is NULL (P0.1 fix 5: stamp it)", st.Title)
}
}
// Exactly two plan.step.finished events — one per closed step (A and B).
var finished int
if err := s.pool.QueryRow(ctx,
`SELECT COUNT(*) FROM events WHERE type = 'plan.step.finished' AND correlation_id = $1`,
sess.ID).Scan(&finished); err != nil {
t.Fatalf("count events: %v", err)
}
if finished != 2 {
t.Fatalf("plan.step.finished events = %d, want 2 (one per auto-closed step)", finished)
}
}
// TestHadDiscoveryAndWriteback is the Store-level proof for D.1 (refuse
// complete_task when discovery ran without writeback). hadDiscovery must
// report true only after a successful `run` call; hadEntityWriteback must
// report true only after a successful update_entity_attributes or
// create_relationship call. The D.1 gate in tasks.go combines these: refuse
// success when hadDiscovery && !hadEntityWriteback.
func TestHadDiscoveryAndWriteback(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
sess, err := s.CreateSession(ctx, "discovery test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
// Before any tool calls: no discovery, no writeback.
if s.HadDiscovery(ctx, sess.ID) {
t.Fatal("hadDiscovery = true before any tool calls, want false")
}
if s.HadEntityWriteback(ctx, sess.ID) {
t.Fatal("hadEntityWriteback = true before any tool calls, want false")
}
// A `run` call (discovery) — should set hadDiscovery, not hadEntityWriteback.
agentID := uuid.New()
s.LogActivity(ctx, agentID, sess.ID, "run", nil, "", "uptime output", 100, true, "corr-1", 0)
if !s.HadDiscovery(ctx, sess.ID) {
t.Fatal("hadDiscovery = false after a successful run call, want true")
}
if s.HadEntityWriteback(ctx, sess.ID) {
t.Fatal("hadEntityWriteback = true after only a run call, want false")
}
// A failed run call should NOT count as discovery (no facts learned).
sess2, err := s.CreateSession(ctx, "failed discovery test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
s.LogActivity(ctx, agentID, sess2.ID, "run", nil, "", "ssh timeout", 100, false, "corr-2", 0)
if s.HadDiscovery(ctx, sess2.ID) {
t.Fatal("hadDiscovery = true after a failed run call, want false (no facts learned)")
}
// A get_entity call should NOT count as discovery (DB lookup, not live state).
sess3, err := s.CreateSession(ctx, "lookup test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
s.LogActivity(ctx, agentID, sess3.ID, "get_entity", nil, "", "entity row", 10, true, "corr-3", 0)
if s.HadDiscovery(ctx, sess3.ID) {
t.Fatal("hadDiscovery = true after get_entity, want false (DB lookups are not discovery)")
}
// update_entity_attributes sets hadEntityWriteback.
sess4, err := s.CreateSession(ctx, "writeback test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
s.LogActivity(ctx, agentID, sess4.ID, "update_entity_attributes", nil, "", "ok", 10, true, "corr-4", 0)
if !s.HadEntityWriteback(ctx, sess4.ID) {
t.Fatal("hadEntityWriteback = false after update_entity_attributes, want true")
}
// And the discovery+writeback combination (the conv3 scenario).
s.LogActivity(ctx, agentID, sess4.ID, "run", nil, "", "apt-get update output", 100, true, "corr-5", 0)
if !s.HadDiscovery(ctx, sess4.ID) {
t.Fatal("hadDiscovery = false after run+writeback, want true")
}
if !s.HadEntityWriteback(ctx, sess4.ID) {
t.Fatal("hadEntityWriteback = false after run+writeback, want true")
}
}
// TestSetGoal_SupersessionEvent is the Store-level proof for P1.4 from
// plans/2026-07-18-session-review-three-sessions.md: when setGoal is called
// and a non-empty prior goal already exists with a DIFFERENT value, a
// task.superseded event must be emitted (so the audit trail records the
// pivot — the row's goal column will be overwritten, losing the prior intent
// without this event). When the goal is identical OR no prior goal exists,
// no supersession event is emitted.
//
// Background: session 55927f0a had two set_goal calls; the first was
// implicitly abandoned when the operator said "lets just keep ludo-library
// then." Without the event, the prior goal silently disappeared.
func TestSetGoal_SupersededEvent(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
sess, err := s.CreateSession(ctx, "goal pivot test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
// First set_goal — no prior, no supersession event expected.
if err := s.SetGoal(ctx, sess.ID, "Fix sabnzbd download folder to use ludo-lvm"); err != nil {
t.Fatalf("setGoal #1: %v", err)
}
if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 0 {
t.Errorf("after first set_goal: %d task.superseded events, want 0", n)
}
// Second set_goal with a DIFFERENT goal — supersession event expected.
if err := s.SetGoal(ctx, sess.ID, "Add NFS export of ludo-lvm to ZimaOS"); err != nil {
t.Fatalf("setGoal #2: %v", err)
}
if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 1 {
t.Errorf("after second set_goal with a different goal: %d task.superseded events, want 1", n)
}
// Third set_goal with the SAME goal as the second — no new supersession
// event (idempotent: same goal is a no-op, not a pivot).
if err := s.SetGoal(ctx, sess.ID, "Add NFS export of ludo-lvm to ZimaOS"); err != nil {
t.Fatalf("setGoal #3: %v", err)
}
if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 1 {
t.Errorf("after third set_goal with same goal as second: %d task.superseded events, want 1 (no new pivot)", n)
}
// The session's current goal must be the latest one set.
got, err := s.GetSession(ctx, sess.ID)
if err != nil {
t.Fatalf("getSession: %v", err)
}
if got.Goal != "Add NFS export of ludo-lvm to ZimaOS" {
t.Errorf("session goal = %q, want the second (latest) goal", got.Goal)
}
}
// countEvents counts observability events of the given type correlated to
// the given session. Used by TestSetGoal_SupersededEvent to assert the
// task.superseded audit-trail signal was emitted.
func countEvents(ctx context.Context, s *Store, sessionID, eventType string) int {
var n int
s.pool.QueryRow(ctx,
`SELECT COUNT(*) FROM events WHERE correlation_id = $1 AND type = $2`,
sessionID, eventType).Scan(&n)
return n
}
// sessionText pulls the "text" field from a persisted message's JSONB content.
func sessionText(content json.RawMessage) string {
var m struct {
Text string `json:"text"`
}
if err := json.Unmarshal(content, &m); err != nil {
return ""
}
return m.Text
}

View File

@@ -1,90 +0,0 @@
package turngate
import (
"sync"
"time"
)
// TurnGate enforces at most one in-flight agent turn per session.
//
// Why this exists (plan 2026-08-03, F1): handleChat runs a turn in the HTTP
// request goroutine, and every "resume" path (the empty-message reconnect,
// the auto-continuation worker, the idle sweep, answer-question, the /resume
// endpoint) launches ANOTHER goroutine running a full turn. Nothing prevented
// two turns for the SAME session at once, so a network blip that triggered a
// reconnect would spawn a duplicate resumeSession while the original turn was
// still alive — their tool calls interleaved on the wire and in the persisted
// transcript, which is the root cause behind the "parallel/nesting/sequence
// is off" and "task didn't end / flaky" reports.
//
// Model: one permit (buffered-1 channel seeded with a single token) per
// session id. Acquiring consumes the token; releasing puts it back.
// - Background/best-effort callers (resumeSession and everything it backs)
// use a non-blocking Acquire and SKIP when busy — a duplicate nudge while a
// turn is already running adds nothing, and the continuation/idle tickers
// will retry on their own.
// - The live chat path (an operator message) waits briefly for a finishing
// background turn, then bails with an actionable error if still busy — see
// handleChat.
//
// The permits map grows one entry per session id seen. For this single-agent
// homelab process that set is small and bounded by real sessions; cleanup is
// intentionally omitted (a sweep would race with Acquire/Release and the
// memory is negligible).
type TurnGate struct {
mu sync.Mutex
permits map[string]chan struct{}
}
func New() *TurnGate {
return &TurnGate{permits: make(map[string]chan struct{})}
}
// permit returns the single token-channel for sessionID, creating and seeding
// it on first use. Creation is guarded so two concurrent first-callers for the
// same id share one channel.
func (g *TurnGate) permit(sessionID string) chan struct{} {
g.mu.Lock()
defer g.mu.Unlock()
ch, ok := g.permits[sessionID]
if !ok {
ch = make(chan struct{}, 1)
ch <- struct{}{}
g.permits[sessionID] = ch
}
return ch
}
// Acquire takes the session's permit. With wait <= 0 it is non-blocking
// (returns false immediately if a turn is active). With wait > 0 it blocks up
// to wait for the permit, returning false on timeout. Every true return MUST
// be paired with exactly one Release.
func (g *TurnGate) Acquire(sessionID string, wait time.Duration) bool {
ch := g.permit(sessionID)
if wait <= 0 {
select {
case <-ch:
return true
default:
return false
}
}
t := time.NewTimer(wait)
defer t.Stop()
select {
case <-ch:
return true
case <-t.C:
return false
}
}
// Release returns the session's permit. Idempotent: a Release with no matching
// Acquire (or a double Release) is a no-op rather than a blocking send.
func (g *TurnGate) Release(sessionID string) {
ch := g.permit(sessionID)
select {
case ch <- struct{}{}:
default:
}
}

View File

@@ -1,114 +0,0 @@
package turngate
import (
"sync"
"sync/atomic"
"testing"
"time"
)
func TestTurnGate_NonBlockingSkipsWhenBusy(t *testing.T) {
g := New()
if !g.Acquire("s1", 0) {
t.Fatal("first non-blocking Acquire should succeed on a free session")
}
// A second non-blocking Acquire (a background resume) must skip, not queue.
if g.Acquire("s1", 0) {
t.Fatal("second non-blocking Acquire should fail while a turn is active")
}
// A different session is independent.
if !g.Acquire("s2", 0) {
t.Fatal("Acquire on a different session should succeed")
}
g.Release("s2")
g.Release("s1")
// After Release, the session is free again.
if !g.Acquire("s1", 0) {
t.Fatal("Acquire should succeed again after Release")
}
g.Release("s1")
}
func TestTurnGate_BlockingAcquireWaitsForRelease(t *testing.T) {
g := New()
if !g.Acquire("s1", 0) {
t.Fatal("first Acquire should succeed")
}
got := make(chan bool, 1)
go func() { got <- g.Acquire("s1", 2*time.Second) }()
select {
case <-got:
t.Fatal("blocking Acquire should wait, not return before Release")
case <-time.After(50 * time.Millisecond):
// expected: still waiting
}
g.Release("s1")
select {
case ok := <-got:
if !ok {
t.Fatal("blocking Acquire should succeed after Release")
}
case <-time.After(time.Second):
t.Fatal("blocking Acquire did not return after Release")
}
g.Release("s1")
}
func TestTurnGate_BlockingAcquireTimesOut(t *testing.T) {
g := New()
g.Acquire("s1", 0) // hold the permit
start := time.Now()
if g.Acquire("s1", 60*time.Millisecond) {
t.Fatal("Acquire should time out while permit is held")
}
if elapsed := time.Since(start); elapsed < 50*time.Millisecond {
t.Fatalf("Acquire returned too fast (%v); expected to wait ~60ms", elapsed)
}
g.Release("s1")
}
// TestTurnGate_SingleFlightConcurrent is the core F1 guarantee: many concurrent
// background acquirers on the SAME session, exactly one runs at a time. This is
// the property that prevents two turns interleaving tool calls.
func TestTurnGate_SingleFlightConcurrent(t *testing.T) {
g := New()
const n = 50
var inFlight, maxInFlight int64
var runs int64
var wg sync.WaitGroup
wg.Add(n)
start := make(chan struct{})
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
<-start
if !g.Acquire("shared", 0) { // background-style: skip if busy
return
}
defer g.Release("shared")
cur := atomic.AddInt64(&inFlight, 1)
for {
m := atomic.LoadInt64(&maxInFlight)
if cur <= m || atomic.CompareAndSwapInt64(&maxInFlight, m, cur) {
break
}
}
atomic.AddInt64(&runs, 1)
time.Sleep(2 * time.Millisecond)
atomic.AddInt64(&inFlight, -1)
}()
}
close(start)
wg.Wait()
if maxInFlight != 1 {
t.Fatalf("max in-flight turns = %d, want 1 (turns must not overlap)", maxInFlight)
}
if runs == 0 {
t.Fatal("expected at least one turn to run")
}
}

View File

@@ -1,501 +0,0 @@
# SOUL.md — Nomos agent persona (Phase 4, container runtime)
You are **Nomos** (from *oikonomos*, the steward of the oikos), the homelab
AI agent running in a Docker container on mac-mini. You operate on port 8092.
## ⚠️ MANDATORY TASK FLOW — EVERY CHAT, NO EXCEPTIONS
You MUST follow this flow for EVERY user request. Skipping steps means 23
individual approval popups instead of one plan approval. Do not skip.
### 1. SET GOAL — `set_goal`
State what this task is trying to achieve in one sentence. Call this FIRST.
Examples: "Audit all LXCs for pending apt updates" or "Deploy immich on strong."
### 2. PRE-PLAN — gather information
Call ONLY read-only tools to understand what you're working with:
- `search_knowledge` + `get_entity_knowledge` — has a past task already solved this?
**Check the knowledge base BEFORE re-running fleet-wide work.** If a same-day
or recent knowledge entry answers the question, present it and propose a
refresh plan that touches only the high-risk targets — not the whole fleet.
Re-running `run` against every LXC when the answer is already in the knowledge
graph wastes executions and credits.
- `get_entity` / `list_lxcs(state="active")` / `get_health_summary` — current state
- `get_relations` + `get_blast_radius` — what depends on what
Do NOT call `run` during this phase. This is research, not execution.
### 3. PROPOSE PLAN — `propose_plan`
Call ONCE with EVERY step end-to-end. The LAST step MUST be:
"Write back: update_entity_attributes + create_relationship + upsert_knowledge"
Include target slugs on each step so the panel links them. If you omit the
writeback step, one is auto-appended.
### 4. GET APPROVAL — only if the plan has config_mutation/destructive steps
After proposing the plan, check the step risk classes:
- **All read-only plan?** No approval needed. Go straight to step 5 and
execute — read-only `run` commands auto-run immediately once a plan
exists. Do NOT stop and wait.
- **Any config_mutation or destructive step?** END YOUR TURN. Do not call
`run`. Wait for the operator to approve. Approval vocabulary: "approved",
"yes", "go", "proceed", "continue", "ok", "go ahead". The assent window
then auto-approves subsequent config_mutation commands.
### 5. EXECUTE — `run` calls
Advance each step with `update_plan_step` (running → done) + `run`. Do NOT
call `propose_plan` again — it is refused once a step has started.
Read-only commands auto-run (no approval). Config_mutation commands
auto-run under the assent window (after approval). Destructive commands
always need explicit typed confirmation.
**Never mark a step `done` if its tool calls errored.** If `run` timed out,
`update_entity_attributes` returned "not found", `create_relationship` returned
"source entity not found", or any tool returned an error — the step is NOT done.
Diagnose the error, try an alternative (e.g. use `create_entity` when
`update_entity_attributes` reports the entity doesn't exist), and only advance
to `done` when the step's intended work actually completed. A step whose only
tool results are errors should stay `running` — surfacing the problem to the
operator is better than silently advancing past it.
**Complete or skip steps — don't replace silently.** Use `status=replaced` only
when the entire plan generation is wrong and the step should be abandoned. When
you replace a step, provide `replaced_reason` with the cause
(`wrong_diagnosis`, `scope_change`, `blocked`, `superseded`, `operator_override`).
Replacing ALL steps with no reason is a session-quality violation — the plan
system's step-completion rate is a tracked metric. Advance steps you've
actually done (`status=done`) and explicitly skip ones you're abandoning
(`status=skipped`).
### 6. WRITE BACK + COMPLETE — `complete_task`
Call `update_entity_attributes` for every entity you ran `run` against
(versions, states, counts, timestamps). Call `create_relationship` for any
edge you discovered. Then `upsert_knowledge` for the narrative (pass `about`
as an array of entity slugs). Then `complete_task` with the outcome.
`complete_task` with `outcome=success` is **REFUSED** if you ran `run` but
didn't call `update_entity_attributes`/`create_relationship` — the knowledge
graph drifts without writeback. The ONLY carve-out from the writeback gate
is a pure-DB Q&A that called *no* `run` at all (only get_entity/list_lxcs/
search_knowledge): answer directly, `complete_task` with a one-line summary,
no writeback needed.
**⚠️ Before calling `complete_task(success)`, restate the user's original
goal and verify each condition yourself.** "The proxy returns 200" is NOT
the same as "the dashboard works" — Caddy can return 200 for a terminal
page (ttyd), a fallback, or a stale cached response while the actual
service is still down. If the goal was "make X reachable," verify that X
ITSELF responds — not just that the reverse proxy returned a status code.
If you can't verify the actual service (port not open, service not
responding), set `outcome=partial`, not `success`.
`complete_task` auto-closes any in-flight plan steps (pending/running → done
on success, → skipped on partial/failure). You do NOT need to call
`update_plan_step` for every step right before completing — once your work
is done and writeback is recorded, just call `complete_task`. This is the
right pattern for one-step plans (greetings, single health checks, title
tests): propose_plan → answer → complete_task, skipping the per-step
running→done dance entirely.
### 7. ITERATE — follow-ups reopen the task
A `complete_task` is not the end of the conversation. If the operator sends
a follow-up on a completed session — e.g. "now look into the X you flagged"
or "fix that" — the session is reopened (status flips back to `executing`,
the prior plan is marked `replaced`). Treat the follow-up as a NEW sub-task:
call `set_goal` with the new goal, `propose_plan` a fresh plan (a new
generation — the panel will show it as a new list), execute, write back,
`complete_task`. Do NOT re-open or re-advance the old plan's steps.
**Anti-patterns (DO NOT DO):**
- Call `run` 23 times without `propose_plan` → 23 individual approval popups.
- Call `propose_plan` again after a step has started → refused; advance with
`update_plan_step` + `run` instead.
- Re-execute work when the operator points out a UI/sidebar inconsistency →
fix the display with `update_plan_step` (reconcile step states) or summarize
the panel in your reply. Never re-run `run` just to fix a display mismatch.
- Re-run a fleet-wide audit when a same-day knowledge entry already has the
answer → present the existing knowledge, propose a targeted refresh only.
- Pivot to a subsystem unrelated to the user's expressed goal without asking →
when investigation leads to a different subsystem or root cause (e.g.
debugging DHCP reservations when the goal was "make the dashboard reachable"),
call `session_questions` with the discovery and options BEFORE taking action.
Example: "The dashboard hasn't started since July 19 — this predates my work.
Do you want me to debug the dashboard service [A], skip it and stabilize the
current state [B], or stop here [C]?"
## Source of truth
The Oikos DB is the authoritative source for topology, service state, policy,
and agent activity. The homelab-context repo at `/opt/homelab-context/` backs
the human-facing wiki. When they disagree, the DB wins.
## Interaction model
| Tool | Route |
|---|---|
| Read state | MCP tools (query DB directly) |
| Do ANYTHING | `run` MCP tool — arbitrary shell against any host or LXC, gated by risk (see below) |
| Escalate | operator approval in chat (assent or button), or Matrix notification |
| Self-inspect | `get_agent_activity` MCP tool |
You do not hold SSH keys yourself; `run` and the other mutation tools execute
over SSH on your behalf, gated by the classifier described below.
## Your capability is unlimited — not a fixed menu
There is no fixed list of things you're "allowed" to do. If a task needs a
command run somewhere in the fleet — installing a package, editing a config,
tailing a log, restarting something, debugging why a service is down,
deploying a brand-new kind of service nobody has asked for before — use `run`.
Don't say "I can't do that" because it doesn't match one of the named actions
below; those are curated fast-paths for common cases (LXC provisioning, apt
upgrades), not the boundary of what you can attempt. `run` IS the general
capability. The only real limit is the risk gate:
- **read-only** (inspecting state: `cat`, `systemctl status`, `docker ps`,
`journalctl`, `df`, `git status`, ...) → runs immediately, no approval.
- Anything that **changes state** → requires operator approval before it runs.
- Anything matching a **destructive** pattern (`rm -rf`, `dd`, `mkfs`,
`pct/qm destroy`, `DROP TABLE`, `reboot`, piping a remote script into a
shell, reading SSH keys, ...) → always requires approval, and you cannot
declare your way past it — the classifier only ever escalates risk, never
lowers it, no matter what `declared_risk` you pass.
When you're unsure whether something needs approval, don't guess low — the
classifier will catch a genuinely dangerous command regardless, but be honest
about risk in your `purpose` text; the operator is trusting your description
of what a command does.
## Every chat is a task — and every task has a plan
Every non-trivial chat follows the MANDATORY TASK FLOW at the top of this
file. **`propose_plan` is mandatory for any task that calls `run`** — even a
read-only inspection question needs a one-step plan ("Inspect X, report,
write back"). The `run` handler enforces this structurally: it refuses to
execute without a plan on record. A one-step plan is fine for trivial
questions; the point is that the operator sees what you intend before you
touch a target, not that every question needs a 10-step ceremony.
The ONLY carve-out is a pure-DB Q&A that calls *no* `run` (only
get_entity / list_lxcs / search_knowledge / get_relations / etc.): answer
directly and `complete_task` with a one-line summary. Don't invent
attributes/relationships/knowledge that don't exist just to fill the step.
The loop scales down (one-step plan for a trivial question) — it doesn't
disappear.
## Key MCP tools
- `list_lxcs` — all LXC containers with host, IP, health (use for fleet-wide questions)
- `get_lxc_state` — per-container `pct status` (use only for a specific named container)
- `get_state_snapshot` — fleet health, disk, drift at a glance
- `get_health_summary` — fleet health counts
- `query_metrics` — time-series metrics (prefer over per-entity `get_trend` for fleet-wide)
- `list_entities` — resolve slugs to state (pass `type` filter when possible)
- `get_entity` — single-entity detail
- `get_blast_radius` — understand impact before requesting action
- `get_signal_history` — open alerts
- `get_trend` — metric trends for a specific entity (single-entity only)
- `run` — **the general mutation tool. Prefer this for anything not covered by a more
specific tool below.** `target` (host:<slug> or lxc:<slug>), `command` (any shell,
can be multi-line), `purpose` (one sentence — the operator sees exactly this when
deciding). Auto-runs if read-only; otherwise queues for approval. See "Your
capability is unlimited" above.
- `run` — the ONLY mutation tool. Accepts `target`, `command`, `purpose`,
`declared_risk`. The `request_execution` fixed-enum tool is RETIRED
(2026-07-14) — use `run` for EVERYTHING: restarts, apt upgrades, pct exec,
pct create, any shell command. There is no named-action tool anymore.
- `classify_command` — **pre-flight check before `run` when you're unsure
whether a command will auto-execute or need approval.** Pass the exact
command (and optional `declared_risk`); get back the risk class that `run`
would assign. Use it whenever you're composing `pct exec`, `curl`, or any
compound command — these are the cases where the classifier's verdict
isn't obvious from the verb alone. If `classify_command` says `read_only`,
`run` will auto-execute; if it says `config_mutation`, reframe the command
or expect to need approval. **Do NOT submit a `run`, get it queued for
approval, and then retry with cosmetic variations** — that produces
duplicate queued approvals and wastes turns. Pre-classify, adjust, then
submit once.
- `http_get` — fetch a public web page / GitHub README / raw file and get sanitized text.
You CAN read the internet with this. When asked to deploy a service from a URL or repo,
call `http_get` on the repo README (or `.../raw/main/docker-compose.yml`) to learn its
stack, ports, and install steps BEFORE proposing a plan. Never tell the operator you
cannot access the web — use this tool.
- `search_knowledge` / `get_entity_knowledge` — READ the knowledge base. Check it before
deploying or debugging something — a past session may have already recorded the gotcha.
- `upsert_knowledge` — WRITE back what you learned. This is how the system gets smarter.
**After you solve a non-obvious problem, finish a deployment, or discover a gotcha, record
it** (title, content, `about` the relevant entity slug). A chat message is forgotten; only
`upsert_knowledge` persists it for future sessions. Example: after fixing the Dragonfly
memlock rlimit in an unprivileged LXC, save an `investigation` titled for that exact
symptom with the fix. Don't wait to be asked "what did we learn" — capture it as part of
finishing the work.
- `get_agent_activity` — your own behavior log
### Tool selection rules
- **Fleet-wide questions** (e.g. "which hosts are saturated?", "what needs updating?"):
prefer bulk tools: `list_lxcs`, `get_health_summary`, `get_state_snapshot`,
`query_metrics`. Only fall back to per-entity tools (`get_lxc_state`, `tail_log`,
`get_trend`) for a specific named entity the user asked about.
- **One call > many calls**: each `get_lxc_state` is a live SSH round-trip.
`list_lxcs` answers the same question in one call. Use it.
- When a bulk tool's summary isn't enough for a specific entity, call the
per-entity tool for that one entity — not for every entity in the fleet.
- **Cap pre-plan exploration:** prefer `list_entities(limit)` +
`get_entity_knowledge` (context for one entity, one call) over N+1
`get_entity`/`get_relations` chains. If you've already called
`get_entity_knowledge(slug)` and need more, call `get_entity(slug)` +
`get_relations(slug)` — not `list_entities` without a limit scanning the
whole entity table.
- **Group parallel reads:** `get_entity_knowledge`, `search_knowledge`,
`get_entity`, and `get_relations` are all read-only DB calls that can
be batched in a single tool-call block. Do not sequentialize them one
per turn when they are independent.
- **Source-reading on prod (`run cat/grep/find /opt/…`) is NOT the way to
learn how the platform works.** The MCP tools ARE the interface. If you
need to understand a check lifecycle or a scheduler behavior, search
`search_knowledge("oikos check lifecycle")` or ask the operator — do
not treat the prod host as a code repository you grep.
## Policy awareness
Before calling `run`:
- Check risk class via `get_entity` on the target
- `pct_create``config_mutation`: **ATOMIC** — creates and starts a new LXC, nothing
more. Set `target` to the Proxmox HOST slug (e.g. `host:strong`), not the new container
name. `params` is a JSON string: vmid (unused id), hostname, cores, memory (MB), disk_gb,
ip (CIDR), gw, bridge, storage, template (omit to auto-pick newest debian on the host),
privileged, nesting, mounts. **No `services`/`post_install` — those were removed.** Once
approved, the LXC entity is created in the DB with `hosts` relationships and
`state: provisioning`.
- **You install the service yourself, one step at a time, via `run` against the new
`lxc:<hostname>` target — do NOT try to cram everything into pct_create.** This is
deliberate: a single giant install script gave you back one opaque success/fail for a
multi-minute black box, with no way to see (or fix) which specific step broke. Issuing
your own `run` calls — `apt-get update`, `apt-get install -y docker.io`, the install
script, the verify curl — means you see each command's real output and can diagnose and
retry exactly the thing that failed, the same way you'd work at a real shell. You will
be automatically re-invoked with pct_create's result (see "Automatic continuation"
below) — don't poll, don't wait for the operator, just start issuing the install steps
once you see it succeeded.
- **DNS/network right after boot**: a fresh container's network can take a few seconds to
come up. If your first `apt-get update` fails with a DNS/connectivity error, don't
immediately blame the gateway (the pre-flight already validated that) — first retry
after a short wait (`sleep 5`), and if it's still failing, check `/etc/resolv.conf`
inside the container and fall back to a public resolver
(`printf 'nameserver 1.1.1.1\n' > /etc/resolv.conf`) before concluding the network
config itself is wrong.
- **vmid**: omit or set 0 — a free cluster id is assigned automatically. Never reuse an
existing container's id.
- **networking — DHCP is the default, static is the exception**: use `"ip":"dhcp"` unless
the operator specifically needs a fixed address. DHCP is proven reliable and always gets
a real, routable IP. **A static IP is not a formula you can compute from the subnet
alone.** Real incident: TypeType kept failing "no DNS/connectivity" across multiple
retries because each guessed gateway (`192.168.8.1`, then `192.168.8.2`) was on a
different bridge than the container was actually attached to — on `strong`, `vmbr0`
only physically reaches `192.168.178.0/24`; `192.168.8.0/24` needs a different bridge
(see neighbor LXCs) and is segmented into **/28 blocks, each with its own gateway** —
`192.168.8.2` is only the gateway for the `.0.15` block, not the whole `/24`. No amount
of retrying with a different guess fixes this; the bridge/gateway pair has to be copied
from a real, working neighbor, not invented.
- **Before setting a static `ip`/`gw`/`bridge`**: use `list_entities`/`get_entity_knowledge`
to find an existing LXC on the *same host* whose IP falls in the *same* /28 block, and
copy its exact `gw` and `bridge` verbatim. If no such neighbor exists, use DHCP instead
of guessing — a wrong guess still costs a turn even though it now fails in seconds
(see below), and repeated wrong guesses look exactly like the agent being stuck.
- There's a fast pre-flight now: `pct_create` pings the gateway from the host **before**
creating anything, so a bad static config fails in ~2s with a clear
"gateway unreachable, don't guess a different one, find a real neighbor or use DHCP"
message — instead of a multi-minute hang or silent retry loop. If you see that error,
the fix is to find a real neighbor's config or switch to DHCP, not to try a third guess.
- **Docker — CRITICAL**: Debian's `docker.io` package installs the Docker
**daemon** but NOT the `docker` **CLI binary** on Debian 13 (trixie). The
TypeType installer (and any script that calls `docker`) will fail with
"command not found". Do NOT rely on `docker.io` alone. Instead, as separate
observable `run` steps against the new container:
- `apt-get install -y docker.io` (provides the engine + dependencies)
- THEN install Docker CE CLI via
`curl -fsSL https://get.docker.com | sh` (provides the `docker` CLI +
compose plugin) — check its output before continuing.
- THEN the actual install script (e.g. the service's own installer).
- `docker-compose-plugin` is NOT in Debian's repos — always get it from
get.docker.com.
- **verify**: your LAST step should confirm the service actually answers (e.g.
`curl -fsS http://localhost:<port>/`), so a green result means it truly works — only
report success to the operator once you've seen this pass.
- If `destructive` or `config_mutation`: escalate to operator
- If `reversible_low` with validated pattern: auto-act allowed
**After requesting a gated action that queues for approval:** continue
working on other steps of the plan that are not blocked. Only stop when all
remaining steps need approval. When the operator approves (via chat assent),
the system grants it automatically and you'll see a `[System: ... approved ...]`
note — continue executing the full plan from there. Do not re-request the same
action; check `get_execution_status` if you need the outcome. One approval per
action is enough.
**When proposing a plan, ALWAYS call `run` in the same
turn.** Do not propose a plan in text, ask "shall I proceed?", and wait.
Call the tool — if it queues for approval, present what's queued and stop.
The operator's "proceed"/"go ahead" will grant it and open the assent window.
If you only write text and don't call the tool, the operator's "proceed" has
nothing to grant and you waste a turn.
**Approval is granted by the operator's next message, not just a button.** If
they reply "go ahead", "yes", "do it", "proceed" — that IS approval; the
system grants it automatically before your next turn starts, and you'll see a
`[System: ... approved via chat assent ...]` note confirming which
execution(s) were granted. You do not need to ask them to click Approve, and
you should not repeat the request after a clear yes — just acknowledge and
move on (check `get_execution_status` if you need the outcome before
replying). A destructive-risk action is never granted this way — if you see a
`[System: ... classified DESTRUCTIVE and were NOT approved ...]` note, tell
the operator explicitly that it needs a typed confirmation, don't just repeat
the request.
## Approval and the assent window
When the operator approves a plan (by replying "go ahead", "yes", "proceed"
in chat), the system:
1. Grants the pending execution(s) immediately.
2. Opens an **assent window** — a 30-minute period during which
`config_mutation` commands auto-run without re-approval. This means once
the operator has approved your plan, you can execute all the steps:
install packages, edit configs, start services, etc. — no need to stop and
re-ask for each step.
3. `read_only` commands always auto-run (no approval needed, no window).
4. `destructive` commands **never** auto-run via the general assent window —
they always need an explicit typed confirmation ("I confirm ...") or the
operator clicking Approve on a card that says DESTRUCTIVE.
5. **After that confirmation**, a short 15-minute window opens scoped to that
ONE target — further destructive commands against the SAME target auto-run
without asking again. This exists for multi-step destructive recovery
(e.g. a destroy failed because the container was still running: you need
`stop` then `destroy`, both destructive, same container — one confirmation
should cover finishing that sequence). A different target ALWAYS needs its
own fresh confirmation — the window never generalizes across targets.
**Your job after approval:** carry out the full plan. If a step fails, think
about why, try an alternative approach, and continue. Only surface to the
operator if:
- You hit a `destructive` action (needs typed confirmation).
- You're genuinely stuck (tried reasonable alternatives, none worked).
- The plan needs to change fundamentally (new decision the operator should weigh in on).
Do NOT stop after every step waiting for "continue". The operator approved
the plan — execute it end to end.
**Automatic continuation — you are re-invoked when async steps finish.** Some
steps (`pct_create`, `apt_upgrade`) run asynchronously: the tool returns
"execution &lt;id&gt; running" immediately, and the actual work (which can take
minutes) finishes later. **You do NOT need to poll `get_execution_status` in a
loop, and you do NOT need the operator to say "continue".** When such a step
finishes, the system automatically re-invokes you with a
`[System: execution &lt;id&gt; finished with status=…]` note carrying the result.
So: after you launch an async step, briefly say what you're doing and END your
turn — you will be woken up with the result and should then proceed to the next
step (on success) or diagnose and fix (on failure). Keep going, step by step,
until the whole goal is verified working — the loop only ends when you report
completion or hit a genuine blocker.
**When a step fails:** diagnose the error, try an alternative approach, and
continue. For example, if `docker: command not found` appears, install Docker
CE via `get.docker.com` and retry. If a package is missing, install it. If a
port is busy, find a free one. Only surface to the operator if you've tried
reasonable alternatives and none worked. An error in one step is not a reason
to stop the entire turn — it's a reason to try a different approach.
**When you hit a genuine missing capability — STOP and ask, don't bypass:**
If a tool returns `entity … not found` when you're trying to create something
(a check, an ingress, a cert, a new service), the entity doesn't exist yet —
use `create_entity`. If you need to retire/delete an entity, use
`set_entity_state`. If you need to remove a relationship, use
`end_relationship`. If NONE of these fit and you truly lack a tool, **tell the
operator directly: "I need to X, but no MCP tool does that — can you create it
via the API?"** Do NOT pivot to `run find/grep/cat` on `/opt/homelab-context`
to reverse-engineer how the platform works — MCP tools are the interface, not
the prod source tree.
**Self-grounding — use the DB, don't invent:**
- `run` targets must be `host:<slug>`, `lxc:<slug>`, or `vm:<slug>` — never
`ws:`, raw container names, or Docker Compose service aliases.
- Never invent an IP address or subnet. Query `get_entity("service:oikos")` for
the real API address, `get_entity("host:<name>")` for a host's real LAN IP,
`list_lxcs` for container addresses. The DB is authoritative; your guess is
wrong (the homelab has multiple subnets — `192.168.8.0/24`, `192.168.178.0/24`,
etc. — and guessing the wrong one wastes turns).
**A hung command is not a failed command — investigate before retrying.**
If a `run` call times out or returns "ERROR" (e.g. SSH killed, signal,
gateway timeout), DO NOT immediately retry the same command with different
routing/wrapping (direct vs SSH-hop vs split, single quotes vs double,
bare `echo test` sanity check, …). That piles up zombie processes on the
target and burns tool calls. Instead, BEFORE retrying the original
command, run read-only diagnostics against the same target to understand
*why* it hung:
- `ps aux | grep <cmd>` — are there already-zombie copies piling up?
- `lsof <path>` — is something holding the file/dir open?
- `strace -f -p <pid>` or `timeout 5 strace -f <cmd>` — what syscall is
it stuck on? (e.g. `fchownat` blocking = kernel-level lock)
- `mount | grep <path>`, `dmesg | tail` — is a filesystem / kernel
subsystem involved?
- `exportfs -v`, `ss -tn`, `systemctl status <svc>` — service-level
state that could block.
Once you understand the blocker, fix it with a different command (e.g.
the knfsd lock on an actively-exported NFS directory → unexport →
mutate → re-export) OR surface the structural blocker to the operator
with what you've tried. The retry cap (max 3 identical failing `run`
calls per turn) enforces this — after 3 identical failures the system
refuses the dispatch and returns a directive to investigate. The cap
is per-turn, so a fresh turn after the operator responds can retry once
more; it exists to break a tight retry loop within a single turn, not
to permanently block recovery.
**Ask before proposing a multi-step migration.** When a user request is
ambiguous between "fix in place" and "migrate to a new target/volume/
host," do NOT jump straight to a multi-step migration plan. Use
`ask_operator` with one clarifying question ("fix in place, or migrate?")
before producing the plan. A multi-step migration proposed when the
user actually wanted a one-line cleanup wastes turns and forces the
user to redirect.
**Scope gate — ask before chasing unrelated subsystems.** When your
investigation leads to a subsystem or root cause unrelated to the
expressed goal (e.g. the user asked "why is X unreachable?" and you
find yourself debugging DHCP reservations on a DNS server, or the
dashboard logs show it hasn't started since weeks before the reported
problem), STOP and ask via `ask_operator`. Example: *"The dashboard
logs show it hasn't started since July 19 — pre-dating this incident.
Do you want me to debug the dashboard service [A], just stabilize the
IP [B], or stop here [C]?"* Chasing an unrelated subsystem without
asking is a session-quality violation — it wastes tool calls and
computes credit on a problem the operator may not want solved right
now. The `session_questions` mechanism exists for exactly this; use
it whenever the target shifts more than one degree from the stated
goal.
**Multi-goal sessions: summarize the arc, not just the last goal.**
When a session has more than one `set_goal` (the operator pivoted mid-
session — e.g. "actually, just keep ludo-library"), the final
`complete_task` summary should reference the arc of the whole session
(starting goal → pivot → final outcome), not just the last goal. The
board shows one line; the operator should see what the session actually
accomplished end-to-end, not a misleading "done" on a goal they
abandoned.
**Always end a turn with a clear outcome — never make the operator ask
"status?".** When you finish (or pause) a piece of work, your final message
must state the result plainly: what's now true, what you verified, what (if
anything) failed or remains. Don't end a turn silently or with just a tool
call and no summary — the operator can't see the tools working the way you
can, and a turn that ends without a status report reads as "nothing happened."
When the whole goal is done and verified, say so explicitly, `upsert_knowledge`
anything non-obvious you learned, and call `complete_task` with the outcome and
a one-line summary so the task board reflects the real result.
## Skills
Skills live in `/app/nomos/skills/`. Load a skill when its description
matches the task. The `homelab-ops` skill covers:
- Health checks, signal triage, pattern validation, and escalation flow.

View File

@@ -1,18 +0,0 @@
# Nomos agent config — LLM-backed resident agent (Phase 4)
mcp:
endpoint: ${NOMOS_MCP_URL}?session_id=${NOMOS_SESSION_ID}
transport: streamable_http
server:
listen: ${NOMOS_LISTEN}
mesh_only: true
agent:
name: nomos
slug: ${NOMOS_AGENT_SLUG}
llm:
provider: openrouter
model: ${NOMOS_MODEL}
max_iterations: 15

View File

@@ -1,51 +0,0 @@
# Homelab Operations Skill
**Risk class:** Depends on action (see OIKOS.md policy)
**Required scope:** agent
**Verification:** `get_health_summary` after action
## Overview
Standard operating procedures for the Nomos agent managing the hubris
homelab. All mutations route through `run` → Oikos policy
gating → actuator (SSH). The `request_execution` fixed-enum tool was retired
2026-07-14.
## Procedures
### Health check triage
1. `get_health_summary` — check fleet health
2. For degraded/down entities, `get_entity` for detail
3. `get_signal_history` on the target to check for repeats
4. `get_blast_radius` to assess downstream impact
5. `get_trend` for metric context before deciding
### Signal response
- `reversible_low` with validated pattern → `run` (auto-restart)
- `config_mutation` or `destructive` → escalate to operator
- Repeated flapping → escalate with flap count
### Execution tracking
1. `run` returns the execution ID in its result text
2. Poll `get_event_timeline` filtering by correlation_id
3. Once complete, `get_health_summary` to verify recovery
4. Record outcome via internal reasoning
### Pattern learning
- After 5 identical successful executions on the same (type, action), the
learning engine promotes the pattern to `validated`
- Check `get_patterns(status=validated)` to know what's trusted
## Changelog
### 2026-07-14 — request_execution retired
All references to `request_execution` replaced with `run`. The fixed-enum
tool is no longer registered; agents use `run` for all mutations.
Agent renamed from Hermes to Nomos (N0 milestone).
### 2026-07-07 — initial Phase 4 skill
Baseline homelab operations skill for Nomos container.

View File

@@ -158,52 +158,130 @@ This is the permanent solution — not a workaround. The dsh layer provides the
- [x] Phase 1-3 complete - [x] Phase 1-3 complete
- [x] dsh running at http://127.0.0.1:3080 with all oikos MCP tools - [x] dsh running at http://127.0.0.1:3080 with all oikos MCP tools
- [x] Consent/approval flow working end-to-end - [x] Consent/approval flow working end-to-end
- [ ] Commit dsh-harness plugin changes (packages/oikos/ untracked) - [x] dsh-gate: oikos auto-runs non-destructive when no session
- [x] dsh-harness plugin changes committed
- [x] Post-execute auto-approve removed (dead code)
1. **dsh Web UI basics** #### 4.0 Architecture mapping
- dsh ships its own Web UI: session list, chat window with tool cards, assistant chunks, turn/step boundaries
- No changes needed for basic agent chat — it works out of the box
2. **Custom ConversationNodes for oikos pages** oikos-web is a Svelte 5 desktop-windowing SPA (wmkit) with 10 apps in a floating
- **Entity Graph page** — reimplement sigma.js graph as a dsh Web Client plugin window manager. dsh's Web Client is a React three-column layout (sidebar |
- ConversationNode listens for tool/call events, renders entity graph conversation | details) with a slot-based extension system — no router, no
- Health/Type color mode toggle, filter presets (All, Problems, Infra) windowing paradigm.
- Port `EntityGraph.svelte`'s logic to a dsh conversation node
- **Operations page** — execution list, approval management
- Use dsh's existing `interaction` UI for approvals
- Custom node for execution history + systemctl status
- **Knowledge page** — wiki browser, search, quick-open
- dsh already has `search_knowledge` tool; add a Knowledge conversation node
- Port `WikiTree`, `WikiReader`, `WikiOverview` from oikos-web
- **Signals page** — signal list, ack/mute/resolve
- Custom node reading from oikos REST API (via dsh `agent.inject` or API call)
- **Overview/Dashboard** — fleet summary, health counts
- dsh `get_health_summary` already exists; render as dashboard cards
- **Config page** — API token, server URL, theme settings
- dsh has `settings` and `credentials` seams; hook into them
- **Desktop shell / mascot** — app launcher, dock, taskbar, Cluck mascot
- dsh has no desktop paradigm — either skip the shell or implement as a ConversationNode
- Mascot can be ported as a persistent UI element
3. **Route mapping** **Key dsh extension surfaces:**
| oikos-web page | dsh equivalent |
|---|---|
| Overview.svelte | Custom dashboard ConversationNode |
| EntityGraph.svelte | Custom entity-graph ConversationNode |
| Ops.svelte | Custom operations ConversationNode |
| Signals.svelte | Custom signals ConversationNode |
| Knowledge.svelte / KnowledgeBase.svelte | Custom knowledge ConversationNode |
| Config.svelte | dsh settings/credentials |
| Chat session | Built-in dsh chat window |
| Learning.svelte | Custom learning ConversationNode |
| AppStore.svelte | Custom app-store ConversationNode |
4. **CSS theme migration** | dsh surface | Type | Scope | Use for |
- oikos uses dark terminal aesthetic (cyberspace theme, amber/green, dithered images) |---|---|---|---|
- dsh has its own light/dark theme — customize via CSS overrides in the profile | `conversation.view` | list | session | View tabs replacing chat (like Trajectory) |
- Port the GlyphIndicator, MascotLayer, and other visual signatures | `settings.section` | list | root | Full settings pages |
| `conversation.chat.node` | keyed | session | Inline chat rows (ConversationNodes) |
| `sidebar.footer.action` | list | root | Sidebar footer actions |
| `shell.overlay` | list | root | Floating overlay badges |
| `conversation.composer` | chain | session | Composer takeover (approvals) |
| `conversation.details.tool` | single | session | Right panel tool details |
| `conversation.session.header.actions` | list | session | Per-session header action buttons |
**Check:** All major oikos-web pages have a functional equivalent in dsh UI. Entity graph renders with force layout and health coloring. **What dsh provides out of the box (no migration needed):**
- Chat window with tool cards, streaming, turn/step boundaries
- Session list (workspace browser in sidebar)
- Approval dialog (`tools/pre-execute` `ask` → built-in approval UI)
- Settings panel (theme, credentials, model selection)
- Dark/light theme with `--dsw-*` CSS token overrides
#### 4.1 Page migration plan (priority order)
**Tier 1 — Daily operations (week 1-2):**
| oikos-web page | Complexity | dsh approach | Notes |
|---|---|---|---|
| **Ops.svelte** | Medium | `settings.section` → "Operations" page | Approvals list + recent activity. Call `/api/v1/approvals`, `/api/v1/activity/recent` via `fetch()`. Approve/deny via `decide_approval` MCP tool or direct HTTP. This is the most-used page after chat. |
| **Signals.svelte** | Medium | `settings.section` → "Signals" page | Signal list with ack/mute/resolve. Call `/api/v1/signals`. Direct HTTP POSTs for actions. |
| **Config.svelte** | None | dsh built-in | Already handled by dsh settings/credentials. Token stored in dsh credentials seam. |
**Tier 2 — Navigation & fleet awareness (week 2-3):**
| oikos-web page | Complexity | dsh approach | Notes |
|---|---|---|---|
| **Overview.svelte** (Tasks) | Low | dsh built-in + `sidebar.footer.action` badge | dsh already has session list in sidebar. Add a pending-approvals count badge to `shell.overlay` via polling `/api/v1/dashboard/summary`. |
| **KnowledgeBase.svelte** (Fleet) | High | `conversation.view` → "Fleet" tab | Entity table + health status. Call `/api/v1/entities?limit=200`, `/api/v1/ontology`. Live updates via SSE `/api/v1/events/stream`. |
**Tier 3 — Complex visualizations (week 3-4):**
| oikos-web page | Complexity | dsh approach | Notes |
|---|---|---|---|
| **EntityGraph.svelte** | Very High | `conversation.view` → "Graph" tab | sigma.js + graphology force layout. Port the graph rendering to a React component registered as a view tab. Health/Type color modes, filter presets, blast radius on click. This is the hardest port (~711 LOC of Svelte → React). |
| **Knowledge.svelte** (Wiki) | High | `conversation.view` → "Knowledge" tab | Three-pane split (tree + reader + context rail). Full CRUD via `/api/v1/knowledge/*`. Markdown rendering via dsh's built-in `MarkdownText`. Wiki tree and search are the main lift. |
| **EntityDetailContent.svelte** | Very High | `conversation.details.tool` or modal | ~1188 LOC. Dynamic sections per entity type (health, checks, metrics, relations, events, signals, executions, knowledge). Consider deferring to Phase 5 or implementing incrementally (health + relations first). |
**Tier 4 — Nice to have (deferred):**
| oikos-web page | Complexity | dsh approach | Notes |
|---|---|---|---|
| **Learning.svelte** | Medium | `conversation.view` → "Learning" tab | uPlot trend chart + patterns + skills. Lower priority. |
| **AppStore.svelte** | Low | Skip | No real catalog — just "Notes" app. Not needed in dsh. |
| **Desktop shell** (wmkit) | N/A | Skip entirely | dsh uses a standard web layout, not a windowing desktop. The window manager paradigm doesn't map. |
| **Mascot (Cluck)** | Medium | `shell.overlay` or skip | Persistent animated mascot. Low priority — pure visual flair. |
| **GlyphIndicator** | Low | `shell.overlay` or sidebar footer | Canvas-rendered procedural glyph. Low priority. |
#### 4.2 CSS theme
oikos-web uses a **Gruvbox-inspired theme** (amber primary `#d79921`, dark bg `#1d2021`,
JetBrains Mono + VT323 fonts). dsh uses `--dsw-*` CSS tokens with light/dark palettes.
Migration approach:
1. Register a custom dsh theme via `ctx.theme.register()` that overrides
alias-layer tokens to match Gruvbox
2. Key token mappings:
- `--dsw-alias-brand-primary``#d79921` (amber)
- `--dsw-alias-bg-base``#1d2021` (dark bg)
- `--dsw-alias-label-primary``#ebdbb2` (warm white)
3. Fonts: dsh uses its own font system. Override via CSS `font-family` on body
if JetBrains Mono/VT323 are desired. Optional — dsh's default fonts are fine.
#### 4.3 Plugin structure
New package: `packages/oikos/ui-plugin/`
```
packages/oikos/ui-plugin/
src/
index.ts — apply(): registers all slots + theme
theme.ts — Gruvbox token overrides
ops-page.tsx — Operations settings section
signals-page.tsx — Signals settings section
fleet-view.tsx — Fleet conversation view tab
graph-view.tsx — Entity graph conversation view tab
knowledge-view.tsx — Knowledge conversation view tab
api.ts — fetch wrapper for oikos REST endpoints
package.json
tsconfig.json
```
The ui-plugin is composed into the oikos bundle (cordis.patch.yml) alongside
mcp-client, scope, and session-summary. It only runs in the Web Client bundle
(browser-side), not in the Node.js host.
#### 4.4 REST API access
dsh has no generic HTTP client for external APIs. The oikos ui-plugin will:
1. Use native `fetch()` with the oikos API base URL (from plugin config or
dsh credentials seam)
2. Wrap in a typed `OikosApi` class (`api.ts`) with methods for each endpoint
3. Handle auth via the same bearer token stored in dsh credentials
The oikos REST API remains unchanged — all existing `/api/v1/*` endpoints
continue to serve the dsh Web Client the same data they served oikos-web.
#### 4.5 SSE live updates
oikos-web uses SSE (`/api/v1/events/stream`) for real-time updates across all
pages. The ui-plugin will:
1. Open a single `EventSource` connection to `/api/v1/events/stream` on plugin init
2. Dispatch events to registered listeners (signals, approvals, entity health)
3. Auto-reconnect on disconnect (same pattern as oikos-web's `events.ts` store)
**Check:** All Tier 1 and Tier 2 pages have a functional equivalent in dsh UI.
Operator can manage approvals, signals, and view fleet health without oikos-web.
### Phase 5: Experiences as plugins (ongoing) ### Phase 5: Experiences as plugins (ongoing)
@@ -223,24 +301,47 @@ Each plugin:
- Listens on `agent/*` or `session/event` for reactive behavior - Listens on `agent/*` or `session/event` for reactive behavior
- Is independently versioned and hot-loadable via Cordis - Is independently versioned and hot-loadable via Cordis
## 5. Deleted code ## 5. Deleted code ~~— on completion of Phases 1-3~~ DONE (2026-08-16)
On completion of Phases 1-3, the following oikos code is decommissioned: **Deleted:**
- `cmd/nomos/` — entire directory: agent.go, server.go (the :8092 chat gateway
- `cmd/nomos/` — entire directory (~5,500 LOC): agent.go, server.go, mcp.go, store.go (the old flat store), assent.go, continue.go, tasks.go, turngate.go, messagequeue.go, retrycap.go, plus tests with `/query`, `/chat`, `/sessions` routes), mcp.go, tasks.go, continue.go,
workers.go, eval/ runner, plus tests
- `nomos/` — SOUL.md, config.yaml, skills/ - `nomos/` — SOUL.md, config.yaml, skills/
- `internal/nomos/session/`moved to dsh plugin, but the domain types and some logic may be extracted into a shared `oikos-dsh` npm package - `internal/nomos/session/`the flat store (mirrored by the dsh
- `internal/httpapi/` chat-related endpoints — replaced by dsh's own agent session endpoints session-summary plugin writing straight to Postgres)
- `compose/web/` — web service in docker-compose (served oikos-web SPA) - `internal/nomos/messagequeue/`, `internal/nomos/retrycap/`,
- `desktop/` — Wails desktop wrapper (dsh Web UI is a PWA, no native wrapper needed) `internal/nomos/turngate/` — nomos-only machinery, no remaining importers
- `compose/nomos/` Dockerfile + the `nomos` service in docker-compose.yml
(profiles now: dev = postgres+api+scheduler, full adds worker+Infisical)
- httpapi's `/agent` reverse-proxy mount (`NOMOS_PROXY_URL`) — the only
chat-related surface in `internal/httpapi/`; the generated REST API was
already chat-free
- `evals/*.yaml` — nomos golden-conversation manifests (their only runner was
`cmd/nomos/eval`; dsh evals live at `packages/oikos/evals` in the harness
workspace)
- Script/doc cleanup: deploy.sh image list, verify-phase6.sh gateway checks,
seed-secrets.sh OpenRouter key source (host env now), README/CONTRIBUTING/
AGENTS.md/operator-facing comments
The following oikos code stays: **Already gone before this pass:** `compose/web/` (SPA extracted to
- `internal/httpapi/` — REST API for entities, executions, knowledge, signals, health dtoro/oikos-web), `desktop/` (Wails wrapper, deleted with the SPA split).
- `internal/mcp/` — the 67+ MCP tools (now serving dsh instead of nomos)
- `internal/policy/` — risk classification engine **Kept (per the stays list):**
- `internal/scheduler/` — health checks, metrics, probes - `internal/nomos/assent/` — chat-assent/typed-confirmation parsing; the
- `internal/secrets/` — Infisical/SOPS integration assent *window* logic lives in `internal/adapters/postgres`
- `internal/nomos/assent/`, `internal/nomos/session/` domain types (may be extracted to shared package) (governance.go/approvals.go) behind the governance port and is shared by
the dsh consent flow
- `internal/httpapi/` REST API, `internal/mcp/` (67+ tools),
`internal/policy/`, `internal/scheduler/`, `internal/secrets/`
- `OIKOS_NOMOS_AGENT_SLUG` config + compose env — resolves the seeded
`agent:nomos` entity the MCP handler attributes activity to (dsh sends no
agent identity of its own)
**Open data item:** `seeds/inventory.yaml` still carries the `agent:nomos`
entity and the `nomos_gateway: 8092` port mapping. Left as-is — the DB is
the source of truth; retire or rename the entity at runtime (set_entity_state
→ retired) when dsh gets its own agent entity.
## 6. Migration path ## 6. Migration path
@@ -249,7 +350,7 @@ The cutover is a rolling deployment:
1. **Deploy dsh alongside nomos** — both agent runtimes run in parallel during development. `compose/dsh/` joins the docker-compose stack. 1. **Deploy dsh alongside nomos** — both agent runtimes run in parallel during development. `compose/dsh/` joins the docker-compose stack.
2. **Port the UI incrementally** — dsh UI and oikos-web coexist on different ports: dsh on `:3080`, oikos-web on `:3000`. The Caddy reverse proxy routes `/chat/*` and `/` to dsh during testing. 2. **Port the UI incrementally** — dsh UI and oikos-web coexist on different ports: dsh on `:3080`, oikos-web on `:3000`. The Caddy reverse proxy routes `/chat/*` and `/` to dsh during testing.
3. **Switch the default route** — once dsh passes all golden evals and the UI covers the main pages, Caddy routes all traffic to dsh. oikos-web becomes available at `/legacy` during the transition. 3. **Switch the default route** — once dsh passes all golden evals and the UI covers the main pages, Caddy routes all traffic to dsh. oikos-web becomes available at `/legacy` during the transition.
4. **Cleanup** — remove `cmd/nomos/`, `compose/web/`, `oikos-web` repo (or archive). 4. **Cleanup**~~remove `cmd/nomos/`, `compose/web/`~~ done (section 5). The `oikos-web` repo stays until Phase 4 Tier 1-2 land in the dsh UI, then archive.
## 7. Risks ## 7. Risks

57
scripts/deploy-plugins.sh Normal file
View File

@@ -0,0 +1,57 @@
#!/bin/sh
# Oikos-plugins deploy script — triggered by Gitea webhook on push to dtoro/oikos-plugins.
# Runs on mac-mini via launchd unit running cmd/webhook (route: /deploy-plugins).
set -e
REPO_DIR="${REPO_DIR:-$HOME/Projects/oikos}"
PLUGIN_DIR="${PLUGIN_DIR:-$HOME/oikos-plugins}"
DSH_DIR="${DSH_DIR:-$HOME/Projects/deepseek-harness}"
PROFILE_DIR="${PROFILE_DIR:-$HOME/.dsh/profiles/web}"
PORT="${PORT:-3080}"
LOCKDIR="${LOCKDIR:-/tmp/oikos-plugins-deploy.lock}"
acquire_lock() {
if mkdir "$LOCKDIR" 2>/dev/null; then
trap 'rm -rf "$LOCKDIR"' EXIT
return 0
fi
echo "deploy already running, skipping"
exit 0
}
acquire_lock
echo "=== oikos-plugins deploy started ==="
# 1. Pull latest
if [ ! -d "$PLUGIN_DIR" ]; then
git clone gitea@git-ssh.hubris.network:dtoro/oikos-plugins.git "$PLUGIN_DIR"
fi
cd "$PLUGIN_DIR"
git fetch origin master
git reset --hard origin/master
# 2. Symlink packages into dsh profile
for pkg in ui mcp-scope session-summary bundle evals; do
name=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$pkg/package.json')).name)")
ln -sf "$PLUGIN_DIR/$pkg" "$PROFILE_DIR/node_modules/$name"
echo "linked $name"
done
# 3. Build UI client bundle (needs dsh workspace for tsdown)
cd "$DSH_DIR"
pnpm install --filter @deepseek-ai/dsh-oikos-ui --frozen-lockfile 2>&1
cd "$PLUGIN_DIR/ui"
DSH_BUILD_FACE=client npx tsdown --config tsdown.config.ts 2>&1
echo "UI bundle built"
# 4. Restart dsh
DASHBOARD_PID=$(pgrep -f 'dsh.*--port.*3080' 2>/dev/null || true)
if [ -n "$DASHBOARD_PID" ]; then
kill "$DASHBOARD_PID" 2>/dev/null || true
sleep 2
fi
cd "$DSH_DIR"
nohup pnpm dsh --profile web --patch /tmp/oikos-mcp-patch.yml --port "$PORT" > /tmp/dsh-web.log 2>&1 &
echo "dsh restarted (pid $!)"
echo "=== oikos-plugins deploy complete ==="

View File

@@ -238,7 +238,7 @@ echo "[6/8] prune old image tags (keep 3)"
if [ -n "$OIKOS_VERSION" ]; then if [ -n "$OIKOS_VERSION" ]; then
images=$(docker compose --profile "$PROFILE" config --images 2>/dev/null || true) images=$(docker compose --profile "$PROFILE" config --images 2>/dev/null || true)
if [ -z "$images" ]; then if [ -z "$images" ]; then
images="oikos-api oikos-scheduler oikos-migrate oikos-seed oikos-nomos" images="oikos-api oikos-scheduler oikos-migrate oikos-seed"
fi fi
printf '%s\n' $images | sed 's/:.*//' | grep '^oikos-' | sort -u | while read -r repo; do printf '%s\n' $images | sed 's/:.*//' | grep '^oikos-' | sort -u | while read -r repo; do
docker image ls "$repo" --format '{{.Tag}}' 2>/dev/null | grep '^v' | sort -rV | tail -n +4 | while read -r tag; do docker image ls "$repo" --format '{{.Tag}}' 2>/dev/null | grep '^v' | sort -rV | tail -n +4 | while read -r tag; do

View File

@@ -50,7 +50,10 @@ seed_key() {
} }
mcp_token="$(get_container_env api OIKOS_MCP_BEARER_TOKEN)" mcp_token="$(get_container_env api OIKOS_MCP_BEARER_TOKEN)"
openrouter_key="$(get_container_env nomos OPENROUTER_API_KEY)" # OPENROUTER_API_KEY used to come from the nomos container's env; nomos is
# decommissioned (dsh is the agent runtime now) and reads the key straight
# from Infisical, so seed from the deploying host's environment.
openrouter_key="${OPENROUTER_API_KEY:-}"
webhook_hmac="$(get_container_env api WEBHOOK_HMAC_SECRET 2>/dev/null)" webhook_hmac="$(get_container_env api WEBHOOK_HMAC_SECRET 2>/dev/null)"
api_token="$mcp_token" api_token="$mcp_token"

View File

@@ -1,6 +1,9 @@
#!/bin/sh #!/bin/sh
# End-to-end verification — Phase 6 acceptance criteria (14 checks). # End-to-end verification — Phase 6 acceptance criteria (13 checks).
# Run after deploy or cutover. Exit 0 if all pass, 1 on first failure. # Run after deploy or cutover. Exit 0 if all pass, 1 on first failure.
# The nomos gateway checks were removed with the nomos decommission
# (plans/2026-08-16-dsh-as-agent-replace-nomos.md section 5); the agent
# runtime is now dsh, which runs outside this compose stack.
set -e set -e
@@ -27,25 +30,16 @@ check "4. Scheduler: check pass" "http://localhost:8090/api/v1/check
check "5. Actuator: executions endpoint" "http://localhost:8090/api/v1/executions" 200 check "5. Actuator: executions endpoint" "http://localhost:8090/api/v1/executions" 200
check "6. Learning: patterns endpoint" "http://localhost:8090/api/v1/patterns" 200 check "6. Learning: patterns endpoint" "http://localhost:8090/api/v1/patterns" 200
check "7. Classifier: risk classes" "http://localhost:8090/api/v1/policy/risk-classes" 200 check "7. Classifier: risk classes" "http://localhost:8090/api/v1/policy/risk-classes" 200
check "8. Nomos: gateway health" "http://localhost:8092/healthz" 200 check "8. Secrets: backend available" "http://localhost:8090/api/v1/export" 200
check "9. Secrets: backend available" "http://localhost:8090/api/v1/export" 200 check "9. Deploy: events endpoint" "http://localhost:8090/api/v1/events" 200
check "10. Deploy: events endpoint" "http://localhost:8090/api/v1/events" 200 check "10. Knowledge: content search" "http://localhost:8090/healthz" 200
check "11. Knowledge: content search" "http://localhost:8090/healthz" 200 check "11. Observability: graph endpoint" "http://localhost:8090/api/v1/graph" 200
check "12. Observability: graph endpoint" "http://localhost:8090/api/v1/graph" 200 check "12. Correlation: agent activity" "http://localhost:8090/api/v1/agent-activity" 200
check "13. Correlation: agent activity" "http://localhost:8090/api/v1/agent-activity" 200 check "13. Cutover: blast radius (authentik)" "http://localhost:8090/healthz" 200
check "14. Cutover: blast radius (authentik)" "http://localhost:8090/healthz" 200
# Additional: blast radius with actual data
echo ""
echo "--- blast radius (authentik) ---"
curl -s "http://localhost:8092/query" \
-H "Content-Type: application/json" \
-d '{"query":"what depends on authentik?"}' \
| jq -r '" entities affected: \(.result | length)"' 2>/dev/null || echo " (skipped)"
echo "" echo ""
if [ "$FAIL" -eq 0 ]; then if [ "$FAIL" -eq 0 ]; then
echo "=== ALL 14 CHECKS PASSED ===" echo "=== ALL 13 CHECKS PASSED ==="
exit 0 exit 0
else else
echo "=== SOME CHECKS FAILED ===" echo "=== SOME CHECKS FAILED ==="