New plan grounds three concurrency issues found by tracing the actual code
(not assumed): the assent/destructive windows are keyed by agent id only
(no session dimension), so an approved plan in one task can auto-run
unapproved actions in a concurrently-running task; nomos shares one
mutex-guarded MCP client across all sessions, so a single slow `run` call
serializes every other task's tool calls behind it; and chat.ts's SSE
callback has no session guard, so switching tasks mid-stream lets the
backgrounded task's events corrupt whatever's now displayed. Proposes
session-scoping the windows (critical/first), a frontend stream guard
(contained/second), a per-session MCP client pool (throughput/third), and
an optional concurrency cap (deferred pending real usage data).
Also archives the goal-oriented-chat-control-panel plan to done/ — all 7
phases shipped and are live in production (SHA e30813a) — fixing its
internal relative links for the new depth and pointing forward to the new
concurrency plan as follow-up hardening.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
313 lines
16 KiB
Markdown
313 lines
16 KiB
Markdown
# 2026-07-11 — Tasks: the chat page as goal-structured autonomous work
|
|
|
|
**Status:** Done — 2026-07-11. All 7 phases shipped and deployed (SHA
|
|
`e30813a`): task schema + entity anchor, `entity.touched`/`involves` live
|
|
tracking, the `complete_task`/knowledge-retrieval loop, structured
|
|
`propose_plan`/`update_plan_step` (with an append-not-replace fix for
|
|
mid-flight re-proposals), `ask_operator` pause/resume, the Tasks board, and
|
|
the live `TaskContextPanel`. Follow-up hardening tracked separately in
|
|
[concurrent-task-execution](../2026-07-11-concurrent-task-execution.md).
|
|
Supersedes the sidebar-only framing and the Chat portion of
|
|
[control-room-webui](../2026-07-08-control-room-webui.md), which described
|
|
chat as a free-form session list.
|
|
|
|
## The vision (operator, distilled)
|
|
|
|
> Structure the whole chat page as **tasks**. A task is a card — you see its
|
|
> status (running / completed / failed), its description. A task *is* a goal:
|
|
> "install the service", "give me the key status of X". The agent takes the
|
|
> goal, finds what it needs, **proposes a plan, the operator approves it once —
|
|
> that single approval is the only one needed — and the agent then executes the
|
|
> whole plan autonomously until the goal is achieved.** Every task has a
|
|
> completion status: successful or not, and its **learnings move to knowledge**,
|
|
> attached via **relationships** to the entities that were involved, so future
|
|
> tasks — successful or unsuccessful — make the agent better over time. Inside a
|
|
> task is the conversation (tools, thinking, questions if needed); the sidebar
|
|
> shows the live context: which entities the agent is exploring, the steps and
|
|
> their status, whether the task succeeded, and the knowledge it recorded — all
|
|
> populated in **real time** as the agent works.
|
|
|
|
Three pillars: **task as the unit**, **one approval → autonomous execution**,
|
|
**a knowledge loop that compounds**.
|
|
|
|
## The reframe
|
|
|
|
Today a "session" is a title + a flat message list
|
|
([migrations/015](../../migrations/015_agent_sessions.up.sql)); a "plan" is prose
|
|
the model types; there is no goal, status, outcome, or step object. We elevate
|
|
the session into a **task**:
|
|
|
|
- **A task = a session with a goal, a plan, a lifecycle status, and an
|
|
outcome.** One task per chat. The chat page becomes a **task board** of
|
|
status cards; opening a card shows the task: conversation in the center, live
|
|
context in the sidebar.
|
|
- **The plan is approved once.** Machinery already exists — the assent window +
|
|
event-driven auto-continuation shipped in
|
|
[autonomous-plan-execution](2026-07-10-autonomous-plan-execution.md)
|
|
([continue.go](../../cmd/nomos/continue.go), [assent.go](../../cmd/nomos/assent.go))
|
|
already turn a single approval into an autonomy grant the agent runs to
|
|
completion. This plan gives that flow a **structured surface**: the one thing
|
|
the operator approves is a named, stepped plan, and progress is visible.
|
|
- **On completion the task deposits knowledge**, linked by relationships to the
|
|
entities involved *and to the task itself*, tagged success/failure — and
|
|
**future tasks read it back at planning time.** The substrate exists:
|
|
`upsert_knowledge` writes a knowledge doc-entity and a `documents`
|
|
relationship ([server.go:1519](../../internal/mcp/server.go));
|
|
`get_entity_knowledge` reads it ([server.go:170](../../internal/mcp/server.go)).
|
|
We add task-linkage, an outcome flavor, and retrieval-at-planning.
|
|
|
|
## Builds on / aligns with
|
|
|
|
- [general-gated-execution](../2026-07-10-general-gated-execution.md) — the
|
|
classifier + `run` primitive is the execution substrate; a plan step is just
|
|
a described unit of work mapping to a `run`/`request_execution` call. **No
|
|
fixed step enum.**
|
|
- [autonomous-plan-execution](2026-07-10-autonomous-plan-execution.md) —
|
|
the single-approval autonomy window + auto-continuation loop.
|
|
- The knowledge tools + relationships graph (`upsert_knowledge`,
|
|
`get_entity_knowledge`, `get_relations`, the temporal `relationships` table).
|
|
|
|
## Data model (migration `018_tasks.up.sql`)
|
|
|
|
Elevate the session into a task; add plan steps, questions, and the
|
|
task→knowledge linkage.
|
|
|
|
```sql
|
|
ALTER TABLE agent_sessions
|
|
ADD COLUMN goal TEXT NOT NULL DEFAULT '',
|
|
ADD COLUMN status TEXT NOT NULL DEFAULT 'active',
|
|
-- active | planning | awaiting_approval | executing
|
|
-- | awaiting_input | done | failed | abandoned
|
|
ADD COLUMN outcome TEXT, -- success | failure | partial (NULL until done)
|
|
ADD COLUMN summary TEXT NOT NULL DEFAULT '', -- one-line result, shown on the card
|
|
ADD COLUMN entity_id UUID; -- the task's OWN entity (type 'task'), for
|
|
-- knowledge/relationship linkage (see below)
|
|
|
|
CREATE TABLE session_plan_steps (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
|
seq INT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
detail TEXT NOT NULL DEFAULT '',
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
-- pending | running | done | failed | skipped | blocked
|
|
execution_id UUID,
|
|
target_slug TEXT,
|
|
started_at TIMESTAMPTZ,
|
|
finished_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
CREATE INDEX idx_plan_steps_session ON session_plan_steps(session_id, seq);
|
|
|
|
CREATE TABLE session_questions (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
|
prompt TEXT NOT NULL,
|
|
context JSONB NOT NULL DEFAULT '{}', -- { entities:[], options:[], why:"" }
|
|
status TEXT NOT NULL DEFAULT 'open', -- open | answered | dismissed
|
|
answer TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
answered_at TIMESTAMPTZ
|
|
);
|
|
CREATE INDEX idx_questions_session_open
|
|
ON session_questions(session_id) WHERE status = 'open';
|
|
```
|
|
|
|
**The task as an entity.** Each task gets a row in `entities` (type `task`,
|
|
slug `task:<short-id>`), stored in `agent_sessions.entity_id`. This is what
|
|
makes the knowledge loop use the *existing* graph machinery unchanged:
|
|
knowledge and involved-entity links hang off the task entity via
|
|
`relationships`, exactly like any other entity.
|
|
|
|
## Task lifecycle
|
|
|
|
```
|
|
created → planning → awaiting_approval → executing ⇄ awaiting_input → done
|
|
│ (outcome:
|
|
└──────────────→ failed success/
|
|
failure/
|
|
partial)
|
|
```
|
|
|
|
- **planning**: agent calls `get_entity_knowledge` on the target(s) first (prior
|
|
learnings), then `set_goal` + `propose_plan`.
|
|
- **awaiting_approval**: the plan is the single approval gate. Operator approves
|
|
→ opens the assent window (existing) → **executing**.
|
|
- **executing**: steps flip pending→running→done via `update_plan_step` and the
|
|
execution→step auto-close (below); the agent runs autonomously
|
|
(auto-continuation) with no per-step re-approval.
|
|
- **awaiting_input**: only when the agent hits a real decision → `ask_operator`;
|
|
answering resumes execution.
|
|
- **done/failed**: agent sets `outcome` + `summary` and deposits knowledge.
|
|
|
|
## Single approval → autonomous execution
|
|
|
|
Already the behaviour of the assent window + auto-continuation. This plan makes
|
|
the **approved object** a structured plan rather than an individual command:
|
|
approving the plan (one click / one "go ahead") authorizes every
|
|
read-only + config-mutation step in it. Destructive steps still require typed
|
|
confirmation *unless* named in the approved plan (the existing pre-authorized
|
|
destructive-step rule). Nothing new in the execution engine — we're giving it a
|
|
legible unit to approve and to show progress against.
|
|
|
|
## The knowledge loop (capture → link → retrieve)
|
|
|
|
**Capture (task end).** On `done`/`failed`, the agent (nudged by SOUL, enforced
|
|
by a server-side fallback) calls `upsert_knowledge` with the concrete learning —
|
|
what worked, what didn't, the gotcha — and we link the resulting knowledge
|
|
doc-entity to:
|
|
- the **entities involved** (already supported via `about`), and
|
|
- the **task entity** (`agent_sessions.entity_id`), via a new
|
|
`outcome_of` / `produced_by` relationship, tagged
|
|
`{"outcome":"success|failure"}`.
|
|
|
|
**Link.** Involved entities are captured cheaply: every `entity.touched` (below)
|
|
records a `relationships` edge `task —involved→ entity`. So a task's entity
|
|
neighborhood *is* its involved-entity set — queryable with the existing
|
|
`get_relations`.
|
|
|
|
**Retrieve (task start).** At **planning**, before proposing, the agent pulls
|
|
prior knowledge for the target entities (`get_entity_knowledge`) — which now
|
|
surfaces both successful and failed prior tasks (the outcome tag lets it weight
|
|
"last time `apt install docker.io` failed on Debian, used get.docker.com
|
|
instead"). This is the compounding: each task's outcome becomes the next task's
|
|
prior. SOUL makes this the first planning move.
|
|
|
|
## UI
|
|
|
|
### Task board (replaces the raw session rail / empty chat state)
|
|
|
|
[Sessions.svelte](../../web/src/pages/Sessions.svelte) /
|
|
[SessionRail.svelte](../../web/src/lib/components/SessionRail.svelte) become a
|
|
**board of task cards**. Each card:
|
|
- goal as the title, one-line `summary`,
|
|
- a **status pill** (running ◐ / awaiting you / done ✓ / failed ✗) with the
|
|
step progress (`4/6`),
|
|
- outcome color on completion, knowledge-count badge (♦ 2 learned),
|
|
- click → open the task.
|
|
|
|
Grouped/filterable by status (Running, Needs input, Done, Failed). "New task"
|
|
replaces "new chat" — the empty state asks for a goal.
|
|
|
|
### Task detail = conversation + live context sidebar
|
|
|
|
Center column: the existing chat transcript (tools, thinking, questions inline)
|
|
— unchanged rendering ([Chat.svelte](../../web/src/pages/Chat.svelte)).
|
|
|
|
Right sidebar becomes `TaskContextPanel.svelte`, populated **in real time**, top
|
|
to bottom:
|
|
1. **GoalHeader** — goal + status pill + outcome (once done); editable goal.
|
|
2. **PlanProgress** — ordered steps, live status icons, `4/6` bar, click a step
|
|
→ scroll chat to its tool call / open its execution output.
|
|
3. **OperatorQuestion** — pinned structured card when a question is open: prompt,
|
|
`why`, context-entity chips (→ EntitySheet), option buttons or free-text.
|
|
Answering POSTs the answer and resumes the agent. Same card also renders
|
|
inline in the transcript at the point it was raised. (The operator's
|
|
"structured component with relevant context.")
|
|
4. **LiveEntityPanel** — the [SessionGraph](../../web/src/lib/components/SessionGraph.svelte)
|
|
upgraded from passive to live: `entity.touched` → the node **pulses** +
|
|
"now touching `lxc:foo`"; `health.changed` → recolor + transient
|
|
`healthy→degraded` diff badge.
|
|
5. **Outcome & Knowledge** — on completion: success/failure banner, the
|
|
`summary`, and the knowledge notes recorded (links to the knowledge
|
|
entities), i.e. the [SessionDigest](../../web/src/lib/components/SessionDigest.svelte)
|
|
evolved into a task-outcome card.
|
|
|
|
## Real-time event contract (global `/events/stream`)
|
|
|
|
The panel is driven by the **always-on** [events stream](../../web/src/lib/stores/events.ts),
|
|
not the per-turn chat SSE — so it stays live during server-side
|
|
auto-continuation (when no chat turn is open) and survives a tab reload. New
|
|
`type`s, each carrying `correlation_id = session_id`:
|
|
|
|
| type | data |
|
|
| ---- | ---- |
|
|
| `task.status` | `{ status, outcome?, summary? }` |
|
|
| `goal.set` | `{ goal }` |
|
|
| `plan.proposed` | `{ steps:[{seq,title,detail,target_slug}] }` |
|
|
| `plan.step.started` / `plan.step.finished` | `{ step_id, seq, status, execution_id? }` |
|
|
| `question.raised` / `question.answered` | `{ question_id, prompt?, context?, answer? }` |
|
|
| `entity.touched` | `{ slug, tool }` |
|
|
| `knowledge.recorded` | `{ title, about, outcome }` |
|
|
|
|
`entity.touched` is emitted from the `withActivityLogging` wrapper
|
|
([server.go:832](../../internal/mcp/server.go)) — it wraps every tool call, so
|
|
touched-entity tracking needs **zero agent changes**; it also writes the
|
|
`task —involved→ entity` relationship. `health.changed` already exists.
|
|
|
|
## Agent surface (new MCP tools + SOUL)
|
|
|
|
Thin declarations that write the tables/relationships and publish the event
|
|
in-process (event and row commit together):
|
|
- `set_goal(goal)`
|
|
- `propose_plan(steps:[{title,detail?,target_slug?}])`
|
|
- `update_plan_step(seq,status,execution_id?)` — plus the execution's terminal
|
|
status **auto-closes** its linked step where
|
|
[phase3.go](../../internal/httpapi/phase3.go) finalizes executions (belt and
|
|
suspenders).
|
|
- `ask_operator(prompt,options?,context_entities?,why?)` — creates the question,
|
|
status→`awaiting_input`, ends the turn; answer resumes via the existing
|
|
assent/continuation path.
|
|
- `complete_task(outcome,summary)` — sets outcome/summary, status→done/failed;
|
|
server enforces "a completed task must have deposited ≥1 knowledge note"
|
|
(fallback: auto-summarize into one if the model forgot).
|
|
|
|
SOUL: "Every task has a goal. **First**, read prior knowledge for the target
|
|
entities (`get_entity_knowledge`) — learn from past tasks, successful or not.
|
|
Then `set_goal` + `propose_plan`. Execute autonomously after approval, marking
|
|
steps. Ask via `ask_operator` only for real decisions. When the goal is
|
|
verified, `complete_task` with the outcome and record what you learned."
|
|
|
|
## Implementation order
|
|
|
|
1. **Migration `018` + task-entity creation** (a `task` entity per session) +
|
|
store methods. Sessions gain goal/status/outcome/summary; no behaviour change.
|
|
2. **`entity.touched` + `task —involved→ entity`** from `withActivityLogging` —
|
|
cheapest live win; graph starts pulsing, involved-set is captured for free.
|
|
3. **Knowledge loop close**: `complete_task` + retrieval-at-planning in SOUL +
|
|
outcome-tagged `outcome_of` link. Makes tasks compound.
|
|
4. **`set_goal`/`propose_plan`/`update_plan_step`** + execution→step auto-close.
|
|
5. **`ask_operator`** end-to-end (tool → question → pinned card inline+panel →
|
|
answer resumes).
|
|
6. **UI: TaskContextPanel** (GoalHeader, PlanProgress, OperatorQuestion,
|
|
LiveEntityPanel, Outcome/Knowledge) + `workspace.ts` store + REST hydration
|
|
(`GET /sessions/{id}/{plan,questions}`).
|
|
7. **UI: Task board** — session rail/list → status-card board, "new task" flow.
|
|
|
|
Each step ships value: 2 = live entity awareness; 3 = compounding knowledge;
|
|
4-5 = plan progress + interactive questions; 6-7 = the full task surface.
|
|
|
|
## Verification
|
|
|
|
- Run "deploy TypeType as an LXC on strong" as a task. Expect: at planning the
|
|
agent reads prior knowledge for `host:strong`; `propose_plan` renders steps;
|
|
operator approves **once**; steps flip live; the touched node pulses; a
|
|
mid-flow ambiguity surfaces as an `ask_operator` card answered in the panel;
|
|
on success `complete_task` sets outcome=success, deposits a knowledge note
|
|
linked to `lxc:typetype`, `host:strong`, and the task entity.
|
|
- Start a **second** task touching `host:strong`; confirm the first task's
|
|
knowledge surfaces at planning (`get_entity_knowledge`) — the compounding loop.
|
|
- Reload the tab mid-execution → panel rehydrates from REST and keeps updating
|
|
from the global stream (proves it isn't chat-SSE-bound).
|
|
- Board shows the task moving Running → Done with the right outcome color and
|
|
knowledge badge. `SELECT status, outcome FROM agent_sessions` shows a real
|
|
lifecycle, not all `active`.
|
|
- `get_relations` on the task entity returns its involved entities + produced
|
|
knowledge.
|
|
|
|
## Open questions
|
|
|
|
- **One task per session vs sequential tasks in a chat** — v1: one task = one
|
|
session (matches "each chat is a goal"). A new goal starts a new task.
|
|
Multi-task threads are a later extension.
|
|
- **Failure knowledge weighting** — do we just tag `outcome:failure` and let the
|
|
model judge, or add explicit "avoid this" surfacing at planning? Lean tag-only
|
|
first; revisit if the agent repeats known-failed approaches.
|
|
- **`complete_task` enforcement** — hard-require a knowledge note (block
|
|
completion) or soft (auto-generate a stub)? Lean soft, so a trivial "give me
|
|
status" task isn't forced to invent a learning.
|
|
- **Board vs thread** for very short tasks ("key status of X") — a status query
|
|
is a degenerate task (no plan, instant done). Render it as a lightweight card
|
|
that never shows an approval, so the board isn't cluttered with heavyweight
|
|
chrome for one-shot questions.
|