0.29.0 — code-quality refactor (plan E1–E5): file splits, sqlc migration, SSH unification, test coverage
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

E1: split monolithic files — cmd/nomos (main.go → server.go + mcp.go + workers.go),
    internal/mcp/tools.go → entity_tools/ops_tools/knowledge_tools/analysis_tools,
    internal/httpapi/impl.go → domain files (entities, events, signals, ontology,
    fleet_health, client_context, client_lifecycle, entity_mutations, query_audit).
E2: migrate raw pool.Exec queries to sqlc (entities/relationships queries + generated).
E3: unify SSH — consolidate crypto/ssh dial into actuator/client.go (+client_test).
E4/E5: add tests — db/lifecycle, checkdefaults/build, ontology/preconditions, policy/risk.
This commit is contained in:
2026-08-08 22:38:47 +02:00
parent 712b66422b
commit 75c0848a6f
34 changed files with 4544 additions and 3745 deletions

348
cmd/nomos/mcp.go Normal file
View File

@@ -0,0 +1,348 @@
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,11 +1,8 @@
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
@@ -19,7 +16,6 @@ import (
"github.com/dtoro/oikos/internal/safego"
"github.com/dtoro/oikos/internal/secrets"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
@@ -230,147 +226,6 @@ func sseEvent(w http.ResponseWriter, flusher http.Flusher, event agentEvent) {
flusher.Flush()
}
// 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) {})
}()
}
}
func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405)
@@ -852,339 +707,4 @@ func truncate(s string, n int) string {
return s
}
return s[:n] + "..."
}
// ─── 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 // this client is one stateful MCP session; serialize ITS OWN calls
// 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)
}
}
}

152
cmd/nomos/workers.go Normal file
View File

@@ -0,0 +1,152 @@
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) {})
}()
}
}