docs: nomos agent code review — gaps and improvement plan
Full read-through of cmd/nomos/ (agent.go, store.go, main.go, continue.go,
assent.go, tasks.go). Findings, ranked:
- A1 (confirmed via runnable probe): isAssent/isTypedConfirmation use
unpadded substring matching for assent/confirm words while negation uses
word-boundary checks — "yes" matches inside "yesterday", "confirm" matches
inside "confirmed" with no negation word covering contracted negatives
("haven't"). isTypedConfirmation gates DESTRUCTIVE actions specifically.
- A2: chatWith replays a session's ENTIRE message history every turn, no
windowing/token budget — confirmed unbounded against a documented
production case (70 tool calls, 106KB messages).
- A3: a live turn's tool-call history is lost entirely if the client
disconnects mid-stream (single end-of-turn save using the same
connection-tied, possibly-cancelled context) — resumeSession already has
the fix pattern (incremental placeholder+update), handleChat doesn't use it.
- B1: zero recover() anywhere in cmd/nomos/internal/mcp/internal/httpapi —
every explicitly-spawned goroutine (continuation worker, resumeSession,
executeApprovedViaAPI, sse listeners) crashes the whole process on panic.
- B2: auto-continuation processes its batch sequentially, one full LLM turn
at a time, undercutting this session's own concurrency work on exactly the
path autonomous tasks depend on most.
- B3: no terminal state for a permanently-failed auto-continuation.
- C1: nomos's own gateway (port 8092, directly published + mesh-reachable)
has ZERO authentication on any endpoint — chat, session read/delete,
chat-assent approval of gated executions, all open to anyone on the LAN.
- D1-D3: dead code (isTaskTool unused), N+1 query in recordTouched, no
validation on complete_task's outcome enum.
- E: zero automated tests for agent.go/store.go/main.go/tasks.go — including
today's new safety-critical logic (session-scoped windows, mcpClientPool,
proposePlan's append-vs-replace), verified only by live manual testing.
- F1: tool list + fleet snapshot re-fetched every turn (minor).
Prioritized implementation order in the doc: A1 → C1 → B1 → B2 → A3 → D1-3 →
A2 → B3/F1, tests landing alongside each fix rather than as a deferred pass.
Also archives the now-fully-shipped concurrent-task-execution plan to done/
(all 3 required fixes deployed this session; fix 4 explicitly deferred per
its own recommendation).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
323
plans/2026-07-11-nomos-agent-code-review.md
Normal file
323
plans/2026-07-11-nomos-agent-code-review.md
Normal file
@@ -0,0 +1,323 @@
|
||||
# 2026-07-11 — Nomos agent code review: gaps and improvement plan
|
||||
|
||||
**Status:** Planned
|
||||
|
||||
## Scope
|
||||
|
||||
A full read-through of `cmd/nomos/` (agent.go, store.go, main.go, continue.go,
|
||||
assent.go, tasks.go — 3,120 lines) plus targeted checks of its HTTP exposure,
|
||||
goroutine safety, and test coverage. Every finding below is grounded in a
|
||||
specific file:line or a runnable reproduction — two of the sharper ones
|
||||
(A1, A2) were empirically confirmed with throwaway test probes before being
|
||||
written up, not just read and assumed.
|
||||
|
||||
This is a review, not an implementation — findings are ranked by severity with
|
||||
a proposed fix per item; nothing here has been changed yet.
|
||||
|
||||
---
|
||||
|
||||
## A. Correctness bugs (confirmed, not theoretical)
|
||||
|
||||
### A1. Chat-assent word matching has real substring false positives
|
||||
|
||||
[assent.go:73-103](../cmd/nomos/assent.go). `isAssent`/`isTypedConfirmation`
|
||||
pad the message with spaces and word-boundary-check the **negation** list
|
||||
(`strings.Contains(m, " "+w+" ")`), but the **assent**/**confirm** checks use
|
||||
bare `strings.Contains(m, w)` — no word boundary at all. Confirmed live via a
|
||||
test probe:
|
||||
|
||||
- `isAssent("not sure, maybe yesterday's logs show something useful")` →
|
||||
**`true`** (`"yes"` matches inside `"yesterday"`; `"not"` alone isn't in
|
||||
`negationWords`, only the phrase `"not yet"` is).
|
||||
- `isTypedConfirmation("I haven't confirmed anything yet, let me think")` →
|
||||
**`true`** (`"confirm"` matches inside `"confirmed"`; `"haven't"` isn't in
|
||||
`negationWords`, which only has `"don't"`/`"do not"`, not other contracted
|
||||
negatives).
|
||||
|
||||
The second one is the serious half: `isTypedConfirmation` is the **sole gate
|
||||
for DESTRUCTIVE actions** ([agent.go:220-223](../cmd/nomos/agent.go)) — a
|
||||
message that merely *mentions* not having confirmed something yet can read as
|
||||
an explicit confirmation.
|
||||
|
||||
**Fix:** apply the same space-padded word-boundary check to the assent/confirm
|
||||
word lists that negation already uses. Expand `negationWords` to cover
|
||||
contracted negatives (`haven't`, `hasn't`, `isn't`, `wasn't`, `can't`,
|
||||
`won't`, `not` as a standalone word, not just `"not yet"`). Add both
|
||||
reproduced cases as permanent regression tests in `assent_test.go`.
|
||||
|
||||
### A2. Unbounded conversation history replay — no windowing, no token budget
|
||||
|
||||
[agent.go:185-207](../cmd/nomos/agent.go): every single turn (`chatWith`)
|
||||
calls `a.store.getMessages(ctx, sessionID)` — [store.go:218-239](../cmd/nomos/store.go),
|
||||
`SELECT ... WHERE session_id=$1 ORDER BY created_at ASC` with **no `LIMIT`,
|
||||
no windowing, no summarization** — and replays the *entire* history into the
|
||||
LLM call every time. `truncateToolResults` ([store.go:152-185](../cmd/nomos/store.go))
|
||||
caps each individual tool **result** at 4KB, but caps nothing else: not tool
|
||||
**args**, not the number of tool calls in one message, not the total message
|
||||
count, not total tokens.
|
||||
|
||||
This isn't theoretical — an earlier production audit (see
|
||||
[chat-sessions-improvements](done/2026-07-09-chat-sessions-improvements.md))
|
||||
found a single turn with **70 tool calls** and messages up to **106KB**. Every
|
||||
subsequent turn of a long-running or heavily-autonomous task (exactly what
|
||||
auto-continuation is built for) re-sends that ever-growing history in full.
|
||||
This is a real cost, latency, and eventual context-length-limit risk that
|
||||
compounds specifically for the tasks the system is designed to run longest.
|
||||
|
||||
**Fix:** at minimum, cap replayed history to the most recent N messages or a
|
||||
token budget, with older turns either dropped or collapsed into a short
|
||||
system-message summary (`finalSummary`'s existing one-shot summarization
|
||||
pattern, [agent.go:481-492](../cmd/nomos/agent.go), could be reused for this).
|
||||
Needs a decision on where the cutoff lives (see open questions).
|
||||
|
||||
### A3. A live turn's tool-call history is lost entirely if the client disconnects mid-stream
|
||||
|
||||
[main.go handleChat](../cmd/nomos/main.go): `toolCalls`/`finalText` accumulate
|
||||
only in local closure variables; `st.saveMessage(...)` runs exactly **once**,
|
||||
after `a.chat(...)` returns, using `ctx := r.Context()` — the *same* context
|
||||
that cancels the instant the client disconnects (Stop button, tab close,
|
||||
network blip). If `a.chat` returns early because that context was cancelled,
|
||||
the final `saveMessage` call runs with an already-cancelled context and its
|
||||
error return is never checked — the whole turn's tool-call history (already
|
||||
real: executions launched, knowledge possibly written) is silently lost from
|
||||
the persisted transcript.
|
||||
|
||||
Contrast with `resumeSession`/`continueSession` ([continue.go:96-166](../cmd/nomos/continue.go)),
|
||||
which insert a placeholder row immediately and update it after every single
|
||||
tool call — exactly the incremental-persistence pattern `handleChat` lacks.
|
||||
Verified live this session: my own Stop-button test showed the turn's actual
|
||||
tool calls (6 of them) *were* visible in the UI only because the SSE stream
|
||||
had already pushed them to the browser's in-memory store before the abort —
|
||||
none of that would have survived a page reload, since nothing was persisted.
|
||||
|
||||
**Fix:** bring `handleChat` in line with `resumeSession`'s pattern — insert a
|
||||
placeholder row before the turn starts, update it after each tool call using
|
||||
a context *not* tied to the client connection for the write itself (or at
|
||||
minimum, persist with `context.Background()` in a deferred cleanup so a
|
||||
cancelled request context doesn't take the DB write down with it).
|
||||
|
||||
---
|
||||
|
||||
## B. Robustness
|
||||
|
||||
### B1. Zero panic recovery on any background goroutine
|
||||
|
||||
Every explicitly-spawned goroutine across the agent surface has no
|
||||
`recover()`:
|
||||
|
||||
```
|
||||
cmd/nomos/main.go:78 go nAgent.runContinuationWorker(ctx)
|
||||
cmd/nomos/main.go:80 go func() { ...sweep ticker... }()
|
||||
cmd/nomos/main.go:117 go func() { ...http server... }()
|
||||
cmd/nomos/main.go:347 go a.resumeSession(context.Background(), sessionID, note)
|
||||
internal/mcp/server.go:477,495 go executeApprovedViaAPI(...)
|
||||
internal/mcp/server.go:1134 go func() { ... }()
|
||||
internal/httpapi/phase3.go:119,1456
|
||||
internal/httpapi/server.go:81,533
|
||||
```
|
||||
|
||||
`grep -rn "recover()" cmd/nomos/ internal/mcp/ internal/httpapi/` returns
|
||||
nothing. Go's default behavior for a panic in *any* goroutine — not just the
|
||||
one handling an HTTP request, which the stdlib does recover — is to crash the
|
||||
**entire process**. `runContinuationWorker` and `resumeSession` in particular
|
||||
run complex, unattended agent logic (JSON unmarshaling of model output, tool
|
||||
result parsing, map/slice indexing) with no operator watching; a single edge
|
||||
case (a malformed tool result, an unexpected nil) takes down nomos for
|
||||
**every concurrently-running task**, not just the one that hit it. This is
|
||||
more consequential post-concurrency (today's work): more simultaneous
|
||||
unattended goroutines running agent code means more surface area for one bad
|
||||
input to end everyone's session.
|
||||
|
||||
**Fix:** wrap every explicitly-spawned goroutine body in a `defer func() {
|
||||
if r := recover(); r != nil { slog.Error(...) } }()`. A small helper
|
||||
(`safeGo(func())`) would make this consistent and hard to forget at new call
|
||||
sites.
|
||||
|
||||
### B2. Auto-continuation processes its batch sequentially, one full turn at a time
|
||||
|
||||
[continue.go:58-75](../cmd/nomos/continue.go): `processContinuations` fetches
|
||||
up to 5 pending items and runs `a.continueSession(ctx, p)` for each **in a
|
||||
plain `for` loop**, in the single `runContinuationWorker` goroutine. Each
|
||||
`continueSession` is a full LLM turn that can run for minutes (10-minute
|
||||
timeout, [continue.go:134](../cmd/nomos/continue.go)). If 3 different tasks'
|
||||
executions finish in the same 4-second tick, task #3's continuation waits for
|
||||
#1 and #2 to *completely finish* first — undercutting today's whole
|
||||
concurrency effort specifically on the auto-continuation path, which is the
|
||||
mechanism autonomous multi-step tasks depend on most.
|
||||
|
||||
**Fix:** spawn each pending continuation as its own goroutine (with B1's
|
||||
panic recovery), bounded by a small semaphore if unbounded parallelism here
|
||||
is a concern.
|
||||
|
||||
### B3. No terminal state for a permanently-failed auto-continuation
|
||||
|
||||
[continue.go:162-165](../cmd/nomos/continue.go): if the resumed LLM call
|
||||
errors on both the initial attempt and its one retry, the code logs an error
|
||||
and returns — the task is left in whatever status it was in (typically
|
||||
`executing`), with no outcome set and no operator-visible signal beyond an
|
||||
inert message buried in the transcript. There's no give-up-after-N-retries or
|
||||
dead-letter marking; the task just looks silently stuck.
|
||||
|
||||
**Fix:** on final failure, call the same path `complete_task` would use to set
|
||||
`outcome='failure'` with a summary explaining the resume failed, so the task
|
||||
board reflects reality instead of showing a task that looks perpetually
|
||||
"executing."
|
||||
|
||||
---
|
||||
|
||||
## C. Security
|
||||
|
||||
### C1. Nomos's own HTTP gateway has zero authentication
|
||||
|
||||
[docker-compose.yml:133](../docker-compose.yml) publishes port 8092 directly
|
||||
(`"8092:8092"`, comment: *"mesh-published"*) and
|
||||
[Caddyfile.oikos](../compose/caddy/Caddyfile.oikos:19,34) reverse-proxies to
|
||||
it from two routes. `grep -n "Authorization\|Bearer\|auth" cmd/nomos/main.go`
|
||||
returns **nothing** — `/chat`, `/sessions`, `/sessions/{id}` (including
|
||||
`DELETE`), and `/query` have no credential check of any kind. Anyone who can
|
||||
reach the LAN or mesh network can converse with Nomos directly: start tasks,
|
||||
read/delete any session, answer pending questions, and — via chat-assent —
|
||||
approve gated executions by typing "yes" or "I confirm" to whatever the agent
|
||||
proposes, with no authentication at all. This is the same class of gap
|
||||
[oikos-gaps-and-improvements](2026-07-08-oikos-gaps-and-improvements.md)
|
||||
flagged for the `api`/MCP surface (items B1-B5), but specifically for nomos's
|
||||
*own* port, which doesn't sit behind `combinedAuth` the way `api`'s routes do.
|
||||
|
||||
**Fix:** put nomos's gateway behind the same auth the `api` process uses
|
||||
(shared bearer token check at minimum), or stop publishing 8092 directly and
|
||||
route all traffic through the already-authenticated `api` proxy exclusively.
|
||||
|
||||
---
|
||||
|
||||
## D. Code quality
|
||||
|
||||
### D1. Dead code: `isTaskTool` is defined, never called
|
||||
|
||||
[tasks.go:139-146](../cmd/nomos/tasks.go). The actual dispatch in
|
||||
[agent.go:370](../cmd/nomos/agent.go) calls `a.handleTaskTool(...)` directly
|
||||
and checks its `handled` return value — `isTaskTool` is unused.
|
||||
**Fix:** delete it, or use it in `buildTools`/dispatch if a cheaper
|
||||
pre-check is actually wanted.
|
||||
|
||||
### D2. N+1 query in `recordTouched`
|
||||
|
||||
[store.go:720-742](../cmd/nomos/store.go): loops over every slug found in a
|
||||
tool call's args and issues a separate `SELECT id, type FROM entities WHERE
|
||||
slug = $1` per slug. Fine for the common case (1-3 slugs) but doesn't batch
|
||||
for tool calls naming many entities.
|
||||
**Fix:** one `SELECT id, slug, type FROM entities WHERE slug = ANY($1)` for
|
||||
all collected slugs, then loop over the results in memory.
|
||||
|
||||
### D3. `complete_task`'s outcome isn't validated
|
||||
|
||||
[tasks.go:248-257](../cmd/nomos/tasks.go) declares an `enum` in the tool
|
||||
schema (`success|failure|partial`) but [store.go:428-457](../cmd/nomos/store.go)
|
||||
never checks it — an out-of-enum value (a model typo, or a weaker model not
|
||||
respecting the schema) silently persists as-is; only `"failure"` is
|
||||
special-cased (else `status="done"`), so a stray value still "completes" the
|
||||
task but with a value the frontend's status/outcome rendering doesn't
|
||||
recognize.
|
||||
**Fix:** validate against the three allowed values in `handleTaskTool` before
|
||||
calling `store.completeTask`, defaulting unrecognized values to `"partial"`
|
||||
(safer than silently treating them as `"success"`).
|
||||
|
||||
---
|
||||
|
||||
## E. Test coverage
|
||||
|
||||
**Zero automated tests exist for `agent.go`, `store.go`, `main.go`, or
|
||||
`tasks.go`.** Only `assent.go`'s and `continue.go`'s pure string-parsing
|
||||
helpers have unit tests (`assent_test.go`, `continue_test.go`) — confirmed by
|
||||
`grep -l "func Test" cmd/nomos/*.go` matching only those two files. This means
|
||||
today's session added substantial new, safety-critical logic — session-scoped
|
||||
assent/destructive windows, the `mcpClientPool`'s creation-race handling and
|
||||
eviction sweep, `proposePlan`'s replace-vs-append branching — verified only by
|
||||
live manual testing (curl + browser), with **no regression protection**
|
||||
against a future change silently reintroducing the cross-task assent bleed or
|
||||
breaking the pool's session isolation.
|
||||
|
||||
**Fix (highest-value additions first):**
|
||||
1. `store_test.go`: `proposePlan`'s append-vs-replace branch (the exact bug
|
||||
fixed earlier today) — needs a real DB (integration-style, matching
|
||||
`internal/db/integration_test.go`'s pattern) or a query-mocking layer.
|
||||
2. `main_test.go`: `mcpClientPool.get()`'s concurrent-creation race path (two
|
||||
goroutines racing to create a client for the same new session id) and
|
||||
`sweep()`'s eviction logic — these are pure in-memory logic, no DB needed,
|
||||
straightforward to unit test.
|
||||
3. `assent_test.go`: the two confirmed false-positive cases from A1.
|
||||
|
||||
---
|
||||
|
||||
## F. Efficiency (minor)
|
||||
|
||||
### F1. Tool list + fleet snapshot re-fetched every single turn
|
||||
|
||||
[agent.go:174,181](../cmd/nomos/agent.go): `buildTools` (`tools/list` MCP
|
||||
round-trip) and `fleetSnapshot` (`get_health_summary` call) both run at the
|
||||
start of **every** `chatWith` call — including auto-continuation resumes,
|
||||
which can fire many times per task. The tool list changes only on an `api`
|
||||
process restart; the fleet snapshot is a live "as of now" read, which is
|
||||
arguably the point of it, but re-fetching the *tool list* every turn is
|
||||
avoidable.
|
||||
**Fix:** cache `buildTools`' result (e.g., in `mcpClientPool`, invalidated on
|
||||
a client's re-initialize) — worth doing only if profiling shows it matters;
|
||||
low priority relative to A-C.
|
||||
|
||||
---
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. **A1** (assent false positives) — smallest, highest-severity-per-line-of-
|
||||
code fix; ships with regression tests same-PR.
|
||||
2. **C1** (unauthenticated gateway) — security-critical, independent of
|
||||
everything else here.
|
||||
3. **B1** (panic recovery) — cheap, broad safety net; do before B2 touches the
|
||||
continuation worker's goroutine structure anyway.
|
||||
4. **B2** (parallel auto-continuation) — natural follow-on to B1 since it's
|
||||
restructuring the same goroutine.
|
||||
5. **A3** (incremental persistence for live turns) — moderate effort, real
|
||||
user-visible correctness gain.
|
||||
6. **D1-D3** (small cleanups) — bundle together, low risk.
|
||||
7. **A2** (history windowing) — needs a design decision (see below) before
|
||||
implementation; largest single change.
|
||||
8. **B3**, **F1** — lower urgency, do opportunistically.
|
||||
9. **E** (tests) — ideally lands alongside each fix above (A1's tests with
|
||||
A1, etc.) rather than as one giant deferred test-writing pass.
|
||||
|
||||
## Verification
|
||||
|
||||
- **A1**: the two probe cases (`isAssent` on the "yesterday" message,
|
||||
`isTypedConfirmation` on the "haven't confirmed" message) become permanent
|
||||
tests in `assent_test.go`, asserting `false` post-fix.
|
||||
- **A2**: after adding windowing, replay a session with 70+ tool calls (the
|
||||
documented production case) and confirm the message payload sent to the LLM
|
||||
stays under a fixed token/byte ceiling regardless of session length.
|
||||
- **A3**: reproduce the Stop-button-mid-turn scenario, reload the page, and
|
||||
confirm the tool calls made before the abort are still present in the
|
||||
persisted transcript (currently: they vanish).
|
||||
- **B1**: inject a deliberate panic in a test build of `resumeSession` (or a
|
||||
fault-injection flag), confirm the process survives and logs the recovered
|
||||
panic instead of exiting.
|
||||
- **C1**: confirm an unauthenticated `curl` to nomos's `/chat` from off-mesh
|
||||
is rejected once auth lands (currently: succeeds).
|
||||
- **D1-D3**: `go vet`/build clean, `complete_task` with a bogus outcome value
|
||||
now rejected or defaulted rather than silently persisted.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **A2's cutoff mechanism**: a fixed N-message window, a token-budget-aware
|
||||
trim, or LLM-summarization of dropped history? Summarization preserves the
|
||||
most context but costs an extra LLM call per trim; a fixed window is
|
||||
simplest but could drop something the agent still needs mid-task. Leaning
|
||||
fixed window + summarize-on-trim as a middle ground, but this needs a
|
||||
decision before implementation, not during.
|
||||
- **C1's auth mechanism**: reuse `api`'s existing static bearer token
|
||||
(simplest, matches an existing pattern) or route everything through `api`'s
|
||||
proxy and stop publishing 8092 at all (removes the surface entirely, but
|
||||
changes the deploy topology)? Leaning the latter if nothing else on the LAN
|
||||
legitimately needs to reach nomos directly — worth confirming with the
|
||||
operator before picking.
|
||||
- **B2's concurrency bound**: unbounded goroutines-per-tick vs. a small
|
||||
semaphore? Given the continuation batch is already capped at 5 per tick
|
||||
(`pendingContinuations(ctx, 5)`), unbounded is probably fine, but worth a
|
||||
sanity check against real task-completion clustering patterns.
|
||||
@@ -1,6 +1,11 @@
|
||||
# 2026-07-11 — Concurrent task execution: safety + throughput + frontend correctness
|
||||
|
||||
**Status:** Planned
|
||||
**Status:** Done — 2026-07-11. All three required fixes shipped and deployed:
|
||||
session-scoped assent/destructive windows (commit `9ef1ba3`), the frontend
|
||||
stream-corruption guard + per-session controllers (`9131559`, `6a8fb43`), and
|
||||
the per-session MCP client pool (`a4ea542`). Fix 4 (concurrency/cost cap)
|
||||
remains explicitly deferred pending real usage data, per this doc's own
|
||||
recommendation.
|
||||
|
||||
## Goal
|
||||
|
||||
@@ -15,11 +20,11 @@ broken today, in decreasing severity: a **cross-task authorization bleed**, a
|
||||
|
||||
### 1. CRITICAL — the assent window is scoped to the agent, not the task
|
||||
|
||||
[store.go:816](../cmd/nomos/store.go), [agent.go:129-143](../cmd/nomos/agent.go):
|
||||
[store.go:816](../../cmd/nomos/store.go), [agent.go:129-143](../../cmd/nomos/agent.go):
|
||||
`openAssentWindow`/`assentWindowActive` key on `"assent_window.agent:" +
|
||||
a.agentID.String()` — there is exactly one `agent:nomos` entity, so this key
|
||||
is **global across every task**. It's checked at three MCP call sites
|
||||
([internal/mcp/server.go:454](../internal/mcp/server.go), :488, :1363) purely
|
||||
([internal/mcp/server.go:454](../../internal/mcp/server.go), :488, :1363) purely
|
||||
as "does *the agent* currently have an open window," with no way to know
|
||||
which task's tool call is asking.
|
||||
|
||||
@@ -29,7 +34,7 @@ opens → operator starts Task B while that window is still open → Task B's
|
||||
because the check has no session dimension. The operator never approved
|
||||
Task B's plan.
|
||||
|
||||
[store.go:313-345](../cmd/nomos/store.go) `destructiveWindowKey`/
|
||||
[store.go:313-345](../../cmd/nomos/store.go) `destructiveWindowKey`/
|
||||
`openDestructiveWindow`/`destructiveWindowActive` have the same shape (keyed
|
||||
`agent:<id>.target:<slug>`, no session) — narrower blast radius (needs a
|
||||
second task hitting the *same target* within 15 minutes of an explicit typed
|
||||
@@ -37,9 +42,9 @@ confirmation elsewhere) but the same class of bug.
|
||||
|
||||
### 2. Tool calls across ALL tasks funnel through one mutex — concurrency is mostly illusory
|
||||
|
||||
[main.go:45](../cmd/nomos/main.go): nomos creates exactly **one**
|
||||
[main.go:45](../../cmd/nomos/main.go): nomos creates exactly **one**
|
||||
`*mcpClient` at startup, shared by every `handleChat` goroutine. Its `mu
|
||||
sync.Mutex` ([main.go:419](../cmd/nomos/main.go)) is held for the full
|
||||
sync.Mutex` ([main.go:419](../../cmd/nomos/main.go)) is held for the full
|
||||
duration of each `doRequest` round-trip. `run`'s MCP handler executes the SSH
|
||||
command *synchronously inside that round-trip* and is capped at up to **10
|
||||
minutes**. So while Task A is mid-`run`, every other task's tool calls —
|
||||
@@ -50,17 +55,17 @@ one slow task stalls all others' progress.
|
||||
The MCP *server* side has no session-scoped in-memory state to protect —
|
||||
`newServer(pool, agentID)` returns one shared `*mcp.Server` instance whose
|
||||
tool handlers close only over `pool` (safe for concurrent use — pgxpool is a
|
||||
connection pool) and `agentID` ([internal/mcp/server.go:51-74](../internal/mcp/server.go)).
|
||||
connection pool) and `agentID` ([internal/mcp/server.go:51-74](../../internal/mcp/server.go)).
|
||||
The mutex exists purely because nomos's *client* reuses one stateful
|
||||
transport session, not because the server needs it. This is fixable without
|
||||
touching the server.
|
||||
|
||||
### 3. Frontend: the chat store is a global singleton — switching tasks mid-stream corrupts the view
|
||||
|
||||
[chat.ts:160-269](../web/src/lib/stores/chat.ts) `sendMessage`'s SSE callback
|
||||
[chat.ts:160-269](../../web/src/lib/stores/chat.ts) `sendMessage`'s SSE callback
|
||||
mutates `messages`/`currentSession` by reaching for `ms[ms.length - 1]` —
|
||||
i.e. it assumes the array it's mutating still belongs to the task it was
|
||||
opened for. Nothing in the callback checks that. [chat.ts:111-117](../web/src/lib/stores/chat.ts)
|
||||
opened for. Nothing in the callback checks that. [chat.ts:111-117](../../web/src/lib/stores/chat.ts)
|
||||
`loadSessionMessages` (fired when you click a different task in the sidebar
|
||||
or the board) does not cancel or otherwise account for a still-open stream
|
||||
from the task you're leaving — it just calls `messages.set(...)` and
|
||||
@@ -75,7 +80,7 @@ underneath the operator. This is a real bug independent of anything else in
|
||||
this plan — it's why "switch away from a running task to start another"
|
||||
currently looks broken even though the backend handles it fine.
|
||||
|
||||
(By contrast, [workspace.ts](../web/src/lib/stores/workspace.ts)'s live
|
||||
(By contrast, [workspace.ts](../../web/src/lib/stores/workspace.ts)'s live
|
||||
events are already correctly session-scoped — `applyEvent` checks
|
||||
`ev.correlation_id !== sid` before doing anything — because that mechanism
|
||||
was built for this from phase 6. The bug is confined to the older,
|
||||
@@ -85,12 +90,12 @@ per-turn `chat.ts` streaming path.)
|
||||
|
||||
- **DB access**: `pgxpool.Pool` is a connection pool; concurrent queries from
|
||||
multiple task goroutines are its normal use case.
|
||||
- **Auto-continuation worker** ([continue.go](../cmd/nomos/continue.go)):
|
||||
- **Auto-continuation worker** ([continue.go](../../cmd/nomos/continue.go)):
|
||||
already scoped per session (`pendingContinuation.SessionID`) — processes
|
||||
its poll batch sequentially (5/tick) but never mixes state across
|
||||
sessions. Sequential processing is a throughput nit, not a correctness bug;
|
||||
not in scope here.
|
||||
- **Task board** ([Tasks.svelte](../web/src/pages/Tasks.svelte)): event-driven
|
||||
- **Task board** ([Tasks.svelte](../../web/src/pages/Tasks.svelte)): event-driven
|
||||
refresh already handles any number of concurrently-changing tasks correctly
|
||||
— it re-lists, it doesn't hold per-task live state.
|
||||
|
||||
@@ -99,7 +104,7 @@ per-turn `chat.ts` streaming path.)
|
||||
### Fix 1 — session-scope the assent and destructive windows
|
||||
|
||||
Thread `sessionID` through to the MCP call sites. nomos already knows the
|
||||
session id when it calls a tool ([agent.go:363](../cmd/nomos/agent.go)); the
|
||||
session id when it calls a tool ([agent.go:363](../../cmd/nomos/agent.go)); the
|
||||
MCP wire protocol doesn't restrict tool-call args to the declared schema
|
||||
(`argsMap` just unmarshals whatever JSON object arrives), so nomos can inject
|
||||
an internal `_session_id` into the args it sends over the wire — invisible to
|
||||
@@ -117,7 +122,7 @@ LLM sees or is asked to supply) but readable server-side.
|
||||
`apt_upgrade`/`pct_create` sub-cases) reads `_session_id` from `argsMap`
|
||||
and passes it through.
|
||||
- `agent.go` `openAssentWindow` gains the same `sessionID` param, called from
|
||||
its two existing call sites ([agent.go:253](../cmd/nomos/agent.go), :269),
|
||||
its two existing call sites ([agent.go:253](../../cmd/nomos/agent.go), :269),
|
||||
which are already inside `chatWith` and have `sessionID` in scope.
|
||||
- Fallback: if `_session_id` is missing (defensive — shouldn't happen since
|
||||
nomos always sets it), treat as "no window" (fail closed, require
|
||||
@@ -143,7 +148,7 @@ one at a time) and never blocks Task B.
|
||||
dedicated client, not pooled per-request, to avoid a connection-per-message
|
||||
churn for the no-DB-store path.
|
||||
- `agent` holds the pool instead of one `client`; `handleQuery` (the
|
||||
structured `/query` endpoint, [main.go:330](../cmd/nomos/main.go)) picks a
|
||||
structured `/query` endpoint, [main.go:330](../../cmd/nomos/main.go)) picks a
|
||||
short-lived or dedicated client the same way.
|
||||
- No server-side change needed (per finding 2's analysis — the server has no
|
||||
per-connection state to protect).
|
||||
@@ -13,7 +13,7 @@ went sideways, open an investigation.
|
||||
| 2026-07-08 | [Control room web UI](2026-07-08-control-room-webui.md) | In Progress |
|
||||
| 2026-07-08 | [Liveness, drift, and UX cohesion](2026-07-08-liveness-drift-and-ux-cohesion.md) | In Progress — Phase 5 deferred |
|
||||
| 2026-07-10 | [General gated execution: unlimited actions, gated by risk](2026-07-10-general-gated-execution.md) | In Progress — enum retirement + auto-act revival still open |
|
||||
| 2026-07-11 | [Concurrent task execution: safety + throughput + frontend correctness](2026-07-11-concurrent-task-execution.md) | Planned |
|
||||
| 2026-07-11 | [Nomos agent code review: gaps and improvement plan](2026-07-11-nomos-agent-code-review.md) | Planned |
|
||||
|
||||
## Done
|
||||
|
||||
@@ -40,6 +40,7 @@ See [`done/`](done/) for executed plans:
|
||||
| 2026-07-09 | [Session execution, UX, and learning improvements](done/2026-07-09-session-execution-and-ux-fixes.md) |
|
||||
| 2026-07-10 | [Autonomous plan execution: close the observation gap](done/2026-07-10-autonomous-plan-execution.md) |
|
||||
| 2026-07-11 | [Tasks: the chat page as goal-structured autonomous work](done/2026-07-11-goal-oriented-chat-control-panel.md) |
|
||||
| 2026-07-11 | [Concurrent task execution: safety + throughput + frontend correctness](done/2026-07-11-concurrent-task-execution.md) |
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
Reference in New Issue
Block a user