Commit Graph

17 Commits

Author SHA1 Message Date
de126daf43 feat(web): fold Events/Agent/Audit into EntityDetail; tag agent_activity with entity_id
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Events, Agent, and Audit were standalone read-only pages that never
cross-referenced the entity they related to. Fold them into EntityDetail
as entity-scoped cards (Agent activity, Audit trail) alongside the
existing Signals/Executions/Knowledge cards, and give the Signals card
real Ack/Mute/Resolve actions. Signals stays a standalone page since
it's the only one with cross-entity triage value (badge count, actions).

Also fixes the underlying reason those new cards would've stayed empty:
agent_activity rows were never tagged with entity_id at insert time
(cmd/nomos/store.go, internal/mcp/server.go), even though the column
and the API filter both support it. Added a best-effort resolver that
checks common tool-arg keys (target, entity_slug, slug, ...) against
the entities table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 08:38:19 +02:00
3b9c75fa3f fix(agent): task completion safety net — stop tasks sticking at Running
Implements fixes 1-3 of plans/2026-07-11-task-completion-safety-net.md.
Confirmed live that 50/50 production sessions never reached a terminal
status because the model almost never calls complete_task, even for
trivial single-tool Q&A turns SOUL.md explicitly calls out as needing it.

- Inline safety net (agent.go): a session that never called set_goal never
  framed itself as a structured task, so its first plain-text turn-end IS
  the task ending — auto-complete it there instead of leaving status stuck
  at its creation default forever.
- Idle sweep (continue.go, new completion_nudges column): goal-bearing
  sessions that stall get one nudge, then auto-close with outcome=partial
  if the nudge goes unanswered, mirroring the pattern resumeSession already
  uses for a different stuck-session failure mode.

Fix 4 (backfill of the 50 already-stuck live sessions) is deliberately
separate — deferred until this is deployed and verified live, per the
plan's implementation order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 22:11:24 +02:00
c3901641d1 fix(agent): bound conversation history replayed to the LLM (A2)
Fix A2 of plans/2026-07-11-nomos-agent-code-review.md. chatWith replayed a
session's ENTIRE message history into the LLM's context on EVERY turn, no
windowing, no token budget — confirmed against a documented production case
(a single turn with 70 tool calls, messages up to 106KB). Every subsequent
turn of a long-running or heavily-autonomous task re-sent that ever-growing
history in full — a real cost/latency/eventual-context-limit risk for
exactly the tasks this system runs longest (many auto-continuation cycles).

Design call (flagged in the review as needing one before implementation):
a fixed-size window for LLM replay specifically, not the UI's own transcript
view. Simplest option that still keeps roughly the current task's working
context; a token-aware trim or LLM-summarize-on-drop are documented as
stretch options if 30 proves insufficient in practice.

- store.go: new getRecentMessages(ctx, sessionID, limit) — last `limit`
  messages in chronological order, plus whether older ones were omitted.
  getMessages (used by the UI's GET /sessions/{id}) is untouched and stays
  unbounded — the operator should still see a task's full history regardless
  of length; only what gets sent to the model is bounded.
- agent.go: chatWith uses getRecentMessages(sessionID, historyWindowSize=30)
  instead of the unbounded getMessages. When truncated, injects a system
  note telling the model explicitly that older turns exist but aren't shown,
  so it checks upsert_knowledge/search_knowledge rather than assuming
  something wasn't done just because it isn't visible.

New cmd/nomos/store_test.go: real Postgres integration tests (mirroring
internal/db/integration_test.go's throwaway-database pattern, guarded by
OIKOS_TEST_DATABASE_URL). TestGetRecentMessages_Truncation is the direct
proof for this fix (35 messages → 30 returned, correctly ordered,
truncated=true; 5 messages → all 5, truncated=false) — both cases run
against a fully-migrated database, not mocked. Also added
TestProposePlan_AppendVsReplace, closing part of the review's test-coverage
finding (E) by permanently regression-testing the earlier append-vs-replace
plan fix (commit 5384499), which had only been verified manually until now.

Verified live: inflated a real session to 42 persisted messages via direct
SQL, then continued it with a real chat call — the turn proceeded normally
(multiple real tool-call iterations, no crash, no context-length error);
nomos stayed healthy throughout. A3's incremental persistence separately
confirmed to have caught the 7 real tool calls made before the client
connection was cut, cleanly closing out both fixes' interaction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:22:30 +02:00
76f76308cc fix(agent): D1-D3 cleanups — dead code, N+1 query, unvalidated outcome enum
Fixes D1-D3 of plans/2026-07-11-nomos-agent-code-review.md:

- D1: deleted isTaskTool — defined, never called (dispatch already checks
  handleTaskTool's own `handled` return value).
- D2: recordTouched issued one SELECT per entity slug found in a tool call's
  args; batched into one `WHERE slug = ANY($1)` query. Verified live: a turn
  naming three separate entities recorded involves edges for all three via
  the single batched lookup.
- D3: complete_task's outcome had a declared enum (success|failure|partial)
  in its tool schema but nothing validated it — an out-of-enum value (model
  typo or a weaker model not respecting the schema) silently persisted as-is,
  with only "failure" special-cased (anything else became status='done'
  regardless of what the value actually said). Now validated in
  handleTaskTool: empty defaults to "success" (unchanged), a recognized value
  passes through, anything else defaults to "partial" (safer than silently
  treating an unrecognized value as success) with a warning logged. Verified
  live: instructed the agent to call complete_task with outcome="unclear" —
  persisted as outcome='partial', not the literal invalid string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:13:48 +02:00
9ef1ba3702 fix(concurrency): scope assent/destructive windows to session, not just agent
Fix 1 of plans/2026-07-11-concurrent-task-execution.md — the safety-critical
one. The assent window (and destructive window) were keyed purely by agent
id ("assent_window.agent:<uuid>"). With one agent:nomos entity serving every
concurrent task, this meant approving Task A's plan opened a window that ANY
concurrently-running task's config-mutation/destructive actions could also
ride, auto-executing without their own approval.

- store.go / agent.go: assentWindowActive/openAssentWindow and
  destructiveWindowActive/openDestructiveWindow/destructiveWindowKey all gain
  a sessionID parameter; keys become
  "assent_window.agent:<id>.session:<sessionID>" and
  "destructive_window.agent:<id>.target:<slug>.session:<sessionID>". Missing
  session id fails closed (no window) rather than falling back to the old
  agent-wide key.
- continue.go: the auto-continuation worker's window check moved from once-
  per-batch to once-per-pending-item, scoped to that item's own session —
  it was previously checking ONE agent-wide window for a batch that can span
  multiple tasks.
- agent.go tool-dispatch: injects `_session_id` into a COPY of the wire args
  sent to the MCP server (never into the args used for the emitted/logged/
  persisted tool call, and never part of any tool's declared InputSchema —
  invisible to the model) so the gating checks on the OTHER side of the
  process boundary know which task is asking.
- internal/mcp/server.go: assentWindowActive/destructiveWindowActive/
  classifyAndGate gain the same sessionID parameter, read from
  args["_session_id"] at the three call sites (request_execution's
  apt_upgrade/pct_create branches, and the shared classifyAndGate used by
  restart/pct_exec/systemctl/run).

Verified against the live stack with the exact scenario from the plan: opened
an assent window for session A only, then called `run` with an identical
config-mutation command for session A (window open) and session B (same
agent, no window). A auto-ran (execution status completed); B correctly
queued for approval (pending_approval) instead of bleeding through — proven
at both the MCP response text and the executions table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 18:27:29 +02:00
5384499903 fix(tasks): plan panel showed only the latest step, not the full plan
Root cause: proposePlan unconditionally deleted and replaced the whole
session_plan_steps list on every call. The model isn't strictly held to
"call propose_plan once with the full list" — nothing stopped it (and
production evidence + live testing showed it happening) from calling
propose_plan once per step as it worked. Each such call wiped every
already-completed step, so the operator only ever saw the model's latest
single step ("1/1") instead of the real, growing plan.

Fix, two layers:
- store.go: proposePlan now only does a destructive replace when no step
  has left 'pending' yet (a genuine pre-execution revision). Once any step
  has started, a new call APPENDS after the current max seq instead of
  wiping — so the panel accumulates the full history regardless of how the
  model chooses to call the tool. plan.proposed now carries `appended` so
  the frontend knows whether to replace or append.
- workspace.ts: plan.proposed handler respects `appended` (update vs set).
- tasks.go / SOUL.md: strengthened the propose_plan description and task-
  loop guidance to call it ONCE with the complete step list end-to-end,
  using update_plan_step (not re-calling propose_plan) to advance — fixing
  the root behavioral cause, with the store-side append as a safety net
  that holds even if the model still calls it incrementally.

Verified: forced the exact incremental-call pattern (propose_plan with 1
step, mark it running, propose_plan again with 1 more step) — the second
call appended at seq 2 instead of erasing seq 1, and its plan.proposed
event carried appended=true.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:07:10 +02:00
991e7d0900 feat(tasks): phase 6 — live TaskContextPanel (goal, plan, question, entities)
Replaces the chat right rail's ad-hoc Digest+Graph stack with a single
TaskContextPanel that renders the task's live working state, driven by the
always-on events stream (not the per-turn chat SSE) so it keeps updating
during server-side auto-continuation/resume:

- GoalHeader: goal + status pill (planning/executing/awaiting_input/done/
  failed), sourced from the sessions list.
- PlanProgress: ordered steps with live status icons + progress bar, hydrated
  via new GET /sessions/{id}/plan; clicking a step with a target opens its
  EntitySheet (no fake "jump to transcript" — bits-ui Collapsible content
  isn't force-mounted, so a DOM-scroll jump would silently no-op for
  collapsed tool groups).
- OperatorQuestion: the pinned structured question card (prompt/why/entity
  chips/option buttons/free-text), hydrated via new GET /sessions/{id}/
  questions; answering POSTs to the existing answer endpoint.
- SessionGraph upgraded to a live entity panel: entity.touched pulses the
  node (animated ring) and shows "Now touching <slug>"; health.changed shows
  a transient diff badge for touched entities.
- SessionDigest gains a success/failure/partial outcome banner and now also
  refetches when the task's status changes, not just on session switch.

Two bugs found and fixed while wiring this up:
- workspace.ts's status-refresh trigger only covered goal.set/task.status;
  question.raised/answered didn't refresh the sessions list, so GoalHeader's
  pill went stale after answering via the panel (resumeSession runs entirely
  server-side — no client 'done' event to piggyback a refresh on). Now every
  status-affecting event triggers the (debounced) refetch.
- Forgot to rebuild the nomos container after adding the /plan and
  /questions endpoints, so they silently fell through to the old default GET
  handler — caught via a live curl diff against the running container,
  not a code read.

Verified end-to-end against the live stack: goal/plan/question all update
without a reload as the agent works; answering a question via the panel
resumes the agent and the header pill correctly flips to Executing;
entity.touched pulses the live graph.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:52:36 +02:00
014e5c74e0 feat(tasks): phase 5 — ask_operator (structured question, pause, resume)
The last backend piece: when the agent hits a decision only the operator can
make, it surfaces a structured question instead of guessing or stalling.

- ask_operator(prompt, why?, options?, context_entities?): nomos-local tool
  that records a session_questions row, moves the task to awaiting_input, emits
  question.raised, and ENDS the turn (the agent loop returns after it, so the
  agent can't barrel past its own question). The prompt becomes the assistant's
  visible message so the question also shows inline in the transcript.
- Two resume paths, both close the question + emit question.answered + return
  the task to executing:
  - Panel: POST /sessions/{id}/questions/{qid}/answer → resumes the agent in the
    background with the answer injected (reusing the continuation machinery,
    refactored continueSession → resumeSession). Returns 202; the reply lands via
    message polling.
  - Chat reply: the next chat message on a task with an open question IS the
    answer — auto-closed in handleChat; the turn itself is the resume.

Verified end-to-end: forcing a decision paused the task at awaiting_input with
the structured question (prompt/why/options/entities); a panel answer resumed
the agent (it acknowledged host:strong and continued); a plain chat reply
auto-closed a second question. Cleanup + tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:00:15 +02:00
be3ce761d4 feat(tasks): phase 4 — structured plan steps (set_goal/propose_plan/update_plan_step)
Gives a task a legible, live-advancing plan via three more nomos-local tools:

- set_goal(goal): records the task goal, status → planning, emits goal.set.
- propose_plan(steps[]): persists ordered steps (clean replace for v1 — a
  revision starts a new list), status → executing, emits plan.proposed with
  the persisted steps (id+seq) so the panel can address them.
- update_plan_step(seq, status, execution_id?): advances a step, stamping
  started_at/finished_at, emits plan.step.started/finished. Anchors the event
  to the step's target entity when it has one.

Belt-and-suspenders: when an execution linked to a step reaches a terminal
state, the api auto-closes the step (closePlanStepForExecution in
emitExecutionEvent) and emits plan.step.finished — so the board stays honest
even if the agent forgets to close a step it started.

Verified end-to-end: a goal-driven task fired goal.set → plan.proposed →
2× step.started/finished → task.status on the SSE stream; both steps persisted
done with start/finish timestamps; status progressed planning→executing→done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:50:27 +02:00
532310bb4b feat(tasks): phase 3 — close the knowledge loop (complete_task + retrieval)
Adds the compounding knowledge loop the task model is built around:

- complete_task(outcome, summary): a nomos-LOCAL, session-scoped tool (the
  shared MCP server has no session id). Introduces the local-tool mechanism —
  buildTools appends task tools, the agent loop routes them to handleTaskTool
  instead of the MCP client. Sets the task's terminal status/outcome/summary,
  mirrors it onto the task entity, and emits task.status.
- Knowledge → task linkage: after a successful upsert_knowledge in a task,
  nomos links the note to the task entity (documents) and emits
  knowledge.recorded, so the task's outcome view shows what it learned. The
  note's about-link to the involved entity (written by upsert_knowledge) is the
  retrieval path future tasks use.
- SOUL: every chat is a task loop — retrieve prior knowledge FIRST
  (get_entity_knowledge on the target), plan, execute, record learnings, then
  complete_task. Scales down for trivial read-only tasks.
- deleteSession now cleans up the task entity, its relationships, and its
  task-scoped events (was orphaning them); the knowledge doc itself and its
  about-links survive, as knowledge should outlive the task.

Verified end-to-end: a task recorded a note and completed; task.status +
knowledge.recorded hit the SSE stream; status=done/outcome=success persisted;
the note linked to both lxc:caddy (retrieval) and the task; a future
get_entity_knowledge(lxc:caddy) surfaces it; delete cleaned edges+events (0/0/0)
while the knowledge survived.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:43:29 +02:00
3dba2e550a feat(tasks): phase 2 — entity.touched events + task→entity involves edges
As the agent runs a task, record which entities each tool call references:
write an idempotent task —involves→ entity relationship and publish one
entity.touched event per entity (correlation_id = session, data {slug,tool}).
Extracted from tool ARGS only — never results — so a bulk fleet query can't
drag every entity into the task graph; bulk/no-slug tools stay silent.

Emitted from the nomos agent loop rather than the shared MCP wrapper, which
has no session id. The involves edges make a task's graph neighborhood its
involved-entity set (queryable via get_relations) — the substrate for the
knowledge loop; the events are the live pulse the context panel consumes in
phase 6.

Verified end-to-end on the local stack: a chat referencing lxc:caddy/lxc:gitea
produced entity.touched on the browser SSE stream with slug+tool+correlation,
and exactly one involves edge per entity despite repeated touches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:35:27 +02:00
72e9fe534e feat(tasks): phase 1 — elevate chat session to a task (schema + task entity)
Migration 018 adds goal/status/outcome/summary/entity_id to agent_sessions
and creates session_plan_steps + session_questions. Registers a 'task'
entity type and an 'involves' (task→entity) relationship in the ontology
so each session anchors its knowledge and involved-entity edges on the
existing relationships graph.

nomos createSession now mints a task:<session-id> entity (type task) and
links it via agent_sessions.entity_id — best-effort so chat never blocks on
it. listSessions/GET /sessions surface the new task fields.

No behaviour change yet; this is the data foundation for the task board and
live context panel. Verified end-to-end against the local stack: migration
applied, ontology ingested (60 types/47 rels), a new session mints a linked
task entity and the API returns status/goal/entity_id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:25:28 +02:00
233b5e4519 feat: live visibility into what the agent is running (no more silent waiting)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Operator: "I'd like to be able to see in the chat what the agent is actually
running, right now I just wait while nothing happens." Two compounding gaps:

1. The auto-continuation worker (cmd/nomos/continue.go) had zero live push —
   its result only appeared on a manual page reload, so approving a plan and
   watching the chat looked completely dead even while the agent was actively
   working.
2. Even with polling, continueSession only persisted ONE message at the very
   end of a continuation — a continuation that runs several tool calls before
   concluding would still show total silence for however long that took.

Fixed both:
- web/src/lib/stores/chat.ts: polls the current session's messages every 3s
  between turns (never while a live stream owns the message list) and merges
  in anything new. Started after a live turn ends and when a session loads;
  stopped on new-chat/session-switch.
- cmd/nomos/store.go: insertMessageReturningID/updateMessage — lets a message
  be created as a placeholder and updated in place.
- cmd/nomos/continue.go: continueSession now inserts a placeholder the
  instant it starts (renders as the existing "thinking" dots — immediate
  feedback that something is happening) and updates that SAME row after
  EVERY tool call, not just at the end. A poll within ~3s of any tool call
  landing shows it — individual `run` commands appear as the agent issues
  them, not just the final rolled-up summary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:20:10 +02:00
2e922f6421 feat: decompose pct_create into atomic create + agent-driven install; add scoped destructive window
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Closes the two remaining open points from the auto-continuation work.

1. Atomic pct_create (observability, the bigger of the two):
   pct_create used to bundle create + apt install + post_install script into
   one black-box multi-minute SSH call — the agent got back a single opaque
   success/fail with no way to see (or fix) which step actually broke.
   Removed the whole post-create provisioning block (and the now-dead
   provisionScript/sanitizePkgs helpers + their tests). pct_create is now
   create + start + register ONLY — fast, and its result is fed back to the
   agent via auto-continuation almost immediately. The agent installs
   packages and runs setup as its OWN sequence of `run` calls against the new
   lxc:<hostname>, observing each command's real output and able to diagnose
   and retry exactly the step that failed — the same recovery loop already
   proven for the general case, now applied to installs too, instead of
   requiring a separate black-box mechanism.
   - services/post_install removed from the pct_create params struct and
     from the MCP tool schema/SOUL.md docs.
   - SOUL.md: explains the new flow, moves the Docker CLI gotcha and DNS
     troubleshooting guidance to be steps the agent runs itself.

2. Scoped destructive window (targeted autonomy for recovery):
   Verified live in the previous session that a destructive recovery (a
   failed destroy needing stop-then-destroy on the same container) required
   TWO separate typed confirmations for what was clearly one recovery
   action. Added a narrow, TARGET-scoped 15-minute grant
   (destructive_window.agent:<id>.target:<slug> in autonomy_settings,
   shared key format across cmd/nomos and internal/mcp) that opens only
   after an EXPLICIT typed confirmation (never loose assent) or an explicit
   button-approval of a destructive step, and only ever covers further
   destructive commands against that SAME target. A different target always
   needs its own fresh confirmation — this narrows risk instead of loosening
   it globally, unlike broadening the general assent window to cover
   destructive actions would have.
   - cmd/nomos/store.go: openDestructiveWindow/destructiveWindowActive/
     executionTarget.
   - cmd/nomos/agent.go: opens the window when a typed confirmation grants a
     destructive chat-assent execution.
   - internal/mcp/server.go: `run` tool checks the window before gating a
     destructive command; auto-runs if active.
   - internal/httpapi/phase3.go: DecideApproval opens the same window when a
     destructive execution is approved via the button/API, for parity with
     the chat-assent path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 14:45:09 +02:00
d2f749d33d feat: event-driven auto-continuation — agent runs an approved plan to completion
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
The root cause behind "the agent stops at the first error and doesn't recover":
provisioning executions run ASYNCHRONOUSLY (pct_create fires the SSH work in a
goroutine and returns "running" immediately), so the agent's turn ENDS before
the result exists. The agent literally isn't running when the step fails — it
can't react to a failure it never observes. The only thing that fed results
back was the operator typing "continue" after every async step: the human was
the event loop. (In the flagged 18-message session the operator typed
continue/proceed/?? eight times while the agent correctly diagnosed each failure
but couldn't advance a step on its own.)

This makes the system the event loop instead:

- migrations/017: nomos_plan_executions links each gated execution to the chat
  session that started it.
- cmd/nomos: after a tool result, any "execution <uuid>" it started is linked
  to the session. A background worker (continue.go) polls for those executions
  reaching a terminal state and — while the agent has an open assent window (an
  approved plan is in flight) — re-invokes the agent with the result
  ("execution X completed/failed: <result>"), so it proceeds to the next step
  or diagnoses+fixes the failure, with no operator tick. Guarded against loops
  (mark-continued before running) and bounded by the 30-min window.
- chatWith(): chat() variant that injects the finished-execution note after
  replayed history without persisting a fake user turn.
- DecideApproval: approving a step by ANY route (button or chat-assent) now
  opens the assent window, so auto-continuation works regardless of how the
  operator approved — previously only typing "go ahead" opened it.
- SOUL: the agent is told it will be auto-re-invoked when async steps finish —
  don't poll get_execution_status, don't wait for "continue"; end the turn and
  keep going step by step until the goal is verified or a genuine blocker.

This is the root fix, not another per-command patch: you can't enumerate every
failure of an unbounded action space, but you can give the agent a loop that
observes each result and adapts — because "do anything" always includes "the
first attempt failed."

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 14:19:43 +02:00
49c37fe8b1 fix: chat session reliability, cost, and hygiene (empty-response guard, tool truncation, delete, titles)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
- Empty/refusal responses retried once, then surfaced as errors instead of silent blanks
- Chinese refusal boilerplate detected via denylist + non-ASCII heuristic
- Bulk-tool preference added to SOUL.md (list_lxcs over per-entity get_lxc_state)
- Tool results truncated to 4KB on persist; get_state_snapshot filters null-state entities
- Session delete (DELETE /sessions/{id} + confirm-on-second-click UI)
- Session titles auto-generated from assistant answer instead of raw user message
2026-07-09 10:18:06 +02:00
e8e230b4a5 nomos+web: streaming, provider routing, event gap-fill, embedded UI; fix approval FK & session context
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Agent (cmd/nomos):
- Stream LLM tokens via NewStreaming; emit text_delta then final text.
- OpenRouter provider routing: data_collection=deny (ZDR) + require_parameters;
  NOMOS_PROVIDER_SORT opt-in; Exacto via model suffix.
- Multi-turn: reload session history into context; UI passes session id.
- Fix agent_activity logging (agent_id/session_id) and mcpClient data race.

Events (live control-room feed):
- approval.created (mcp), approval.decided (api), execution.completed/failed
  (approved-action path), signal.raised/resolved + health.changed (scheduler,
  transition-gated).

Fixes:
- createApproval FK violation (reuse execution entity) — the agent's only
  write path; log the previously-swallowed errors.

Web UI:
- Embed web/dist via //go:embed (single binary); Dockerfile builds SPA into
  the Go stage; committed .gitkeep placeholder keeps backend-only builds green.
- Caddy: Authentik-gated /agent/* -> nomos so the UI reaches the agent
  same-origin in production.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 15:22:27 +02:00