From f63254d0939521db108e2f82dbdbb34c809e9eda Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Wed, 10 Jun 2026 00:20:57 +0000 Subject: [PATCH 1/6] refactor(agent): delete the legacy claude -p harness Pi is the sole agent harness. Removes the executor/queue/output legacy path, DEUCE_AGENT_HARNESS selection, the agent_status/typing_indicator/ agent_output WS events only it emitted, and the claude_session_id queries. Also commits the single-deuce-agent plan doc. --- ...09-001-refactor-single-deuce-agent-plan.md | 252 +++++++++++++++++ server/internal/agent/executor.go | 167 ----------- server/internal/agent/output.go | 110 -------- server/internal/agent/queue.go | 168 ------------ server/internal/config/config.go | 11 +- server/internal/db/agents.sql.go | 40 --- server/internal/db/queries/agents.sql | 9 - server/internal/handler/handler.go | 11 +- server/internal/handler/messages.go | 259 +----------------- .../handler/session_visibility_test.go | 2 +- server/internal/handler/sessions_test.go | 2 +- server/internal/handler/teams_test.go | 2 +- server/internal/server/server.go | 52 ++-- server/internal/ws/events.go | 13 +- 14 files changed, 301 insertions(+), 797 deletions(-) create mode 100644 docs/plans/2026-06-09-001-refactor-single-deuce-agent-plan.md delete mode 100644 server/internal/agent/executor.go delete mode 100644 server/internal/agent/output.go delete mode 100644 server/internal/agent/queue.go diff --git a/docs/plans/2026-06-09-001-refactor-single-deuce-agent-plan.md b/docs/plans/2026-06-09-001-refactor-single-deuce-agent-plan.md new file mode 100644 index 0000000..e3e96e1 --- /dev/null +++ b/docs/plans/2026-06-09-001-refactor-single-deuce-agent-plan.md @@ -0,0 +1,252 @@ +--- +title: "refactor: Collapse multi-agent model into a single @deuce agent" +type: refactor +status: active +date: 2026-06-09 +--- + +# refactor: Collapse multi-agent model into a single @deuce agent + +## Summary + +Remove every multi-agent feature — agent roles, role colors, per-session rosters, agent CRUD, multi-agent mention routing, per-(session, agent) Pi processes — and replace them with one built-in agent named **deuce**, implicitly present in every session. Destructive migration; no data preservation required. The legacy `claude -p` harness is deleted in the same pass since porting it to a single-agent shape would be wasted work. + +--- + +## Problem Frame + +Deuce today models five role agents (Coder, Reviewer, Planner, Tester, Designer) with per-session rosters, role colors, per-agent system prompts, and @mention routing by agent UUID. Under the Pi harness this is largely a UI fiction: an agent's `role` and `system_prompt` are fetched but never forwarded to Pi, which runs as one generic coding agent regardless of which row triggered it (validated in `docs/solutions/architecture-patterns/pi-loads-agent-skills-standard-in-rpc-mode.md`). The multi-agent model adds schema (`agents`, `session_agents`), runtime keying (`pirun.Key{SessionID, AgentID}`), WS payload fields, and UI surfaces that all maintain a distinction the execution layer ignores. Future specialization will ride skills/subagents on a single agent (see `docs/ideation/2026-06-08-single-deuce-agent-ideation.md`); this refactor clears the ground. Skills and subagents themselves are explicitly out of scope here. + +--- + +## Requirements + +**Agent model & data** + +- R1. Exactly one agent, named `deuce`, exists. It is implicitly part of every session — no roster, no add/remove, no per-session agent state. +- R2. The `agents` table holds a single seeded row (fixed well-known UUID, `name`, `system_prompt` only); `session_agents` is dropped entirely. +- R3. Tasks are session-scoped: `tasks.agent_id` is gone, task queries key on `session_id` alone. +- R4. The legacy `claude -p` harness is fully removed: executor/queue/output code, `DEUCE_AGENT_HARNESS` config, `agent_status` / `typing_indicator` / `agent_output` WS events, and their frontend consumers. + +**Backend behavior** + +- R5. Deuce replies only when `@deuce` is mentioned. Mention detection moves server-side (word-boundary, case-insensitive); the client `mentions` request field and the `messages.mentions` column are removed. A message with no mention triggers nothing (unchanged behavior). +- R6. A running or queued task can be stopped from the UI (running task card and thread-drawer header) and via an exact `/stop` chat message, both routed to the Pi runtime's session cancel. The fuzzy `" stop"` suffix trigger is removed. +- R7. Deuce's system prompt is editable via `GET/PUT /api/agent`. On save, idle Pi processes are recycled so the next task launches with the new prompt; the editor states that sessions mid-task pick it up on their next process launch. +- R8. The thread-drawer steer path gates on workspace status with the same friendly system messages as chat mentions (no more instant "Agent process could not start." cards while a workspace is starting). +- R9. System notices (nil-UUID author sentinel) remain visible in chat; deuce's task replies remain hidden from the main chat list and surface on task cards / the thread drawer, exactly as today. + +**Frontend** + +- R10. All multi-agent UI is removed: agent picker in session creation, agent CRUD dialog, role color tokens, per-agent thread keying. A single frontend `DEUCE` constant carries id/name/color. +- R11. Deuce's status indicator (summary panel, drawer header) derives from task state in the `agentRuns` reducer (running/awaiting_input → working), not from deleted `agent_status` events. + +**Migration & operability** + +- R12. The migration is explicitly ordered so task history survives the agent-row deletion (FK/column drops before row deletes), queued tasks are cancelled (stale persona prompts must not auto-run under deuce later), and agent-authored messages are repointed to deuce's UUID with the nil-UUID system sentinel excluded. +- R13. The server boots clean post-migration: stuck-task recovery no longer references `session_agents` and does not panic. +- R14. `go build ./... && go test ./...` (server), `npx tsc --noEmit`, and `npm run lint` pass; CLAUDE.md and README reflect the single-agent model. + +--- + +## Key Technical Decisions + +- **Keep one seeded `agents` row rather than removing the table.** The row anchors message authorship (`author_type='agent'`, `author_id`), gives `system_prompt` a durable home for the settings editor, and minimizes churn in the message pipeline. Columns shrink to `id`, `name`, `system_prompt` — `role`, `color`, `color_muted`, `provider`, `model`, `description`, `deleted_at` go away (provider/model are already owned by `DEUCE_PI_PROVIDER`/`DEUCE_PI_MODEL` on the Pi path; color/name render from the frontend constant). +- **Fixed well-known UUID shared as a constant in Go and TS.** The visibility filter, message repointing, and authorship all pin to it. Implementer picks the value (a fresh deuce-specific constant is cleaner than reusing a role seed ID); it must not be the nil UUID, which stays reserved as the system-notice sentinel. +- **Delete the legacy claude harness now, don't port it.** Project memory pins Pi as the sole harness going forward; adapting the legacy executor to single-agent keying would be investment in dead code. Deleting it first removes roughly half the per-agent plumbing before the schema work starts. +- **Server-side mention parsing, dropping the `mentions` plumbing end to end.** With the name hardcoded, the server can parse `@deuce` from content directly (left-boundary regex so `clint@deuce.dev` doesn't trigger). This kills the client/server drift class (stale tabs, spoofable UUID arrays, near-miss silent no-ops on names) and deletes the request field, the `messages.mentions` column, and the ChatView parse code. Chat-side `@deuce` highlighting renders from text matching. +- **Mention-triggered, not always-listening.** Deuce still replies only to `@deuce`. "Agent responds to every message" is a separate product bet (flagged in the ideation doc's rejection list) and stays out. +- **`pirun` keying collapses to session ID.** One persistent Pi process per session; the per-key serial queue becomes a per-session queue. `pi_session_id` is dropped entirely rather than moved to `sessions` — it is dead code today (always launched with empty session path; resume-across-restart was deferred), and carrying a dead column through the collapse buys nothing. Re-add on `sessions` when resume is actually implemented. +- **Stop affordance relocates to the task surfaces.** The only current Stop button lives on the legacy typing indicator, which never renders on the Pi path and is being deleted. Stop moves to the running task card and the drawer header, calling the rewired cancel endpoint (still `requireSessionMember`-gated). The `" stop"` suffix match in chat is narrowed to exact `/stop` — "make the flicker stop" must not cancel a run. +- **Prompt edits recycle idle processes.** Pi applies the system prompt only at process launch and processes are reused across tasks, so a bare PUT would appear to do nothing for an unbounded window. On save: stop Pi processes for sessions with no running task; sessions mid-task pick the change up after their process is next reaped. The editor UI states this. +- **Stale browser tabs hard-reload at deploy.** The session JSON loses `agents`, WS payloads lose `agentId`, and old bundles will throw. Accepted for this pre-1.0 dogfood deployment; the steer handler tolerates (ignores) an `agentId` field from old tabs rather than rejecting the frame, and session-create tolerates an ignored `agentIds` body field. + +--- + +## High-Level Technical Design + +Target shape after the collapse: + +```mermaid +flowchart TB + subgraph Browser + CV["ChatView
send '@deuce ...' (plain content)"] + TD["Thread drawer
steer / answer / Stop"] + ST["Settings
edit deuce system prompt"] + end + subgraph Server + MH["messages handler
server-side @deuce parse
exact /stop match"] + AH["agent handler
GET/PUT /api/agent"] + WS["ws hub
task/action events (no agentId)"] + RT["agent runtime
keyed by sessionID
per-session serial queue"] + SUP["pirun supervisor
one pi --mode rpc per session"] + end + DB[("Postgres
agents: 1 row (id, name, system_prompt)
tasks: session-scoped")] + PI["Pi process in session DevPod
base prompt + deuce prompt at launch"] + + CV -->|"POST /messages"| MH + TD -->|"ws steer"| MH + ST --> AH + MH --> RT + AH -->|"update prompt,
recycle idle processes"| RT + RT --> SUP --> PI + RT --> DB + AH --> DB + RT -->|events| WS --> Browser +``` + +Migration ordering (the FK cascade is the trap — `tasks.agent_id REFERENCES agents(id) ON DELETE CASCADE` means deleting agent rows first silently destroys all task history): + +```mermaid +flowchart TB + A["1. Drop FK + column tasks.agent_id
(task history now survives row deletes)"] + B["2. Cancel all queued tasks
(stale persona prompts must not run under deuce)"] + C["3. Repoint agent-authored messages to DEUCE_UUID
WHERE author_id IN (SELECT id FROM agents)
(excludes nil-UUID system notices)"] + D["4. Drop session_agents
(takes claude_session_id and pi_session_id with it)"] + E["5. Reshape agents: drop role/color/provider/model/
description/deleted_at; delete all rows;
seed single deuce row at DEUCE_UUID"] + F["6. Drop messages.mentions"] + A --> B --> C --> D --> E --> F +``` + +The goose Down restores the prior schema and the five role seed rows so a Down→Up cycle returns to a known dev state (precedent: `server/internal/db/migrations/007_drop_user_forge_id.sql`). + +--- + +## Implementation Units + +U2–U4 are one interdependent backend change-set: U2's query removals break compilation until U3/U4 land. Land them as a single PR (separate commits are fine; the tree need only be green at the U4 boundary). Likewise U5–U6 for the frontend. + +### U1. Delete the legacy claude harness + +- **Goal:** Remove the `claude -p` executor path and everything only it used, shrinking the multi-agent surface before the schema work. +- **Requirements:** R4 +- **Dependencies:** none +- **Files:** `server/internal/agent/executor.go`, `server/internal/agent/queue.go`, `server/internal/agent/output.go` (delete); `server/internal/handler/messages.go` (`executeAgent`, `finishAgent`, `buildChatHistory`, legacy branch of mention loop, legacy `StopAgent` body); `server/internal/handler/handler.go` (executor/agentQueue fields); `server/internal/server/server.go` (legacy wiring, `ResetStaleAgentStatuses` boot call, harness branch); `server/internal/config/config.go` (`AgentHarness`); `server/internal/ws/events.go` (`agent_status`, `typing_indicator`, `agent_output` events); `server/internal/db/queries/agents.sql` (`UpdateClaudeSessionID`, `GetClaudeSessionID`, `ResetStaleAgentStatuses`); affected tests. +- **Approach:** Pure deletion. The Pi path becomes the only branch — `runtime` is constructed unconditionally. Leave `session_agents.claude_session_id` untouched — it is only read/written by the legacy executor code deleted in this unit, so the column becomes intentionally dead after U1 and U2's `session_agents` table drop cleans it up (no standalone column migration needed). Keep `postAgentReply`, `postSystemMessage`, the question backstop, and all Pi-path WS events untouched. Do not touch `PiSubagentsPackage`/`InstallPi` provisioning in `server/internal/workspace/manager.go` — `pi-subagents` is Pi-internal tooling, unrelated to Deuce's agents table. +- **Test scenarios:** + - Happy path: `go build ./...` and `go test ./...` pass with the legacy files gone; a Pi-path mention still enqueues (existing runtime tests still green). + - Edge: server boots with no `DEUCE_AGENT_HARNESS` env var set and with a stale value set (config no longer reads it; document removal in CLAUDE.md at U7). + - Removal completeness: `grep` finds no remaining references to the three deleted WS event types or `claude_session_id` in `server/` Go code. +- **Verification:** Server builds, boots against the current (pre-migration) schema, and a `@Coder` mention still runs a Pi task — proving the legacy deletion is independent of the schema collapse. + +### U2. Migration 013 and sqlc query rewrite + +- **Goal:** Collapse the schema to a single seeded deuce row with session-scoped tasks, following the ordered steps in the migration diagram. +- **Requirements:** R2, R3, R12 +- **Dependencies:** U1 +- **Files:** `server/internal/db/migrations/013_single_deuce_agent.sql` (new); `server/internal/db/queries/agents.sql` (reduce to `GetDeuceAgent` / `UpdateDeuceSystemPrompt` against the single row); `server/internal/db/queries/sessions.sql` (remove `ListSessionAgents`, `AddSessionAgent`, `RemoveAllSessionAgents`, `UpdateSessionAgentStatus`); `server/internal/db/queries/tasks.sql` (drop `agent_id` params, delete `ListAgentTasks` — the existing `ListSessionTasks` already provides the session-scoped replacement — and delete `GetPiSessionID` / `UpdatePiSessionID` / `ClearStuckPiSessions`); `server/internal/db/queries/messages.sql` (drop mentions usage); `server/internal/db/queries/activities.sql` (drop `agent_id` param or leave nullable-unused — implementer's call, lean drop); regenerated `server/internal/db/*.go` via `make generate`; `server/internal/db/migrations/002_seed_data.sql` untouched (history), new seed lives in 013. +- **Approach:** Follow the six-step ordering in the HTD diagram exactly — the `ON DELETE CASCADE` from `tasks.agent_id` is the data-destroying trap, and the message repoint must use the `author_id IN (SELECT id FROM agents)` guard so nil-UUID system notices stay untouched. Cancel `queued` tasks in the migration (stuck-recovery at boot only fails `running`/`awaiting_input`). Down restores schema plus the five role seed rows per the 007 precedent. +- **Execution note:** Write the migration first and exercise `make migrate` / `make migrate-down` against a dev DB seeded with multi-agent data before touching queries. +- **Test scenarios:** + - Happy path: on a dev DB with seeded sessions/tasks/messages, `make migrate` succeeds; task rows survive; agent-authored messages now carry DEUCE_UUID; the single deuce row exists. + - Edge (sentinel): a system notice with nil-UUID author is NOT repointed and remains nil. + - Edge (queued task): a task in `queued` state pre-migration is `cancelled` after. + - Down/Up cycle: `make migrate-down && make migrate` returns to the post-013 state without error; Down alone restores the five role rows. + - Fresh DB: `make migrate` from empty applies 001→013 cleanly. +- **Verification:** All migration scenarios above pass on a real Postgres; `make generate` produces compiling Go (full compile restores at U4). + +### U3. Runtime collapse to per-session keying + +- **Goal:** One Pi process and one serial task queue per session; the deuce system prompt actually reaches Pi. +- **Requirements:** R1, R7 (runtime half), R13 +- **Dependencies:** U2 +- **Files:** `server/internal/agent/pirun/supervisor.go` (`Key{SessionID, AgentID}` → session ID); `server/internal/agent/runtime.go` (maps, `EnqueueParams` drops `AgentID`, `DefaultBaseSystemPrompt` wording no longer says "other agents", prompt fetch via the single-row query); `server/internal/agent/store.go` + `dbstore.go` (interface drops agentID params); `server/internal/handler/agent_run.go` (`RecoverStuckTasks` simplifies — no `ClearStuckPiSessions`, just fail stuck `running`/`awaiting_input` tasks; must not panic post-migration); `server/internal/agent/runtime_test.go`, `system_prompt_test.go`. +- **Approach:** Mechanical de-keying — the per-key mutex, `running`/`workspace`/`consumers` maps, and promote logic all re-key on session ID. Launch-time prompt remains `joinSystemPrompts(base, deucePrompt)` with base from `DEUCE_AGENT_SYSTEM_PROMPT` (unchanged) and deucePrompt from the single row. Add a runtime method to stop idle sessions' processes (no running task), for U4's prompt-edit handler to call. +- **Test scenarios:** + - Happy path: two enqueues in one session run serially on one process; enqueues in two sessions run on two processes. + - Edge: cancel-session kills the running task and clears the queue for that session only. + - Edge: recycle-idle stops the process for an idle session and leaves a session with a running task alone; the idle session's next task triggers a fresh launch (fresh prompt). + - Error path: boot recovery against a DB with a stuck `running` task marks it failed and does not panic. + - Integration: launched process receives base + deuce prompt joined (assert on the launcher's received system prompt, mirroring existing `system_prompt_test.go`). +- **Verification:** Runtime test suite green; manual: two sessions mention `@deuce` concurrently and get independent serial queues. + +### U4. Handlers, routes, and WS contract + +- **Goal:** Server-side mention parsing, single-agent settings endpoint, relocated stop, gated steer, and agentId-free WS payloads. +- **Requirements:** R5, R6, R7 (API half), R8, R9 (server half) +- **Dependencies:** U2, U3 +- **Files:** `server/internal/handler/agents.go` (replace CRUD with `GetAgentSettings` / `UpdateAgentSettings`); `server/internal/server/server.go` (routes: `GET/PUT /api/agent`; delete `POST/PUT/DELETE /agents*`, `PUT /sessions/{id}/agents`; keep stop route gated `requireSessionMember`, rewired to runtime cancel); `server/internal/handler/messages.go` (server-side `(^|\W)@deuce\b` case-insensitive parse on content, drop `mentions` request field — the `messages.mentions` column drop happens in U2's migration step 6 — exact `/stop` match only); `server/internal/handler/sessions.go` (drop `agentResult`/`Agents` from session response, drop the create-time roster loop, tolerate-and-ignore `agentIds` in the request body); `server/internal/handler/websocket.go` (`handleSteer` ignores incoming `agentId`, adds the workspace-status three-way gate mirroring `SendMessage`); `server/internal/ws/events.go` (drop `AgentID` from task/action payloads and `ClientMessage`); `server/internal/handler/agent_run.go` (snapshot drops `agentId`); handler tests. +- **Approach:** On prompt PUT, call U3's recycle-idle-processes hook. Re-run the route-authorization audit from `docs/solutions/architecture-patterns/broadening-resource-visibility-requires-per-route-authorization-audit.md` over the final route table — the trap is a replacement route shipping ungated because no test fails. `PUT /api/agent` is authenticated-user gated (no finer role model exists); a change-audit trail is deferred. +- **Test scenarios:** + - Mention parse: `@deuce do x` triggers; `@Deuce` (case) triggers; `clint@deuce.dev` does NOT; `@deucebot` does NOT; `hey @deuce!` triggers; message with no mention persists but enqueues nothing. + - Stop: exact `/stop` cancels and posts the cancelled notice; `@deuce make the flicker stop` enqueues a task and cancels nothing; `POST .../stop` from a non-member returns 403 (gate before existence lookup); from a member, cancels. + - Settings: `GET /api/agent` returns name + prompt; `PUT` round-trips and recycles idle processes (assert hook called); unauthenticated request rejected. + - Steer gate: steer into a `starting` workspace posts the friendly system message instead of a failed card; steer into a `ready` workspace routes/enqueues as today; steer frame carrying a legacy `agentId` field is accepted and the field ignored. + - Compat: session create with an `agentIds` array in the body succeeds and ignores it; session response JSON contains no `agents` key. + - Visibility invariant: deuce's reply persists with `author_type='agent'`, `author_id=DEUCE_UUID`; system notices persist with nil UUID (existing `session_visibility_test.go` updated, not deleted). +- **Verification:** `go build ./... && go test ./...` green — the U2–U4 change-set compiles as a whole; route audit table updated in the PR description. + +### U5. Frontend state layer + +- **Goal:** Types, API client, store, and reducer reflect the single-agent contract. +- **Requirements:** R9 (client half), R10 (state half), R11 +- **Dependencies:** U4 (contract shape) +- **Files:** `src/types/index.ts` (delete `Agent`, `AgentStatus`, `AgentRole`, `Session.agents`, `agentId` fields on task/action payloads and `ActivityItem`); new `src/lib/deuce.ts` (or similar) exporting the `DEUCE` constant (id matching the Go constant, name, color); `src/lib/api.ts` (drop `listAgents`/`createAgent`/`updateAgent`/`deleteAgent`/`updateSessionAgents`/`AgentMutation`/`agentIds`; add `getAgentSettings`/`updateAgentSettings`; keep `stopAgent` pointing at the rewired route); `src/stores/session-store.ts` (delete `thinkingAgents`, `agentOutput` and their actions; `openThread` drops `agentId`; `steer` drops agentId); `src/stores/agent-runs.ts` + `.test.ts` (de-key agentId; add a derived session-status selector: any task running/awaiting_input → working/waiting, else idle); `src/hooks/use-websocket.ts` (remove `agent_status`/`typing_indicator`/`agent_output` handlers; `sendSteer` drops agentId); `src/components/chat/message-visibility.ts` + `.test.ts` (filter pins to `DEUCE.id`; nil-author system notices stay visible); `src/mocks/data/seed.ts` (single deuce agent). +- **Approach:** Check how the frontend `.test.ts` files run (no test script is configured in `package.json`) — wire vitest or run the existing convention before relying on the tests; flag in the PR if a runner had to be added. +- **Test scenarios:** + - Visibility: message with `authorType='agent'` + `DEUCE.id` is hidden from chat; nil-UUID system notice visible; human message visible; agent-authored message with an unknown legacy UUID — decide and pin behavior (post-migration these shouldn't exist; hiding all `authorType='agent'` except nil is the safe shape). + - Reducer: task lifecycle events without `agentId` reduce correctly; queue positions key per session; status selector returns working during a running task, waiting during awaiting_input, idle when none. + - Edge: WS reconnect snapshot (`fetchAgentRuns`) hydrates the de-keyed reducer. +- **Verification:** `npx tsc --noEmit` clean across the U5–U6 set; reducer and visibility test suites green. + +### U6. Frontend UI surfaces + +- **Goal:** Every multi-agent UI surface becomes the single-deuce equivalent; Stop gets its new home. +- **Requirements:** R6 (UI half), R7 (UI hint), R10, R11 +- **Dependencies:** U5 +- **Files:** `src/components/chat/ChatView.tsx` (delete mention-parse/`mentions` send, `TypingIndicator`/`AgentWorkingIndicator`, agent-colored borders keyed by roster; empty state pre-fills `@deuce `; running `AgentTaskCard` gains a Stop button); `src/components/super-threads/AgentTaskCard.tsx`, `AgentThreadDrawer.tsx` (single per-session thread, status from the U5 selector, Stop in header), `ThreadDrawerPanel.tsx`, `atoms.tsx` (color from `DEUCE`), `utils.ts`; `src/components/settings/AgentSettingsDialog.tsx` → single system-prompt editor with the "takes effect on the agent's next process launch" note; `src/components/layout/SessionSidebar.tsx` (settings entry point), `SummaryPanel.tsx` (one deuce row, status from selector, participant count = members + 1), `AppShell.tsx`; `src/components/session/CreateSessionDialog.tsx` (delete preset loading and roster checkboxes); `src/styles/globals.css` (delete `--color-agent-*` role tokens; keep/collapse the `--ac` plumbing to deuce's color). +- **Approach:** `@deuce` highlighting in rendered messages comes from text matching, not a mentions array. Keep the drawer's awaiting-input question context (`QuestionControls`) prominent — with one shared thread, two humans' drawer messages can collide on a pending question (accepted; see Scope Boundaries). +- **Test scenarios:** + - Happy path (manual smoke): create session → empty state shows `@deuce` pre-fill → mention runs task → card shows queued/running with Stop → drawer opens, steer works → Stop cancels → settings dialog edits prompt and shows the staleness note. + - Edge: session with pre-migration history renders without crashing (old hidden messages stay hidden; no roster lookups). + - Test expectation: component-level — none beyond the U5 logic modules (no component test rig exists); the pure-logic-first pattern (`message-visibility.ts` precedent) keeps testable logic out of components. +- **Verification:** `npx tsc --noEmit` + `npm run lint` clean; manual smoke of the happy path above against a migrated dev DB. + +### U7. Documentation + +- **Goal:** Docs describe the single-agent model. +- **Requirements:** R14 +- **Dependencies:** U1–U6 +- **Files:** `CLAUDE.md` (remove `DEUCE_AGENT_HARNESS` and the legacy-harness paragraph from the env block, remove the role-color design-system bullet, fix the "Agent simulation" architecture line, mention `GET/PUT /api/agent`); `README.md` (any multi-agent framing). +- **Test scenarios:** Test expectation: none — documentation-only unit. +- **Verification:** `grep` for "Coder", "Reviewer", "DEUCE_AGENT_HARNESS", "session_agents" in CLAUDE.md/README returns nothing stale. + +--- + +## Scope Boundaries + +**In scope:** everything above — schema collapse, legacy harness deletion, runtime de-keying, contract simplification, UI collapse, docs. + +**Out of scope (separate product bets):** + +- Skills, plugins, and subagent dispatch (the ideation doc's main thread) — this refactor only clears the ground. +- "Deuce always listening" / ambient triggering — deuce stays mention-triggered. + +### Deferred to Follow-Up Work + +- A proper `system` author type replacing the nil-UUID sentinel hack (`postSystemMessage` writes `author_type='agent'` + nil UUID today; cleaner but touches rendering and is severable). +- `pi_session_id` resume-across-restart, re-added on `sessions` when actually implemented. +- Audit trail / announcement for system-prompt edits (global setting, per-session blast radius; today any authenticated user can edit silently). +- Unread badge incremented by hidden agent replies (preexisting oddity, now universal — every deuce reply is hidden). +- Multi-human drawer answer collisions on a pending `ask_user` question (existing semantics; the single shared thread makes them likelier — accepted for now, `QuestionControls` shows question context). +- Transitional `agents: []` compat field in session JSON (rejected — stale tabs hard-reload, acceptable pre-1.0). + +--- + +## Risks & Dependencies + +- **Deploy with in-flight tasks.** Running/awaiting tasks at deploy are failed by boot recovery (existing behavior); queued tasks are cancelled by the migration (R12). Users re-mention `@deuce`. +- **Frontend tests have no configured runner.** `package.json` has no test script despite `.test.ts` files existing — U5 must establish how they run before trusting them. +- **`pi-subagents` confusion hazard.** `PiSubagentsPackage` provisioning in `server/internal/workspace/manager.go` looks multi-agent but is Pi-internal tooling consumed inside the container — do not remove it. +- **Old bundles crash at deploy** (session JSON shape change). Accepted; hard reload recovers. +- **Route-gate regression.** Replacement routes (`GET/PUT /api/agent`, rewired stop) must carry explicit gates; the audit in `docs/solutions/architecture-patterns/broadening-resource-visibility-requires-per-route-authorization-audit.md` is the checklist, including routes outside the `/api/sessions/{id}` subtree. + +--- + +## Sources + +- `docs/ideation/2026-06-08-single-deuce-agent-ideation.md` — design source for the single-agent direction; documents that role/system_prompt never reach Pi. +- `docs/solutions/architecture-patterns/pi-loads-agent-skills-standard-in-rpc-mode.md` — validated Pi 0.74.2 mechanics: `--append-system-prompt` at launch is real; no mid-session prompt override; skills/extension provisioning seam. +- `docs/solutions/architecture-patterns/broadening-resource-visibility-requires-per-route-authorization-audit.md` — per-route gate audit method for the route changes in U4. +- `server/internal/db/migrations/007_drop_user_forge_id.sql` — precedent for destructive migrations whose Down restores schema + seed rows. +- Project memory: legacy `claude -p` harness is removal fodder (don't port it); `ask_user` requires a capable model (`DEUCE_PI_MODEL`) — unchanged by this refactor but relevant to the single agent's question flow. diff --git a/server/internal/agent/executor.go b/server/internal/agent/executor.go deleted file mode 100644 index 062e258..0000000 --- a/server/internal/agent/executor.go +++ /dev/null @@ -1,167 +0,0 @@ -package agent - -import ( - "bufio" - "context" - "fmt" - "log/slog" - "strings" - "time" - - "github.com/forgeutah/deuce/server/internal/workspace" -) - -// StreamFunc receives streaming output events from Claude Code execution. -type StreamFunc func(event StreamEvent) - -// StreamEvent represents a parsed streaming output event from Claude Code. -type StreamEvent struct { - Type string // "text", "tool_use", "tool_result", "error" - Content string // text content or tool description -} - -// ExecuteParams holds the parameters for an agent execution. -type ExecuteParams struct { - WorkspaceID string - AgentName string - SystemPrompt string - UserMessage string - ChatHistory string // formatted chat history for context - ClaudeSessionID string // for --resume, empty for fresh session - Model string -} - -// ExecuteResult holds the result of an agent execution. -type ExecuteResult struct { - Summary string - ExpandableContent []map[string]string - ClaudeSessionID string - Error string -} - -// Executor manages Claude Code headless execution inside DevPod workspaces. -type Executor struct { - workspaces *workspace.Manager - apiKey string - timeout time.Duration -} - -// NewExecutor creates a new agent executor. -func NewExecutor(wm *workspace.Manager, apiKey string) *Executor { - return &Executor{ - workspaces: wm, - apiKey: apiKey, - timeout: 5 * time.Minute, - } -} - -// Execute runs Claude Code headless inside a devcontainer and returns structured results. -func (e *Executor) Execute(ctx context.Context, params ExecuteParams, streamFn StreamFunc) (*ExecuteResult, error) { - if e.apiKey == "" { - return nil, fmt.Errorf("ANTHROPIC_API_KEY not configured") - } - - // Apply timeout - ctx, cancel := context.WithTimeout(ctx, e.timeout) - defer cancel() - - // Build the claude command - cmdParts := []string{"claude", "-p"} - - // Add system prompt - if params.SystemPrompt != "" { - cmdParts = append(cmdParts, "--append-system-prompt", shellQuote(params.SystemPrompt)) - } - - // Add allowed tools - cmdParts = append(cmdParts, "--allowedTools", "Bash,Read,Edit,Write") - - // Use stream-json for structured output - cmdParts = append(cmdParts, "--output-format", "stream-json", "--verbose") - - // Resume session if available - if params.ClaudeSessionID != "" { - cmdParts = append(cmdParts, "--resume", params.ClaudeSessionID) - } - - // Build the full prompt with chat history context - prompt := params.UserMessage - if params.ChatHistory != "" && params.ClaudeSessionID == "" { - // Only inject full chat history on fresh sessions - prompt = fmt.Sprintf("Recent conversation context:\n%s\n\nCurrent request:\n%s", params.ChatHistory, params.UserMessage) - } - - // Build the full SSH command — pipe prompt via stdin. Prefix PATH with - // $HOME/.local/bin since the native installer drops `claude` there and - // `devpod ssh --command` runs a non-interactive shell. - fullCommand := fmt.Sprintf("echo %s | %s%s", shellQuote(prompt), workspace.ClaudePathPrefix, strings.Join(cmdParts, " ")) - - // Pass API key into the container via --set-env (not cmd.Env, which only sets host env) - cmd := e.workspaces.ExecInWorkspace(ctx, params.WorkspaceID, fullCommand, - fmt.Sprintf("ANTHROPIC_API_KEY=%s", e.apiKey), - ) - - slog.Info("executing agent", "agent", params.AgentName, "workspace", params.WorkspaceID) - - // Capture stdout for parsing - stdout, err := cmd.StdoutPipe() - if err != nil { - return nil, fmt.Errorf("stdout pipe: %w", err) - } - cmd.Stderr = cmd.Stdout - - if err := cmd.Start(); err != nil { - return nil, fmt.Errorf("failed to start claude: %w", err) - } - - // Parse streaming output - result := &ExecuteResult{} - parser := newOutputParser() - - scanner := bufio.NewScanner(stdout) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - for scanner.Scan() { - line := scanner.Text() - events := parser.parseLine(line) - for _, event := range events { - if streamFn != nil { - streamFn(event) - } - } - } - - if err := cmd.Wait(); err != nil { - // Check if this was a resume failure - if params.ClaudeSessionID != "" && ctx.Err() == nil { - slog.Warn("claude execution failed with resume, retrying without", "agent", params.AgentName, "error", err) - // Retry without resume - retryParams := params - retryParams.ClaudeSessionID = "" - return e.Execute(ctx, retryParams, streamFn) - } - - if ctx.Err() == context.Canceled { - result.Error = "cancelled" - return result, fmt.Errorf("agent execution cancelled") - } - if ctx.Err() == context.DeadlineExceeded { - result.Error = "timeout" - return result, fmt.Errorf("agent execution timed out after %v", e.timeout) - } - result.Error = err.Error() - return result, fmt.Errorf("claude execution failed: %w", err) - } - - // Extract results from parser - result.Summary = parser.getSummary() - result.ExpandableContent = parser.getExpandableContent() - result.ClaudeSessionID = parser.getSessionID() - - slog.Info("agent execution complete", "agent", params.AgentName, "sessionID", result.ClaudeSessionID) - return result, nil -} - -// shellQuote wraps a string in single quotes for shell safety. -func shellQuote(s string) string { - return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" -} diff --git a/server/internal/agent/output.go b/server/internal/agent/output.go deleted file mode 100644 index dd73c72..0000000 --- a/server/internal/agent/output.go +++ /dev/null @@ -1,110 +0,0 @@ -package agent - -import ( - "encoding/json" - "strings" -) - -// outputParser parses Claude Code stream-json output into structured results. -type outputParser struct { - summaryParts []string - expandableContent []map[string]string - sessionID string - currentToolName string -} - -func newOutputParser() *outputParser { - return &outputParser{} -} - -// parseLine parses a single line of stream-json output and returns any events. -func (p *outputParser) parseLine(line string) []StreamEvent { - line = strings.TrimSpace(line) - if line == "" { - return nil - } - - var raw map[string]any - if err := json.Unmarshal([]byte(line), &raw); err != nil { - // Not JSON — could be raw text output - return nil - } - - var events []StreamEvent - - msgType, _ := raw["type"].(string) - - switch msgType { - case "stream_event": - event, _ := raw["event"].(map[string]any) - if event == nil { - break - } - - delta, _ := event["delta"].(map[string]any) - if delta != nil { - deltaType, _ := delta["type"].(string) - switch deltaType { - case "text_delta": - text, _ := delta["text"].(string) - if text != "" { - p.summaryParts = append(p.summaryParts, text) - events = append(events, StreamEvent{Type: "text", Content: text}) - } - case "input_json_delta": - // Tool input streaming — not critical for chat display - } - } - - // Handle content_block_start for tool use - contentBlock, _ := event["content_block"].(map[string]any) - if contentBlock != nil { - blockType, _ := contentBlock["type"].(string) - if blockType == "tool_use" { - toolName, _ := contentBlock["name"].(string) - p.currentToolName = toolName - events = append(events, StreamEvent{ - Type: "tool_use", - Content: toolName, - }) - } - } - - case "result": - // Final result — extract session_id and any remaining data - if sid, ok := raw["session_id"].(string); ok { - p.sessionID = sid - } - - // Extract result text if present - if result, ok := raw["result"].(string); ok && result != "" { - if len(p.summaryParts) == 0 { - p.summaryParts = append(p.summaryParts, result) - } - } - - case "system": - // System events (init, retry, etc.) — log but don't surface - subtype, _ := raw["subtype"].(string) - if subtype == "api_retry" { - events = append(events, StreamEvent{ - Type: "error", - Content: "Rate limited, retrying...", - }) - } - } - - return events -} - -func (p *outputParser) getSummary() string { - return strings.Join(p.summaryParts, "") -} - -func (p *outputParser) getExpandableContent() []map[string]string { - return p.expandableContent -} - -func (p *outputParser) getSessionID() string { - return p.sessionID -} diff --git a/server/internal/agent/queue.go b/server/internal/agent/queue.go deleted file mode 100644 index 33fd45f..0000000 --- a/server/internal/agent/queue.go +++ /dev/null @@ -1,168 +0,0 @@ -package agent - -import ( - "context" - "log/slog" - "sync" - "time" -) - -const ( - queueBufferSize = 10 - workerIdleTimeout = 10 * time.Minute -) - -// Task represents an agent execution request. -type Task struct { - SessionID string - AgentID string - AgentName string - Prompt string - Callback func(task Task) // Called by the worker to execute the task -} - -// Queue manages per-session sequential agent execution. -type Queue struct { - mu sync.Mutex - sessions map[string]chan Task - cancels map[string]context.CancelFunc // active execution cancel functions - wg sync.WaitGroup -} - -// NewQueue creates a new agent execution queue. -func NewQueue() *Queue { - return &Queue{ - sessions: make(map[string]chan Task), - cancels: make(map[string]context.CancelFunc), - } -} - -// Enqueue adds a task to the session's queue. Returns an error if the queue is full. -func (q *Queue) Enqueue(task Task) error { - q.mu.Lock() - ch, exists := q.sessions[task.SessionID] - if !exists { - ch = make(chan Task, queueBufferSize) - q.sessions[task.SessionID] = ch - q.wg.Add(1) - go q.worker(task.SessionID, ch) - } - q.mu.Unlock() - - select { - case ch <- task: - return nil - default: - return ErrQueueFull - } -} - -// SetCancel stores a cancel function for the currently executing task in a session. -func (q *Queue) SetCancel(sessionID string, cancel context.CancelFunc) { - q.mu.Lock() - defer q.mu.Unlock() - q.cancels[sessionID] = cancel -} - -// ClearCancel removes the cancel function for a session. -func (q *Queue) ClearCancel(sessionID string) { - q.mu.Lock() - defer q.mu.Unlock() - delete(q.cancels, sessionID) -} - -// Cancel cancels the currently executing task in a session. -func (q *Queue) Cancel(sessionID string) bool { - q.mu.Lock() - cancel, ok := q.cancels[sessionID] - q.mu.Unlock() - if ok { - cancel() - return true - } - return false -} - -// CancelSession cancels the running task and drains the queue for a session. -func (q *Queue) CancelSession(sessionID string) { - q.Cancel(sessionID) - - q.mu.Lock() - ch, exists := q.sessions[sessionID] - q.mu.Unlock() - - if exists { - // Drain remaining tasks - for { - select { - case <-ch: - default: - return - } - } - } -} - -// Shutdown cancels all active executions and waits for workers to exit. -func (q *Queue) Shutdown(ctx context.Context) { - q.mu.Lock() - // Cancel all active executions - for _, cancel := range q.cancels { - cancel() - } - // Close all channels to signal workers to exit - for id, ch := range q.sessions { - close(ch) - delete(q.sessions, id) - } - q.mu.Unlock() - - // Wait for workers with timeout - done := make(chan struct{}) - go func() { - q.wg.Wait() - close(done) - }() - - select { - case <-done: - case <-ctx.Done(): - slog.Warn("agent queue shutdown timed out") - } -} - -func (q *Queue) worker(sessionID string, ch chan Task) { - defer q.wg.Done() - slog.Info("agent queue worker started", "session", sessionID) - - for { - select { - case task, ok := <-ch: - if !ok { - slog.Info("agent queue worker exiting (channel closed)", "session", sessionID) - return - } - task.Callback(task) - - case <-time.After(workerIdleTimeout): - q.mu.Lock() - // Check if the channel is still empty before cleaning up - if len(ch) == 0 { - delete(q.sessions, sessionID) - q.mu.Unlock() - slog.Info("agent queue worker exiting (idle timeout)", "session", sessionID) - return - } - q.mu.Unlock() - } - } -} - -// ErrQueueFull is returned when the agent queue for a session is full. -var ErrQueueFull = &queueFullError{} - -type queueFullError struct{} - -func (e *queueFullError) Error() string { - return "agent queue is full, please wait for current work to complete" -} diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 3899b8b..4637421 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -42,13 +42,10 @@ type Config struct { DevPodProvider string `env:"DEVPOD_PROVIDER" envDefault:"docker"` AnthropicAPIKey string `env:"ANTHROPIC_API_KEY" envDefault:""` - // AgentHarness selects the agent backend: "pi" (default, Pi RPC) or - // "claude" (the legacy claude -p executor, kept one release as an emergency - // rollback — KTD11). PiProvider/PiModel configure the Pi backend; v1 runs - // Claude models through Pi. - AgentHarness string `env:"DEUCE_AGENT_HARNESS" envDefault:"pi"` - PiProvider string `env:"DEUCE_PI_PROVIDER" envDefault:"anthropic"` - PiModel string `env:"DEUCE_PI_MODEL" envDefault:"claude-haiku-4-5"` + // PiProvider/PiModel configure the Pi agent backend (the sole harness); + // v1 runs Claude models through Pi. + PiProvider string `env:"DEUCE_PI_PROVIDER" envDefault:"anthropic"` + PiModel string `env:"DEUCE_PI_MODEL" envDefault:"claude-haiku-4-5"` // AgentSystemPrompt is a global system prompt prepended to every agent's own // system_prompt on the Pi path. Empty means use agent.DefaultBaseSystemPrompt diff --git a/server/internal/db/agents.sql.go b/server/internal/db/agents.sql.go index 964266c..dc15dab 100644 --- a/server/internal/db/agents.sql.go +++ b/server/internal/db/agents.sql.go @@ -81,22 +81,6 @@ func (q *Queries) GetAgent(ctx context.Context, id uuid.UUID) (Agent, error) { return i, err } -const getClaudeSessionID = `-- name: GetClaudeSessionID :one -SELECT claude_session_id FROM session_agents WHERE session_id = $1 AND agent_id = $2 -` - -type GetClaudeSessionIDParams struct { - SessionID uuid.UUID `json:"session_id"` - AgentID uuid.UUID `json:"agent_id"` -} - -func (q *Queries) GetClaudeSessionID(ctx context.Context, arg GetClaudeSessionIDParams) (string, error) { - row := q.db.QueryRow(ctx, getClaudeSessionID, arg.SessionID, arg.AgentID) - var claude_session_id string - err := row.Scan(&claude_session_id) - return claude_session_id, err -} - const listAgents = `-- name: ListAgents :many SELECT id, name, role, color, color_muted, provider, model, description, system_prompt, deleted_at, created_at, updated_at FROM agents WHERE deleted_at IS NULL ORDER BY name ` @@ -134,15 +118,6 @@ func (q *Queries) ListAgents(ctx context.Context) ([]Agent, error) { return items, nil } -const resetStaleAgentStatuses = `-- name: ResetStaleAgentStatuses :exec -UPDATE session_agents SET status = 'idle' WHERE status = 'working' -` - -func (q *Queries) ResetStaleAgentStatuses(ctx context.Context) error { - _, err := q.db.Exec(ctx, resetStaleAgentStatuses) - return err -} - const softDeleteAgent = `-- name: SoftDeleteAgent :exec UPDATE agents SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL ` @@ -202,18 +177,3 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent ) return i, err } - -const updateClaudeSessionID = `-- name: UpdateClaudeSessionID :exec -UPDATE session_agents SET claude_session_id = $3 WHERE session_id = $1 AND agent_id = $2 -` - -type UpdateClaudeSessionIDParams struct { - SessionID uuid.UUID `json:"session_id"` - AgentID uuid.UUID `json:"agent_id"` - ClaudeSessionID string `json:"claude_session_id"` -} - -func (q *Queries) UpdateClaudeSessionID(ctx context.Context, arg UpdateClaudeSessionIDParams) error { - _, err := q.db.Exec(ctx, updateClaudeSessionID, arg.SessionID, arg.AgentID, arg.ClaudeSessionID) - return err -} diff --git a/server/internal/db/queries/agents.sql b/server/internal/db/queries/agents.sql index a3cdbdc..aa9e98b 100644 --- a/server/internal/db/queries/agents.sql +++ b/server/internal/db/queries/agents.sql @@ -23,12 +23,3 @@ RETURNING *; -- name: SoftDeleteAgent :exec UPDATE agents SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL; - --- name: UpdateClaudeSessionID :exec -UPDATE session_agents SET claude_session_id = $3 WHERE session_id = $1 AND agent_id = $2; - --- name: GetClaudeSessionID :one -SELECT claude_session_id FROM session_agents WHERE session_id = $1 AND agent_id = $2; - --- name: ResetStaleAgentStatuses :exec -UPDATE session_agents SET status = 'idle' WHERE status = 'working'; diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go index fd76af8..8e4cf0d 100644 --- a/server/internal/handler/handler.go +++ b/server/internal/handler/handler.go @@ -24,11 +24,8 @@ type Handler struct { githubToken string workspaces *workspace.Manager terminals *terminal.Manager - executor *agent.Executor - agentQueue *agent.Queue - // runtime is the Pi-harness engine (KTD11). Non-nil when - // DEUCE_AGENT_HARNESS=pi (default); nil in legacy "claude" mode, where the - // executor/agentQueue path runs instead. Installed via SetRuntime. + // runtime is the Pi-harness engine (KTD11). Installed via SetRuntime; + // nil only in tests that don't wire it. runtime *agent.Runtime wsOrigins []string publicHostname string @@ -72,7 +69,7 @@ func (h *Handler) WaitWorkspaceActions(ctx context.Context) error { // header in dev; required-in-proxy is enforced by config.Validate). // sshListenAddr is used to derive the URI port. sshAvailable lets main.go // flip the SSH state; nil means always-available (test default). -func New(queries *db.Queries, pool *pgxpool.Pool, hub *ws.Hub, githubToken string, wm *workspace.Manager, tm *terminal.Manager, exec *agent.Executor, aq *agent.Queue, wsOrigins []string, publicHostname, sshListenAddr string) *Handler { +func New(queries *db.Queries, pool *pgxpool.Pool, hub *ws.Hub, githubToken string, wm *workspace.Manager, tm *terminal.Manager, wsOrigins []string, publicHostname, sshListenAddr string) *Handler { return &Handler{ queries: queries, pool: pool, @@ -80,8 +77,6 @@ func New(queries *db.Queries, pool *pgxpool.Pool, hub *ws.Hub, githubToken strin githubToken: githubToken, workspaces: wm, terminals: tm, - executor: exec, - agentQueue: aq, wsOrigins: wsOrigins, publicHostname: publicHostname, sshListenAddr: sshListenAddr, diff --git a/server/internal/handler/messages.go b/server/internal/handler/messages.go index 6fc5039..b1dcd3a 100644 --- a/server/internal/handler/messages.go +++ b/server/internal/handler/messages.go @@ -11,7 +11,6 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" agentpkg "github.com/forgeutah/deuce/server/internal/agent" db "github.com/forgeutah/deuce/server/internal/db" @@ -192,17 +191,13 @@ func (h *Handler) SendMessage(w http.ResponseWriter, r *http.Request) { if strings.TrimSpace(req.Content) == "/stop" || strings.HasSuffix(strings.TrimSpace(req.Content), " stop") { if h.runtime != nil { h.runtime.CancelSession(r.Context(), sessionID.String()) - } else if h.agentQueue != nil { - h.agentQueue.Cancel(sessionID.String()) } writeJSON(w, http.StatusCreated, resp) return } - // Process agent mentions: route through the Pi runtime when enabled, - // otherwise the legacy executor queue (DEUCE_AGENT_HARNESS, KTD11). - agentsEnabled := h.runtime != nil || (h.agentQueue != nil && h.executor != nil) - if len(req.Mentions) > 0 && agentsEnabled { + // Process agent mentions through the Pi runtime. + if len(req.Mentions) > 0 && h.runtime != nil { session, err := h.queries.GetSession(r.Context(), sessionID) if err == nil { switch session.WorkspaceStatus { @@ -216,35 +211,20 @@ func (h *Handler) SendMessage(w http.ResponseWriter, r *http.Request) { if err != nil { continue } - ag, err := h.queries.GetAgent(r.Context(), agentID) - if err != nil { + if _, err := h.queries.GetAgent(r.Context(), agentID); err != nil { continue } - if h.runtime != nil { - if _, err := h.runtime.Enqueue(r.Context(), agentpkg.EnqueueParams{ - SessionID: sessionID.String(), - AgentID: agentID.String(), - RequestedBy: userID.String(), - AnchorMessageID: msg.ID.String(), - Prompt: req.Content, - WorkspaceID: session.Name, - }); err != nil { - slog.Error("failed to enqueue agent task", "error", err) - h.postSystemMessage(sessionID, "Could not start the agent. Please try again.") - } - continue - } - - task := agentpkg.Task{ - SessionID: sessionID.String(), - AgentID: agentID.String(), - AgentName: ag.Name, - Prompt: req.Content, - Callback: func(t agentpkg.Task) { h.executeAgent(t, session.Name) }, - } - if err := h.agentQueue.Enqueue(task); err != nil { - h.postSystemMessage(sessionID, "Agent queue is full. Please wait for current work to complete.") + if _, err := h.runtime.Enqueue(r.Context(), agentpkg.EnqueueParams{ + SessionID: sessionID.String(), + AgentID: agentID.String(), + RequestedBy: userID.String(), + AnchorMessageID: msg.ID.String(), + Prompt: req.Content, + WorkspaceID: session.Name, + }); err != nil { + slog.Error("failed to enqueue agent task", "error", err) + h.postSystemMessage(sessionID, "Could not start the agent. Please try again.") } } } @@ -272,8 +252,8 @@ func (h *Handler) StopAgent(w http.ResponseWriter, r *http.Request) { return } - if h.agentQueue != nil { - h.agentQueue.Cancel(sessionID.String()) + if h.runtime != nil { + h.runtime.CancelSession(r.Context(), sessionID.String()) } w.WriteHeader(http.StatusNoContent) } @@ -327,212 +307,3 @@ func (h *Handler) postSystemMessage(sessionID uuid.UUID, content string) { h.hub.BroadcastToSession(sessionID.String(), wsMsg, nil) } -func (h *Handler) executeAgent(task agentpkg.Task, workspaceName string) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - sessionID, _ := uuid.Parse(task.SessionID) - agentID, _ := uuid.Parse(task.AgentID) - - // Store cancel function for stop button - h.agentQueue.SetCancel(task.SessionID, cancel) - defer h.agentQueue.ClearCancel(task.SessionID) - - // Set agent to working - _ = h.queries.UpdateSessionAgentStatus(ctx, db.UpdateSessionAgentStatusParams{ - SessionID: sessionID, - AgentID: agentID, - Status: "working", - }) - statusMsg, _ := ws.NewServerMessage(ws.TypeAgentStatus, sessionID.String(), map[string]any{ - "agentId": task.AgentID, - "status": "working", - }) - h.hub.BroadcastToSession(sessionID.String(), statusMsg, nil) - - // Send typing indicator - typingMsg, _ := ws.NewServerMessage(ws.TypeTypingIndicator, sessionID.String(), map[string]any{ - "agentId": task.AgentID, - "active": true, - }) - h.hub.BroadcastToSession(sessionID.String(), typingMsg, nil) - - // Get agent info for system prompt - ag, err := h.queries.GetAgent(ctx, agentID) - if err != nil { - slog.Error("failed to get agent", "error", err) - h.finishAgent(sessionID, agentID, task.AgentID, "Failed to load agent configuration.", true) - return - } - - // Get Claude session ID for continuity - claudeSessionID := "" - if csid, err := h.queries.GetClaudeSessionID(ctx, db.GetClaudeSessionIDParams{ - SessionID: sessionID, - AgentID: agentID, - }); err == nil { - claudeSessionID = csid - } - - // Build chat history context - chatHistory := h.buildChatHistory(ctx, sessionID, claudeSessionID != "") - - // Streaming callback — broadcast agent output via WebSocket - streamFn := func(event agentpkg.StreamEvent) { - outMsg, _ := ws.NewServerMessage(ws.TypeAgentOutput, sessionID.String(), map[string]any{ - "agentId": task.AgentID, - "content": event.Content, - "contentType": event.Type, - }) - h.hub.BroadcastToSession(sessionID.String(), outMsg, nil) - } - - // Execute - result, execErr := h.executor.Execute(ctx, agentpkg.ExecuteParams{ - WorkspaceID: workspaceName, - AgentName: ag.Name, - SystemPrompt: ag.SystemPrompt, - UserMessage: task.Prompt, - ChatHistory: chatHistory, - ClaudeSessionID: claudeSessionID, - Model: ag.Model, - }, streamFn) - - if execErr != nil { - errMsg := "Agent encountered an error: " + execErr.Error() - if result != nil && result.Error == "cancelled" { - errMsg = "Cancelled by user." - } else if result != nil && result.Error == "timeout" { - errMsg = "Agent execution timed out." - } - h.finishAgent(sessionID, agentID, task.AgentID, errMsg, true) - return - } - - // Store Claude session ID for continuity - if result.ClaudeSessionID != "" { - _ = h.queries.UpdateClaudeSessionID(ctx, db.UpdateClaudeSessionIDParams{ - SessionID: sessionID, - AgentID: agentID, - ClaudeSessionID: result.ClaudeSessionID, - }) - } - - // Create the agent message - var ec []byte - if len(result.ExpandableContent) > 0 { - ec, _ = json.Marshal(result.ExpandableContent) - } - - content := result.Summary - if content == "" { - content = "Task completed." - } - - h.finishAgent(sessionID, agentID, task.AgentID, content, false, ec) -} - -func (h *Handler) finishAgent(sessionID, agentID uuid.UUID, agentIDStr, content string, isError bool, expandableContent ...[]byte) { - ctx := context.Background() - - // Stop typing - stopTyping, _ := ws.NewServerMessage(ws.TypeTypingIndicator, sessionID.String(), map[string]any{ - "agentId": agentIDStr, - "active": false, - }) - h.hub.BroadcastToSession(sessionID.String(), stopTyping, nil) - - // Create message - var ec []byte - if len(expandableContent) > 0 { - ec = expandableContent[0] - } - - agentMsg, err := h.queries.CreateMessage(ctx, db.CreateMessageParams{ - SessionID: sessionID, - AuthorID: agentID, - AuthorType: "agent", - Content: content, - ExpandableContent: ec, - Mentions: []string{}, - Status: "sent", - }) - if err != nil { - slog.Error("failed to create agent message", "error", err) - } else { - _ = h.queries.UpdateSessionLastActivity(ctx, sessionID) - agentWsMsg, _ := ws.NewServerMessage(ws.TypeNewMessage, sessionID.String(), toMessageResponse(agentMsg)) - h.hub.BroadcastToSession(sessionID.String(), agentWsMsg, nil) - } - - // Set status - finalStatus := "idle" - if isError { - finalStatus = "error" - // Reset to idle after a delay - go func() { - time.Sleep(10 * time.Second) - _ = h.queries.UpdateSessionAgentStatus(context.Background(), db.UpdateSessionAgentStatusParams{ - SessionID: sessionID, - AgentID: agentID, - Status: "idle", - }) - idleMsg, _ := ws.NewServerMessage(ws.TypeAgentStatus, sessionID.String(), map[string]any{ - "agentId": agentIDStr, - "status": "idle", - }) - h.hub.BroadcastToSession(sessionID.String(), idleMsg, nil) - }() - } - - _ = h.queries.UpdateSessionAgentStatus(ctx, db.UpdateSessionAgentStatusParams{ - SessionID: sessionID, - AgentID: agentID, - Status: finalStatus, - }) - - statusMsg, _ := ws.NewServerMessage(ws.TypeAgentStatus, sessionID.String(), map[string]any{ - "agentId": agentIDStr, - "status": finalStatus, - }) - h.hub.BroadcastToSession(sessionID.String(), statusMsg, nil) - - // Create activity - agentUUID := pgtype.UUID{Bytes: agentID, Valid: true} - desc := "completed a task" - if isError { - desc = "encountered an error" - } - _, _ = h.queries.CreateActivity(ctx, db.CreateActivityParams{ - SessionID: sessionID, - Type: "agent-action", - Description: desc, - AgentID: agentUUID, - }) -} - -func (h *Handler) buildChatHistory(ctx context.Context, sessionID uuid.UUID, hasResume bool) string { - msgs, err := h.queries.ListMessages(ctx, db.ListMessagesParams{ - SessionID: sessionID, - Limit: 20, - }) - if err != nil || len(msgs) == 0 { - return "" - } - - // If resuming, only include messages since the last agent response - if hasResume { - msgs = msgs[:min(5, len(msgs))] - } - - var parts []string - for i := len(msgs) - 1; i >= 0; i-- { - m := msgs[i] - prefix := "[Human]" - if m.AuthorType == "agent" { - prefix = "[Agent]" - } - parts = append(parts, prefix+" "+m.Content) - } - return strings.Join(parts, "\n") -} diff --git a/server/internal/handler/session_visibility_test.go b/server/internal/handler/session_visibility_test.go index 40a5d8a..fecd02a 100644 --- a/server/internal/handler/session_visibility_test.go +++ b/server/internal/handler/session_visibility_test.go @@ -60,7 +60,7 @@ func newVisFixture(t *testing.T) *visFixture { hub := ws.NewHub() go hub.Run() - h := New(q, pool, hub, "", nil, nil, nil, nil, nil, "", "") + h := New(q, pool, hub, "", nil, nil, nil, "", "") r := chi.NewRouter() r.Use(auth.Middleware("")) diff --git a/server/internal/handler/sessions_test.go b/server/internal/handler/sessions_test.go index 5a363ef..d602a25 100644 --- a/server/internal/handler/sessions_test.go +++ b/server/internal/handler/sessions_test.go @@ -90,7 +90,7 @@ func newVSCodeURIFixture(t *testing.T, publicHostname, sshAddr string) *vscodeUR // Construct handler with zero/empty deps — only the queries field and // the two URI-builder fields are exercised by GetSessionVSCodeURI. - h := New(q, pool, nil, "", nil, nil, nil, nil, nil, publicHostname, sshAddr) + h := New(q, pool, nil, "", nil, nil, nil, publicHostname, sshAddr) // Build a chi router so chi.URLParam works inside the handler, and // inject the seeded userID via the existing auth middleware. The diff --git a/server/internal/handler/teams_test.go b/server/internal/handler/teams_test.go index bc5019b..05d0ccc 100644 --- a/server/internal/handler/teams_test.go +++ b/server/internal/handler/teams_test.go @@ -47,7 +47,7 @@ func newTeamsFixture(t *testing.T) *teamsFixture { } q := db.New(pool) - h := New(q, pool, nil, "", nil, nil, nil, nil, nil, "", "") + h := New(q, pool, nil, "", nil, nil, nil, "", "") r := chi.NewRouter() r.Use(auth.Middleware("")) diff --git a/server/internal/server/server.go b/server/internal/server/server.go index 5df773c..ba23062 100644 --- a/server/internal/server/server.go +++ b/server/internal/server/server.go @@ -131,15 +131,6 @@ func (s *Server) Router() http.Handler { tm := terminal.NewManager() - // Create agent executor and queue - exec := agent.NewExecutor(wm, s.cfg.AnthropicAPIKey) - aq := agent.NewQueue() - - // Startup recovery: reset stale agent statuses from prior server crash - if err := s.queries.ResetStaleAgentStatuses(context.Background()); err != nil { - slog.Warn("failed to reset stale agent statuses", "error", err) - } - // Startup recovery: flip any workspace_status sitting in a transitional // state (starting/stopping/rebuilding/deleting) to `failed`. Their owning // goroutines died with the prior process; without this, the rows would @@ -152,35 +143,30 @@ func (s *Server) Router() http.Handler { // PublicHostname / SSHListenAddr come from config (U11). The vscode-uri // endpoint falls back to r.Host when PublicHostname is empty (dev mode); // proxy mode requires it via config.Validate. - h := handler.New(s.queries, s.pool, s.hub, s.cfg.GitHubToken, wm, tm, exec, aq, s.cfg.WSAllowedOriginList(), s.cfg.PublicHostname, s.cfg.SSHListenAddr) + h := handler.New(s.queries, s.pool, s.hub, s.cfg.GitHubToken, wm, tm, s.cfg.WSAllowedOriginList(), s.cfg.PublicHostname, s.cfg.SSHListenAddr) if s.sshAvailable != nil { h.SetSSHAvailable(s.sshAvailable) } - // Agent harness selection (KTD11). Default "pi": run boot recovery to - // completion BEFORE the runtime starts accepting work (KTD10 happens-before), - // then wire the runtime into the handler. Legacy "claude" keeps the executor. - if s.cfg.AgentHarness == "pi" { - if err := handler.RecoverStuckTasks(context.Background(), s.queries); err != nil { - // Abort boot: serving with crash-stuck tasks would report them live - // in snapshots forever (KTD10 retry-then-abort). - panic(fmt.Sprintf("pi harness boot recovery failed: %v", err)) - } - launcher := pirun.NewDevpodLauncher(wm, s.cfg.PiProvider, s.cfg.PiModel) - sup := pirun.NewSupervisor(launcher, s.cfg.AnthropicAPIKey) - basePrompt := s.cfg.AgentSystemPrompt - if basePrompt == "" { - basePrompt = agent.DefaultBaseSystemPrompt - } - rt := agent.NewRuntime(agent.NewDBStore(s.pool, s.queries), sup, s.hub, basePrompt) - rt.Start() - h.SetRuntime(rt) - s.piSupervisor = sup - s.piRuntime = rt - slog.Info("agent harness: pi") - } else { - slog.Info("agent harness: claude (legacy)") + // Pi agent harness: run boot recovery to completion BEFORE the runtime + // starts accepting work (KTD10 happens-before), then wire the runtime + // into the handler. + if err := handler.RecoverStuckTasks(context.Background(), s.queries); err != nil { + // Abort boot: serving with crash-stuck tasks would report them live + // in snapshots forever (KTD10 retry-then-abort). + panic(fmt.Sprintf("pi harness boot recovery failed: %v", err)) + } + launcher := pirun.NewDevpodLauncher(wm, s.cfg.PiProvider, s.cfg.PiModel) + sup := pirun.NewSupervisor(launcher, s.cfg.AnthropicAPIKey) + basePrompt := s.cfg.AgentSystemPrompt + if basePrompt == "" { + basePrompt = agent.DefaultBaseSystemPrompt } + rt := agent.NewRuntime(agent.NewDBStore(s.pool, s.queries), sup, s.hub, basePrompt) + rt.Start() + h.SetRuntime(rt) + s.piSupervisor = sup + s.piRuntime = rt s.handler = h go s.hub.Run() diff --git a/server/internal/ws/events.go b/server/internal/ws/events.go index ba9f9df..f80126d 100644 --- a/server/internal/ws/events.go +++ b/server/internal/ws/events.go @@ -15,14 +15,11 @@ const ( // Server-to-client message types const ( - TypeNewMessage = "new_message" - TypeAgentStatus = "agent_status" - TypeTypingIndicator = "typing_indicator" - TypeActivityUpdate = "activity_update" - TypeSessionUpdate = "session_update" - TypeUnreadUpdate = "unread_update" - TypeWorkspaceLog = "workspace_log" - TypeAgentOutput = "agent_output" + TypeNewMessage = "new_message" + TypeActivityUpdate = "activity_update" + TypeSessionUpdate = "session_update" + TypeUnreadUpdate = "unread_update" + TypeWorkspaceLog = "workspace_log" // AgentRunEvent family (Super Threads). Append-only, per-session // monotonic-seq deltas applied client-side by seq. Deliberately NOT routed From 363ba57d61595e2ae7b6181c00c19fb76d870f06 Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Wed, 10 Jun 2026 00:38:06 +0000 Subject: [PATCH 2/6] refactor(server): collapse backend to the single deuce agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 013 drops session_agents, de-keys tasks from agents (FK/column drop ordered before row deletes so task history survives), cancels still-queued tasks, repoints historical agent messages to deuce's fixed UUID (nil-UUID system sentinel excluded), reshapes agents to one seeded row (id/name/system_prompt), and drops messages.mentions. Down restores the pre-013 schema and the five role seed rows. Runtime keys on session ID — one Pi process and one serial queue per session. @deuce mention detection moves server-side (word-boundary regex; emails and @deucebot don't trigger); the mentions plumbing is gone end to end. /stop is exact-match and drains the queue along with the running task (R6). Agent CRUD is replaced by GET/PUT /api/agent for deuce's global system prompt; saving recycles idle Pi processes (StopAndForget deregisters synchronously so a racing enqueue launches fresh). The steer path gains the same workspace-status gate as chat mentions (R8). WS task/action payloads and the steer frame drop agentId; stale-tab fields are tolerated and ignored. --- server/internal/agent/dbstore.go | 47 ++--- server/internal/agent/pirun/protocol.go | 8 - server/internal/agent/pirun/supervisor.go | 59 ++++-- .../internal/agent/pirun/supervisor_test.go | 54 ++--- server/internal/agent/runtime.go | 171 +++++++++------- server/internal/agent/runtime_test.go | 157 +++++++++++---- server/internal/agent/store.go | 27 ++- server/internal/db/activities.sql.go | 21 +- server/internal/db/agents.sql.go | 170 ++-------------- server/internal/db/messages.sql.go | 15 +- .../db/migrations/013_single_deuce_agent.sql | 104 ++++++++++ server/internal/db/models.go | 38 +--- server/internal/db/queries/activities.sql | 4 +- server/internal/db/queries/agents.sql | 33 +--- server/internal/db/queries/messages.sql | 4 +- server/internal/db/queries/sessions.sql | 18 -- server/internal/db/queries/tasks.sql | 35 +--- server/internal/db/sessions.sql.go | 100 ---------- server/internal/db/tasks.sql.go | 119 ++--------- server/internal/handler/activities.go | 22 --- server/internal/handler/agent_run.go | 20 +- server/internal/handler/agent_run_test.go | 5 +- server/internal/handler/agents.go | 185 +++--------------- server/internal/handler/messages.go | 90 ++++----- server/internal/handler/messages_test.go | 43 ++++ server/internal/handler/sessions.go | 52 +---- server/internal/handler/websocket.go | 34 ++-- server/internal/server/server.go | 12 +- server/internal/ws/client.go | 6 +- server/internal/ws/events.go | 15 +- server/internal/ws/events_test.go | 10 +- 31 files changed, 671 insertions(+), 1007 deletions(-) create mode 100644 server/internal/db/migrations/013_single_deuce_agent.sql create mode 100644 server/internal/handler/messages_test.go diff --git a/server/internal/agent/dbstore.go b/server/internal/agent/dbstore.go index c342f53..7f49395 100644 --- a/server/internal/agent/dbstore.go +++ b/server/internal/agent/dbstore.go @@ -56,16 +56,11 @@ func (s *DBStore) CreateQueuedTask(ctx context.Context, p EnqueueParams) (string if err != nil { return "", 0, 0, fmt.Errorf("parse session id: %w", err) } - aid, err := uuid.Parse(p.AgentID) - if err != nil { - return "", 0, 0, fmt.Errorf("parse agent id: %w", err) - } var taskID string seq, err := s.withSeq(ctx, p.SessionID, func(q *db.Queries, seq int64) error { task, err := q.CreateTask(ctx, db.CreateTaskParams{ SessionID: sid, - AgentID: aid, RequestedBy: parseNullableUUID(p.RequestedBy), AnchorMessageID: parseNullableUUID(p.AnchorMessageID), Prompt: p.Prompt, @@ -82,9 +77,9 @@ func (s *DBStore) CreateQueuedTask(ctx context.Context, p EnqueueParams) (string return "", 0, 0, err } - // Queue position: 1-based index among the agent's queued tasks. + // Queue position: 1-based index among the session's queued tasks. position := 1 - if tasks, err := s.q.ListAgentTasks(ctx, db.ListAgentTasksParams{SessionID: sid, AgentID: aid}); err == nil { + if tasks, err := s.q.ListSessionTasks(ctx, sid); err == nil { n := 0 for _, t := range tasks { if t.State == StateQueued { @@ -135,12 +130,8 @@ func (s *DBStore) CompleteAction(ctx context.Context, sessionID, taskID, callID, }) } -func (s *DBStore) AgentSystemPrompt(ctx context.Context, agentID string) (string, error) { - aid, err := uuid.Parse(agentID) - if err != nil { - return "", err - } - ag, err := s.q.GetAgent(ctx, aid) +func (s *DBStore) DeuceSystemPrompt(ctx context.Context) (string, error) { + ag, err := s.q.GetDeuceAgent(ctx) if err != nil { return "", err } @@ -191,8 +182,8 @@ func (s *DBStore) FinishTask(ctx context.Context, sessionID, taskID, state, repl }) } -func (s *DBStore) RunningTask(ctx context.Context, sessionID, agentID string) (string, bool, error) { - tasks, err := s.agentTasks(ctx, sessionID, agentID) +func (s *DBStore) RunningTask(ctx context.Context, sessionID string) (string, bool, error) { + tasks, err := s.sessionTasks(ctx, sessionID) if err != nil { return "", false, err } @@ -204,8 +195,8 @@ func (s *DBStore) RunningTask(ctx context.Context, sessionID, agentID string) (s return "", false, nil } -func (s *DBStore) NextQueuedTask(ctx context.Context, sessionID, agentID string) (string, string, bool, error) { - tasks, err := s.agentTasks(ctx, sessionID, agentID) +func (s *DBStore) NextQueuedTask(ctx context.Context, sessionID string) (string, string, bool, error) { + tasks, err := s.sessionTasks(ctx, sessionID) if err != nil { return "", "", false, err } @@ -217,6 +208,20 @@ func (s *DBStore) NextQueuedTask(ctx context.Context, sessionID, agentID string) return "", "", false, nil } +func (s *DBStore) QueuedTaskIDs(ctx context.Context, sessionID string) ([]string, error) { + tasks, err := s.sessionTasks(ctx, sessionID) + if err != nil { + return nil, err + } + var ids []string + for _, t := range tasks { + if t.State == StateQueued { + ids = append(ids, t.ID.String()) + } + } + return ids, nil +} + func (s *DBStore) TaskState(ctx context.Context, taskID string) (string, bool, error) { tid, err := uuid.Parse(taskID) if err != nil { @@ -232,16 +237,12 @@ func (s *DBStore) TaskState(ctx context.Context, taskID string) (string, bool, e return task.State, true, nil } -func (s *DBStore) agentTasks(ctx context.Context, sessionID, agentID string) ([]db.Task, error) { +func (s *DBStore) sessionTasks(ctx context.Context, sessionID string) ([]db.Task, error) { sid, err := uuid.Parse(sessionID) if err != nil { return nil, err } - aid, err := uuid.Parse(agentID) - if err != nil { - return nil, err - } - return s.q.ListAgentTasks(ctx, db.ListAgentTasksParams{SessionID: sid, AgentID: aid}) + return s.q.ListSessionTasks(ctx, sid) } // parseNullableUUID maps an empty string to a NULL pgtype.UUID and a valid diff --git a/server/internal/agent/pirun/protocol.go b/server/internal/agent/pirun/protocol.go index 19358ac..72105f7 100644 --- a/server/internal/agent/pirun/protocol.go +++ b/server/internal/agent/pirun/protocol.go @@ -63,14 +63,6 @@ type SetSteeringMode struct { func (SetSteeringMode) commandType() string { return "set_steering_mode" } -// SwitchSession re-attaches a restarted process to a prior session by file -// path (KTD13 continuity). Pi identifies sessions by file path or partial UUID. -type SwitchSession struct { - SessionPath string `json:"sessionPath"` -} - -func (SwitchSession) commandType() string { return "switch_session" } - // ExtensionUIResponse answers a blocking extension_ui_request (the ask-user // mechanism, KTD15). The ID must match the originating request's ID. type ExtensionUIResponse struct { diff --git a/server/internal/agent/pirun/supervisor.go b/server/internal/agent/pirun/supervisor.go index 6274f93..fdc0fbc 100644 --- a/server/internal/agent/pirun/supervisor.go +++ b/server/internal/agent/pirun/supervisor.go @@ -10,14 +10,15 @@ import ( "time" ) -// Key identifies one persistent Pi process. There is exactly one Pi RPC process -// per (session, agent) — the granularity the steerable model requires (KTD5). -type Key struct { - SessionID string - AgentID string -} +// Key identifies one persistent Pi process: the session ID. There is exactly +// one Pi RPC process per session — the single built-in deuce agent owns the +// channel's ordering and continuity (KTD5, single-agent collapse). +type Key string + +func (k Key) String() string { return string(k) } -func (k Key) String() string { return k.SessionID + "/" + k.AgentID } +// SessionID returns the session the process belongs to. +func (k Key) SessionID() string { return string(k) } // Handle is one launched Pi process's I/O plus lifecycle control. The real // implementation (devpodLauncher) wraps `devpod ssh --command "pi --mode rpc"`; @@ -164,10 +165,8 @@ func (s *Supervisor) Get(key Key) (*Process, bool) { // Ensure returns the live process for key, launching one if absent. On launch // it performs a readiness handshake (get_state round-trip) before returning, so -// callers never race the devpod-ssh tunnel setup (the U1 transport caveat). When -// sessionPath is non-empty it re-attaches to that prior Pi session via -// switch_session (KTD13 continuity), tolerating failure. -func (s *Supervisor) Ensure(ctx context.Context, key Key, workspaceID, sessionPath, systemPrompt string) (*Process, error) { +// callers never race the devpod-ssh tunnel setup (the U1 transport caveat). +func (s *Supervisor) Ensure(ctx context.Context, key Key, workspaceID, systemPrompt string) (*Process, error) { s.mu.Lock() if s.closed { s.mu.Unlock() @@ -230,11 +229,6 @@ func (s *Supervisor) Ensure(ctx context.Context, key Key, workspaceID, sessionPa s.Stop(key) return nil, fmt.Errorf("pirun: readiness handshake %s: %w%s", key, err, detail) } - if sessionPath != "" { - if err := p.Send(SwitchSession{SessionPath: sessionPath}); err != nil { - slog.Warn("pirun: switch_session failed, continuing with fresh session", "key", key.String(), "error", err) - } - } return p, nil } @@ -329,6 +323,39 @@ func (s *Supervisor) Stop(key Key) { _ = p.h.Stop(ctx) } +// LiveKeys returns the keys of all currently registered processes. +func (s *Supervisor) LiveKeys() []Key { + s.mu.Lock() + defer s.mu.Unlock() + keys := make([]Key, 0, len(s.procs)) + for k := range s.procs { + keys = append(keys, k) + } + return keys +} + +// StopAndForget removes the registry entry synchronously, THEN signals the +// process. Deregistration before the signal means a racing Ensure launches a +// fresh process instead of adopting the dying one (a Stop'd process stays in +// the registry for seconds until the pump reaps it over devpod ssh). Used by +// the prompt-edit recycle path. The pump's registry delete is guarded by +// identity (cur == p), so the double-remove is safe; the Exit signal still +// fires so the scheduler can promote. +func (s *Supervisor) StopAndForget(key Key) { + s.mu.Lock() + p, ok := s.procs[key] + if ok { + delete(s.procs, key) + } + s.mu.Unlock() + if !ok { + return + } + ctx, cancel := context.WithTimeout(context.Background(), stopGrace+2*time.Second) + defer cancel() + _ = p.h.Stop(ctx) +} + // Shutdown stops every process and waits for the pumps to drain, bounded by ctx. func (s *Supervisor) Shutdown(ctx context.Context) error { s.mu.Lock() diff --git a/server/internal/agent/pirun/supervisor_test.go b/server/internal/agent/pirun/supervisor_test.go index 8f6470d..347db80 100644 --- a/server/internal/agent/pirun/supervisor_test.go +++ b/server/internal/agent/pirun/supervisor_test.go @@ -121,9 +121,9 @@ func waitEvent(t *testing.T, p *Process, d time.Duration) (Event, bool) { func TestEnsureLaunchesAndHandshakes(t *testing.T) { l := &fakeLauncher{} s := NewSupervisor(l, "sk-test") - key := Key{SessionID: "s1", AgentID: "a1"} + key := Key("s1") - p, err := s.Ensure(context.Background(), key, "ws1", "", "") + p, err := s.Ensure(context.Background(), key, "ws1", "") if err != nil { t.Fatalf("Ensure: %v", err) } @@ -144,12 +144,12 @@ func TestEnsureReusesAndIsolatesKeys(t *testing.T) { s := NewSupervisor(l, "") ctx := context.Background() - k1 := Key{SessionID: "s1", AgentID: "a1"} - k2 := Key{SessionID: "s1", AgentID: "a2"} + k1 := Key("s1") + k2 := Key("s2") - p1, _ := s.Ensure(ctx, k1, "ws", "", "") - p1b, _ := s.Ensure(ctx, k1, "ws", "", "") // reuse - p2, _ := s.Ensure(ctx, k2, "ws", "", "") // distinct agent + p1, _ := s.Ensure(ctx, k1, "ws", "") + p1b, _ := s.Ensure(ctx, k1, "ws", "") // reuse + p2, _ := s.Ensure(ctx, k2, "ws", "") // distinct agent if p1 != p1b { t.Error("same key should reuse the same process") @@ -166,9 +166,9 @@ func TestEnsureReusesAndIsolatesKeys(t *testing.T) { func TestEnsureLaunchError(t *testing.T) { l := &fakeLauncher{failNext: errors.New("container unreachable")} s := NewSupervisor(l, "") - key := Key{SessionID: "s1", AgentID: "a1"} + key := Key("s1") - if _, err := s.Ensure(context.Background(), key, "ws", "", ""); err == nil { + if _, err := s.Ensure(context.Background(), key, "ws", ""); err == nil { t.Fatal("expected launch error") } if _, ok := s.Get(key); ok { @@ -179,9 +179,9 @@ func TestEnsureLaunchError(t *testing.T) { func TestProcessExitEmitsSignal(t *testing.T) { l := &fakeLauncher{} s := NewSupervisor(l, "") - key := Key{SessionID: "s1", AgentID: "a1"} + key := Key("s1") - p, err := s.Ensure(context.Background(), key, "ws", "", "") + p, err := s.Ensure(context.Background(), key, "ws", "") if err != nil { t.Fatalf("Ensure: %v", err) } @@ -212,24 +212,32 @@ func TestProcessExitEmitsSignal(t *testing.T) { } } -func TestReattachSendsSwitchSession(t *testing.T) { +func TestStopAndForgetDeregistersSynchronously(t *testing.T) { l := &fakeLauncher{} s := NewSupervisor(l, "") - key := Key{SessionID: "s1", AgentID: "a1"} + key := Key("s1") - if _, err := s.Ensure(context.Background(), key, "ws", "/sessions/prior.jsonl", ""); err != nil { + if _, err := s.Ensure(context.Background(), key, "ws", ""); err != nil { t.Fatalf("Ensure: %v", err) } - m := l.handles[0].waitCmd(t, "switch_session") - if m["sessionPath"] != "/sessions/prior.jsonl" { - t.Errorf("switch_session path = %v, want /sessions/prior.jsonl", m["sessionPath"]) + s.StopAndForget(key) + // The registry entry is gone IMMEDIATELY — before the pump reaps the dying + // process — so a racing Ensure launches fresh instead of adopting it. + if _, ok := s.Get(key); ok { + t.Error("process still registered after StopAndForget") + } + if _, err := s.Ensure(context.Background(), key, "ws", ""); err != nil { + t.Fatalf("re-Ensure after StopAndForget: %v", err) + } + if l.count() != 2 { + t.Errorf("launches = %d, want 2 (fresh process after StopAndForget)", l.count()) } _ = s.Shutdown(context.Background()) } func TestSendUnknownKey(t *testing.T) { s := NewSupervisor(&fakeLauncher{}, "") - if err := s.Send(Key{SessionID: "x", AgentID: "y"}, Abort{}); !errors.Is(err, ErrNoProcess) { + if err := s.Send(Key("x"), Abort{}); !errors.Is(err, ErrNoProcess) { t.Errorf("Send to unknown key = %v, want ErrNoProcess", err) } } @@ -238,8 +246,8 @@ func TestShutdownStopsAllAndRejectsEnsure(t *testing.T) { l := &fakeLauncher{} s := NewSupervisor(l, "") ctx := context.Background() - _, _ = s.Ensure(ctx, Key{SessionID: "s", AgentID: "a"}, "ws", "", "") - _, _ = s.Ensure(ctx, Key{SessionID: "s", AgentID: "b"}, "ws", "", "") + _, _ = s.Ensure(ctx, Key("sa"), "ws", "") + _, _ = s.Ensure(ctx, Key("sb"), "ws", "") if err := s.Shutdown(ctx); err != nil { t.Fatalf("Shutdown: %v", err) @@ -251,7 +259,7 @@ func TestShutdownStopsAllAndRejectsEnsure(t *testing.T) { t.Error("handle was not stopped on shutdown") } } - if _, err := s.Ensure(ctx, Key{SessionID: "s", AgentID: "c"}, "ws", "", ""); !errors.Is(err, ErrSupervisorClosed) { + if _, err := s.Ensure(ctx, Key("sc"), "ws", ""); !errors.Is(err, ErrSupervisorClosed) { t.Errorf("Ensure after shutdown = %v, want ErrSupervisorClosed", err) } } @@ -260,9 +268,9 @@ func TestIdleReap(t *testing.T) { l := &fakeLauncher{} s := NewSupervisor(l, "") s.idleTimeout = 60 * time.Millisecond // shorten for the test (same package) - key := Key{SessionID: "s1", AgentID: "a1"} + key := Key("s1") - if _, err := s.Ensure(context.Background(), key, "ws", "", ""); err != nil { + if _, err := s.Ensure(context.Background(), key, "ws", ""); err != nil { t.Fatalf("Ensure: %v", err) } // No activity → reaped after the idle timeout. diff --git a/server/internal/agent/runtime.go b/server/internal/agent/runtime.go index 581b414..a6b775b 100644 --- a/server/internal/agent/runtime.go +++ b/server/internal/agent/runtime.go @@ -18,22 +18,22 @@ type Broadcaster interface { } // Runtime ties the Pi supervisor, the persistence Store, and the WebSocket -// broadcaster into the Super Threads execution engine. It owns the per-agent +// broadcaster into the Super Threads execution engine. It owns the per-session // serial queue and — per KTD12 — is the single owner of every terminal task // transition and of promotion, including failures sourced from a process exit. type Runtime struct { store Store sup *pirun.Supervisor bc Broadcaster - // baseSystemPrompt is prepended to each agent's own system_prompt at Pi + // baseSystemPrompt is prepended to deuce's configurable system_prompt at Pi // launch — the global "prefer ask_user when blocked" guidance. baseSystemPrompt string - // replyPoster, when set, posts an agent's terminal reply as a normal chat + // replyPoster, when set, posts deuce's terminal reply as a normal chat // message so it shows in the existing chat UI (the Super Threads task/action // cards are a separate, later surface). Wired by the handler layer. - replyPoster func(sessionID, agentID, reply string) + replyPoster func(sessionID, reply string) - keys keyedMutex // per-(session,agent) critical sections (KTD9 TOCTOU) + keys keyedMutex // per-session critical sections (KTD9 TOCTOU) mu sync.Mutex running map[pirun.Key]string // current running/awaiting task id per key @@ -72,12 +72,13 @@ const ( defaultAwaitTimeout = 30 * time.Minute ) -// DefaultBaseSystemPrompt is the global system prompt applied to every agent on -// the Pi path, ahead of the agent's own system_prompt. Its load-bearing job is -// to steer agents to the ask_user tool when blocked on a human decision (which -// is what surfaces the interactive typed prompt) instead of guessing or asking -// in a normal chat reply. Overridable via DEUCE_AGENT_SYSTEM_PROMPT. -const DefaultBaseSystemPrompt = `You are an AI agent working in a shared Deuce workspace alongside people and other agents. Your messages appear in a chat channel. +// DefaultBaseSystemPrompt is the global system prompt applied to the deuce +// agent at Pi launch, ahead of its configurable system_prompt. Its load-bearing +// job is to steer the agent to the ask_user tool when blocked on a human +// decision (which is what surfaces the interactive typed prompt) instead of +// guessing or asking in a normal chat reply. Overridable via +// DEUCE_AGENT_SYSTEM_PROMPT. +const DefaultBaseSystemPrompt = `You are deuce, an AI agent working in a shared Deuce workspace alongside your human teammates. Your messages appear in a chat channel. CRITICAL — how to ask the user something. The ONLY reliable way to get an answer from a human is the ask_user tool. It pauses your run, shows the user a real prompt, and returns their answer to you so you can continue. If you instead write a question as ordinary chat text and end your turn, your run is marked complete — you are NOT waiting, the user is not shown a prompt, and you will not receive a clean answer to act on. A plain-text question is a dead end. @@ -86,8 +87,9 @@ Therefore, whenever you need a decision, clarification, a missing detail (a file Use the kind parameter: "select" with options when the answer is one of a few choices, "confirm" for a yes/no decision, or omit it for a free-text answer. Only ask when you are genuinely blocked — otherwise keep working without asking.` // NewRuntime builds the runtime. Call Start to begin consuming process exits. -// baseSystemPrompt is prepended to every agent's own system_prompt at Pi launch -// (pass DefaultBaseSystemPrompt for the standard guidance, or "" to disable). +// baseSystemPrompt is prepended to deuce's configurable system_prompt at Pi +// launch (pass DefaultBaseSystemPrompt for the standard guidance, or "" to +// disable). func NewRuntime(store Store, sup *pirun.Supervisor, bc Broadcaster, baseSystemPrompt string) *Runtime { ctx, cancel := context.WithCancel(context.Background()) return &Runtime{ @@ -108,7 +110,7 @@ func NewRuntime(store Store, sup *pirun.Supervisor, bc Broadcaster, baseSystemPr } } -// joinSystemPrompts combines the global base prompt with an agent's own +// joinSystemPrompts combines the global base prompt with deuce's configurable // system_prompt, trimming each and separating with a blank line. Either may be // empty, in which case the other is returned (both empty → ""). func joinSystemPrompts(base, agent string) string { @@ -124,10 +126,10 @@ func joinSystemPrompts(base, agent string) string { } } -// SetReplyPoster installs a callback that posts an agent's terminal reply as a +// SetReplyPoster installs a callback that posts deuce's terminal reply as a // chat message. Without it, agent output is only visible via the AgentRunEvent // stream (the Super Threads cards), not in the existing chat. -func (r *Runtime) SetReplyPoster(fn func(sessionID, agentID, reply string)) { +func (r *Runtime) SetReplyPoster(fn func(sessionID, reply string)) { r.replyPoster = fn } @@ -165,8 +167,8 @@ func (r *Runtime) Enqueue(ctx context.Context, p EnqueueParams) (string, error) if err != nil { return "", err } - slog.Info("runtime: task enqueued", "session", p.SessionID, "agent", p.AgentID, "task", taskID, "workspace", p.WorkspaceID) - r.promote(ctx, pirun.Key{SessionID: p.SessionID, AgentID: p.AgentID}) + slog.Info("runtime: task enqueued", "session", p.SessionID, "task", taskID, "workspace", p.WorkspaceID) + r.promote(ctx, pirun.Key(p.SessionID)) return taskID, nil } @@ -177,12 +179,12 @@ func (r *Runtime) createQueued(ctx context.Context, p EnqueueParams) (string, er if err != nil { return "", err } - key := pirun.Key{SessionID: p.SessionID, AgentID: p.AgentID} + key := pirun.Key(p.SessionID) r.mu.Lock() r.workspace[key] = p.WorkspaceID r.mu.Unlock() r.broadcastTask(ws.TypeTaskEnqueued, ws.TaskEventPayload{ - Seq: seq, TaskID: taskID, AgentID: p.AgentID, RequestedBy: p.RequestedBy, + Seq: seq, TaskID: taskID, RequestedBy: p.RequestedBy, AnchorMessageID: p.AnchorMessageID, Prompt: p.Prompt, State: StateQueued, Position: position, }, p.SessionID) return taskID, nil @@ -193,7 +195,7 @@ func (r *Runtime) createQueued(ctx context.Context, p EnqueueParams) (string, er // awaiting_input it answers via extension_ui_response; if merely running it // steers; if idle/terminal it enqueues a new task (R15/R16/R17/R19). func (r *Runtime) RouteOrEnqueue(ctx context.Context, p EnqueueParams) (RouteResult, error) { - key := pirun.Key{SessionID: p.SessionID, AgentID: p.AgentID} + key := pirun.Key(p.SessionID) unlock := r.keys.lock(key) defer unlock() @@ -212,13 +214,13 @@ func (r *Runtime) RouteOrEnqueue(ctx context.Context, p EnqueueParams) (RouteRes // even if the DB resolve below fails (the next event reconciles). r.clearPending(taskID) r.exitAwaiting(key, taskID) - seq, rerr := r.store.ResolveAwaitingInput(ctx, key.SessionID, taskID) + seq, rerr := r.store.ResolveAwaitingInput(ctx, key.SessionID(), taskID) if rerr != nil { slog.Error("runtime: resolve awaiting input", "task", taskID, "error", rerr) } else { r.broadcastTask(ws.TypeTaskStarted, ws.TaskEventPayload{ - Seq: seq, TaskID: taskID, AgentID: key.AgentID, State: StateRunning, - }, key.SessionID) + Seq: seq, TaskID: taskID, State: StateRunning, + }, key.SessionID()) } return RouteFed, nil } @@ -250,13 +252,13 @@ func (r *Runtime) promote(ctx context.Context, key pirun.Key) { // promoteLocked assumes the per-key lock is held. func (r *Runtime) promoteLocked(ctx context.Context, key pirun.Key) { - if _, busy, err := r.store.RunningTask(ctx, key.SessionID, key.AgentID); err != nil { + if _, busy, err := r.store.RunningTask(ctx, key.SessionID()); err != nil { slog.Error("runtime: running-task lookup", "key", key.String(), "error", err) return } else if busy { - return // one running task per key (R11) + return // one running task per session (R11) } - taskID, prompt, ok, err := r.store.NextQueuedTask(ctx, key.SessionID, key.AgentID) + taskID, prompt, ok, err := r.store.NextQueuedTask(ctx, key.SessionID()) if err != nil { slog.Error("runtime: next-queued lookup", "key", key.String(), "error", err) return @@ -265,33 +267,34 @@ func (r *Runtime) promoteLocked(ctx context.Context, key pirun.Key) { return } - seq, err := r.store.MarkRunning(ctx, key.SessionID, taskID) + seq, err := r.store.MarkRunning(ctx, key.SessionID(), taskID) if err != nil { slog.Error("runtime: mark running", "task", taskID, "error", err) return } r.setRunning(key, taskID) r.broadcastTask(ws.TypeTaskStarted, ws.TaskEventPayload{ - Seq: seq, TaskID: taskID, AgentID: key.AgentID, State: StateRunning, - }, key.SessionID) + Seq: seq, TaskID: taskID, State: StateRunning, + }, key.SessionID()) // Ensure the Pi process + its consumer, then send the prompt. Continuity - // across a process restart (pi_session_id re-attach) is a tolerated v1 - // degradation — within a process, sequential tasks share the Pi session. + // across a process restart is a tolerated v1 degradation — within a + // process, sequential tasks share the Pi session. r.mu.Lock() wsID := r.workspace[key] r.mu.Unlock() - // The global guidance plus the agent's own persona/instructions are applied + // The global guidance plus deuce's configurable instructions are applied // to the Pi process at launch (Ensure only launches when no process exists - // for the key, so this is a no-op on reuse). A per-agent lookup failure is + // for the key, so this is a no-op on reuse — a prompt edit takes effect on + // the next launch; see RecycleIdleProcesses). A lookup failure is // non-fatal — fall back to the global base alone rather than failing the task. - agentPrompt, err := r.store.AgentSystemPrompt(ctx, key.AgentID) + deucePrompt, err := r.store.DeuceSystemPrompt(ctx) if err != nil { - slog.Warn("runtime: agent system prompt lookup failed", "key", key.String(), "error", err) - agentPrompt = "" + slog.Warn("runtime: deuce system prompt lookup failed", "key", key.String(), "error", err) + deucePrompt = "" } - systemPrompt := joinSystemPrompts(r.baseSystemPrompt, agentPrompt) - p, err := r.sup.Ensure(ctx, key, wsID, "", systemPrompt) + systemPrompt := joinSystemPrompts(r.baseSystemPrompt, deucePrompt) + p, err := r.sup.Ensure(ctx, key, wsID, systemPrompt) if err != nil { slog.Error("runtime: ensure pi process", "key", key.String(), "error", err) // promote=false: we are inside promoteLocked, don't recurse. teardown=true @@ -353,27 +356,27 @@ func (r *Runtime) translate(key pirun.Key, ev pirun.Event) { ctx := r.ctx switch ev.Kind { case pirun.KindToolStarted: - seq, err := r.store.AppendActionStarted(ctx, key.SessionID, taskID, ev.ToolCallID, ev.Tool, ev.Arg) + seq, err := r.store.AppendActionStarted(ctx, key.SessionID(), taskID, ev.ToolCallID, ev.Tool, ev.Arg) if err != nil { slog.Error("runtime: append action", "task", taskID, "error", err) return } r.broadcastAction(ws.TypeActionStarted, ws.ActionEventPayload{ - Seq: seq, TaskID: taskID, AgentID: key.AgentID, CallID: ev.ToolCallID, Tool: ev.Tool, Arg: ev.Arg, - }, key.SessionID) + Seq: seq, TaskID: taskID, CallID: ev.ToolCallID, Tool: ev.Tool, Arg: ev.Arg, + }, key.SessionID()) case pirun.KindToolCompleted: - seq, err := r.store.CompleteAction(ctx, key.SessionID, taskID, ev.ToolCallID, ev.Output, ev.IsError) + seq, err := r.store.CompleteAction(ctx, key.SessionID(), taskID, ev.ToolCallID, ev.Output, ev.IsError) if err != nil { slog.Error("runtime: complete action", "task", taskID, "error", err) return } r.broadcastAction(ws.TypeActionCompleted, ws.ActionEventPayload{ - Seq: seq, TaskID: taskID, AgentID: key.AgentID, CallID: ev.ToolCallID, Tool: ev.Tool, Text: ev.Output, IsError: ev.IsError, - }, key.SessionID) + Seq: seq, TaskID: taskID, CallID: ev.ToolCallID, Tool: ev.Tool, Text: ev.Output, IsError: ev.IsError, + }, key.SessionID()) case pirun.KindAssistantText: r.appendReply(taskID, ev.Text) case pirun.KindAwaitingInput: - seq, err := r.store.SetAwaitingInput(ctx, key.SessionID, taskID, ev.Prompt, ev.RequestKind, ev.Options) + seq, err := r.store.SetAwaitingInput(ctx, key.SessionID(), taskID, ev.Prompt, ev.RequestKind, ev.Options) if err != nil { slog.Error("runtime: set awaiting input", "task", taskID, "error", err) return @@ -381,9 +384,9 @@ func (r *Runtime) translate(key pirun.Key, ev pirun.Event) { r.setPending(taskID, ev.RequestID) r.enterAwaiting(key, taskID) // suspend active timeout, start ceiling (KTD8) r.broadcastTask(ws.TypeTaskAwaitingInput, ws.TaskEventPayload{ - Seq: seq, TaskID: taskID, AgentID: key.AgentID, State: StateAwaitingInput, + Seq: seq, TaskID: taskID, State: StateAwaitingInput, PendingQuestion: ev.Prompt, PendingQuestionKind: ev.RequestKind, PendingQuestionOptions: ev.Options, - }, key.SessionID) + }, key.SessionID()) case pirun.KindRunCompleted: unlock := r.keys.lock(key) // Pi finished cleanly: reuse the live process for the next task (promote @@ -439,7 +442,7 @@ func (r *Runtime) finalizeLocked(ctx context.Context, key pirun.Key, taskID, sta // (the ask-user extension didn't fire), rewrite it to the plain question // before it is persisted, broadcast, and posted to chat (R9/R11/R12). reply = sanitizeNarratedQuestion(reply) - seq, err := r.store.FinishTask(ctx, key.SessionID, taskID, state, reply) + seq, err := r.store.FinishTask(ctx, key.SessionID(), taskID, state, reply) if err != nil { slog.Error("runtime: finish task", "task", taskID, "state", state, "error", err) return @@ -454,8 +457,8 @@ func (r *Runtime) finalizeLocked(ctx context.Context, key pirun.Key, taskID, sta r.promoteLocked(ctx, key) } r.broadcastTask(ws.TypeTaskCompleted, ws.TaskEventPayload{ - Seq: seq, TaskID: taskID, AgentID: key.AgentID, State: state, Status: state, Reply: reply, - }, key.SessionID) + Seq: seq, TaskID: taskID, State: state, Status: state, Reply: reply, + }, key.SessionID()) slog.Info("runtime: task finalized", "key", key.String(), "task", taskID, "state", state, "replyLen", len(reply)) // Surface the result in the existing chat. For a done task with no streamed // text (e.g. the model produced only tool calls, or the run errored without @@ -466,39 +469,65 @@ func (r *Runtime) finalizeLocked(ctx context.Context, key pirun.Key, taskID, sta msg = "(The agent finished without a text response.)" } if msg != "" { - r.replyPoster(key.SessionID, key.AgentID, msg) + r.replyPoster(key.SessionID(), msg) } } } -// CancelSession cancels every running task in a session (agent-less /stop, R21). +// CancelSession cancels the session's running task AND its queued tasks (R6: +// /stop or the Stop button halts everything; a queued task must not be +// promoted seconds after the user asked for quiet). func (r *Runtime) CancelSession(ctx context.Context, sessionID string) { - r.mu.Lock() - var keys []pirun.Key - for k := range r.running { - if k.SessionID == sessionID { - keys = append(keys, k) - } - } - r.mu.Unlock() - for _, k := range keys { - r.Cancel(ctx, k) - } -} - -// Cancel cancels the running task for a key (/stop targeting an agent, R21). It -// tears down the task's Pi process (teardown=true); the resulting process-exit -// promotes the next queued task onto a fresh process — avoiding the bug where -// promoting inline would launch the next task onto the process we then kill. -func (r *Runtime) Cancel(ctx context.Context, key pirun.Key) { + key := pirun.Key(sessionID) unlock := r.keys.lock(key) defer unlock() + r.cancelQueuedLocked(ctx, key) taskID, ok := r.currentTask(key) if ok { + // Teardown=true: the resulting process-exit would promote the next + // queued task onto a fresh process — but the queue was just drained. r.finalizeLocked(ctx, key, taskID, StateCancelled, "Cancelled by user.", false, true) } } +// cancelQueuedLocked finalizes every queued task in the session as cancelled, +// broadcasting each task_completed. No replyPoster — one "Cancelled by user." +// chat notice for the running task is enough; queued-card state changes carry +// the rest. Assumes the per-key lock is held. +func (r *Runtime) cancelQueuedLocked(ctx context.Context, key pirun.Key) { + ids, err := r.store.QueuedTaskIDs(ctx, key.SessionID()) + if err != nil { + slog.Error("runtime: queued-task lookup", "key", key.String(), "error", err) + return + } + for _, taskID := range ids { + seq, err := r.store.FinishTask(ctx, key.SessionID(), taskID, StateCancelled, "") + if err != nil { + slog.Error("runtime: cancel queued task", "task", taskID, "error", err) + continue + } + r.broadcastTask(ws.TypeTaskCompleted, ws.TaskEventPayload{ + Seq: seq, TaskID: taskID, State: StateCancelled, Status: StateCancelled, + }, key.SessionID()) + } +} + +// RecycleIdleProcesses stops the Pi process of every session with no running or +// awaiting_input task, so the next task launches with the freshly-saved system +// prompt (R7). Sessions mid-task keep their process and pick the new prompt up +// on their next process launch. StopAndForget deregisters synchronously under +// the per-session lock, so an enqueue racing the recycle launches a fresh +// process instead of adopting the dying one. +func (r *Runtime) RecycleIdleProcesses() { + for _, key := range r.sup.LiveKeys() { + unlock := r.keys.lock(key) + if _, busy := r.currentTask(key); !busy { + r.sup.StopAndForget(key) + } + unlock() + } +} + // --- small state helpers (guarded by r.mu) --- func (r *Runtime) setRunning(key pirun.Key, taskID string) { diff --git a/server/internal/agent/runtime_test.go b/server/internal/agent/runtime_test.go index 1f3bd04..dce1a14 100644 --- a/server/internal/agent/runtime_test.go +++ b/server/internal/agent/runtime_test.go @@ -17,8 +17,8 @@ import ( // --- fake Store ------------------------------------------------------------- type fakeTask struct { - id, sessionID, agentID, state, prompt, reply string - order int + id, sessionID, state, prompt, reply string + order int } type fakeStore struct { @@ -45,10 +45,10 @@ func (s *fakeStore) CreateQueuedTask(_ context.Context, p EnqueueParams) (string s.idn++ id := fmt.Sprintf("task-%d", s.idn) s.order++ - s.tasks[id] = &fakeTask{id: id, sessionID: p.SessionID, agentID: p.AgentID, state: StateQueued, prompt: p.Prompt, order: s.order} + s.tasks[id] = &fakeTask{id: id, sessionID: p.SessionID, state: StateQueued, prompt: p.Prompt, order: s.order} pos := 0 for _, t := range s.tasks { - if t.sessionID == p.SessionID && t.agentID == p.AgentID && t.state == StateQueued { + if t.sessionID == p.SessionID && t.state == StateQueued { pos++ } } @@ -70,7 +70,7 @@ func (s *fakeStore) MarkRunning(_ context.Context, sessionID, taskID string) (in func (s *fakeStore) SetAwaitingInput(_ context.Context, sessionID, taskID, _, _ string, _ []string) (int64, error) { return s.setState(sessionID, taskID, StateAwaitingInput), nil } -func (s *fakeStore) AgentSystemPrompt(_ context.Context, _ string) (string, error) { +func (s *fakeStore) DeuceSystemPrompt(_ context.Context) (string, error) { return s.systemPrompt, nil } func (s *fakeStore) ResolveAwaitingInput(_ context.Context, sessionID, taskID string) (int64, error) { @@ -97,21 +97,47 @@ func (s *fakeStore) CompleteAction(_ context.Context, sessionID, _, _, _ string, return s.nextSeq(sessionID), nil } -func (s *fakeStore) RunningTask(_ context.Context, sessionID, agentID string) (string, bool, error) { +func (s *fakeStore) RunningTask(_ context.Context, sessionID string) (string, bool, error) { s.mu.Lock() defer s.mu.Unlock() - best := s.firstByOrder(sessionID, agentID, func(st string) bool { return st == StateRunning || st == StateAwaitingInput }) + best := s.firstByOrder(sessionID, func(st string) bool { return st == StateRunning || st == StateAwaitingInput }) return best, best != "", nil } -func (s *fakeStore) NextQueuedTask(_ context.Context, sessionID, agentID string) (string, string, bool, error) { +func (s *fakeStore) NextQueuedTask(_ context.Context, sessionID string) (string, string, bool, error) { s.mu.Lock() defer s.mu.Unlock() - id := s.firstByOrder(sessionID, agentID, func(st string) bool { return st == StateQueued }) + id := s.firstByOrder(sessionID, func(st string) bool { return st == StateQueued }) if id == "" { return "", "", false, nil } return id, s.tasks[id].prompt, true, nil } +func (s *fakeStore) QueuedTaskIDs(_ context.Context, sessionID string) ([]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + type ot struct { + id string + order int + } + var queued []ot + for _, t := range s.tasks { + if t.sessionID == sessionID && t.state == StateQueued { + queued = append(queued, ot{t.id, t.order}) + } + } + ids := make([]string, 0, len(queued)) + for len(queued) > 0 { + bi := 0 + for i := range queued { + if queued[i].order < queued[bi].order { + bi = i + } + } + ids = append(ids, queued[bi].id) + queued = append(queued[:bi], queued[bi+1:]...) + } + return ids, nil +} func (s *fakeStore) TaskState(_ context.Context, taskID string) (string, bool, error) { s.mu.Lock() defer s.mu.Unlock() @@ -121,13 +147,13 @@ func (s *fakeStore) TaskState(_ context.Context, taskID string) (string, bool, e return "", false, nil } -// firstByOrder returns the lowest-order task for the key matching pred. Caller +// firstByOrder returns the session's lowest-order task matching pred. Caller // holds s.mu. -func (s *fakeStore) firstByOrder(sessionID, agentID string, pred func(string) bool) string { +func (s *fakeStore) firstByOrder(sessionID string, pred func(string) bool) string { best := "" bestOrder := 1 << 30 for _, t := range s.tasks { - if t.sessionID == sessionID && t.agentID == agentID && pred(t.state) && t.order < bestOrder { + if t.sessionID == sessionID && pred(t.state) && t.order < bestOrder { best, bestOrder = t.id, t.order } } @@ -315,7 +341,7 @@ func TestEnqueueRunsAndCompletes(t *testing.T) { rt, store, bc, lr := newTestRuntime(t) ctx := context.Background() - taskID, err := rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", AgentID: "a1", Prompt: "do it", WorkspaceID: "ws"}) + taskID, err := rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", Prompt: "do it", WorkspaceID: "ws"}) if err != nil { t.Fatalf("Enqueue: %v", err) } @@ -347,9 +373,9 @@ func TestSecondMentionQueuesThenPromotes(t *testing.T) { rt, store, bc, lr := newTestRuntime(t) ctx := context.Background() - t1, _ := rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", AgentID: "a1", Prompt: "first", WorkspaceID: "ws"}) + t1, _ := rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", Prompt: "first", WorkspaceID: "ws"}) bc.waitFor(t, ws.TypeTaskStarted, 1) - t2, _ := rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", AgentID: "a1", Prompt: "second", WorkspaceID: "ws"}) + t2, _ := rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", Prompt: "second", WorkspaceID: "ws"}) // t2 must stay queued while t1 runs (one running per key). if store.state(t2) != StateQueued { @@ -367,21 +393,24 @@ func TestSecondMentionQueuesThenPromotes(t *testing.T) { } } -func TestConcurrentAgentsRunInParallel(t *testing.T) { - rt, store, bc, _ := newTestRuntime(t) +func TestConcurrentSessionsRunInParallel(t *testing.T) { + rt, store, bc, lr := newTestRuntime(t) ctx := context.Background() - ca, _ := rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", AgentID: "coder", Prompt: "x", WorkspaceID: "ws"}) - ra, _ := rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", AgentID: "reviewer", Prompt: "y", WorkspaceID: "ws"}) + a, _ := rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", Prompt: "x", WorkspaceID: "ws1"}) + b, _ := rt.Enqueue(ctx, EnqueueParams{SessionID: "s2", Prompt: "y", WorkspaceID: "ws2"}) bc.waitFor(t, ws.TypeTaskStarted, 2) - if store.state(ca) != StateRunning || store.state(ra) != StateRunning { - t.Errorf("both agents should run concurrently: coder=%q reviewer=%q", store.state(ca), store.state(ra)) + if store.state(a) != StateRunning || store.state(b) != StateRunning { + t.Errorf("both sessions should run concurrently: s1=%q s2=%q", store.state(a), store.state(b)) + } + if lr.count() != 2 { + t.Errorf("expected one Pi process per session, launches = %d", lr.count()) } } func TestProcessExitFailsRunningTask(t *testing.T) { rt, store, bc, lr := newTestRuntime(t) ctx := context.Background() - task, _ := rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", AgentID: "a1", Prompt: "x", WorkspaceID: "ws"}) + task, _ := rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", Prompt: "x", WorkspaceID: "ws"}) bc.waitFor(t, ws.TypeTaskStarted, 1) _ = lr.handle(t, 0).Stop(ctx) // process dies @@ -394,7 +423,7 @@ func TestProcessExitFailsRunningTask(t *testing.T) { func TestTerminalIsIdempotent(t *testing.T) { rt, store, bc, lr := newTestRuntime(t) ctx := context.Background() - task, _ := rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", AgentID: "a1", Prompt: "x", WorkspaceID: "ws"}) + task, _ := rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", Prompt: "x", WorkspaceID: "ws"}) bc.waitFor(t, ws.TypeTaskStarted, 1) h := lr.handle(t, 0) @@ -416,10 +445,10 @@ func TestReplyPosterReceivesReply(t *testing.T) { rt, _, bc, lr := newTestRuntime(t) var mu sync.Mutex var got string - rt.SetReplyPoster(func(_, _, reply string) { mu.Lock(); got = reply; mu.Unlock() }) + rt.SetReplyPoster(func(_, reply string) { mu.Lock(); got = reply; mu.Unlock() }) ctx := context.Background() - rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", AgentID: "a1", Prompt: "hi", WorkspaceID: "ws"}) + rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", Prompt: "hi", WorkspaceID: "ws"}) bc.waitFor(t, ws.TypeTaskStarted, 1) h := lr.handle(t, 0) h.push(`{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"hello there"}}`) @@ -442,39 +471,81 @@ func TestReplyPosterReceivesReply(t *testing.T) { } } -func TestCancelPromotesNextOntoFreshProcess(t *testing.T) { - rt, store, bc, lr := newTestRuntime(t) +func TestCancelSessionCancelsRunningAndQueued(t *testing.T) { + rt, store, bc, _ := newTestRuntime(t) ctx := context.Background() - t1, _ := rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", AgentID: "a1", Prompt: "first", WorkspaceID: "ws"}) + t1, _ := rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", Prompt: "first", WorkspaceID: "ws"}) bc.waitFor(t, ws.TypeTaskStarted, 1) - t2, _ := rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", AgentID: "a1", Prompt: "second", WorkspaceID: "ws"}) + t2, _ := rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", Prompt: "second", WorkspaceID: "ws"}) if store.state(t2) != StateQueued { t.Fatalf("t2 state = %q, want queued", store.state(t2)) } - // /stop the agent: t1 is cancelled, its process torn down, and t2 promoted - // onto a FRESH process (not the one being killed — the bug this guards). - rt.Cancel(ctx, pirun.Key{SessionID: "s1", AgentID: "a1"}) - bc.waitFor(t, ws.TypeTaskStarted, 2) + // /stop: the queued task is drained AND the running task cancelled (R6) — + // nothing is promoted seconds after the user asked for quiet. + rt.CancelSession(ctx, "s1") + bc.waitFor(t, ws.TypeTaskCompleted, 2) if store.state(t1) != StateCancelled { t.Errorf("t1 state = %q, want cancelled", store.state(t1)) } + if store.state(t2) != StateCancelled { + t.Errorf("t2 state = %q, want cancelled (queue drained by /stop)", store.state(t2)) + } + // Give any stray promotion a moment, then confirm nothing restarted. + time.Sleep(150 * time.Millisecond) + if n := bc.count(ws.TypeTaskStarted); n != 1 { + t.Errorf("task_started broadcast %d times, want exactly 1 (no post-cancel promotion)", n) + } +} + +func TestRecycleIdleStopsOnlyIdleSessions(t *testing.T) { + rt, store, bc, lr := newTestRuntime(t) + ctx := context.Background() + + // s1 finishes a task cleanly — its process stays alive (reuse) but idle. + rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", Prompt: "a", WorkspaceID: "ws1"}) + bc.waitFor(t, ws.TypeTaskStarted, 1) + lr.handle(t, 0).push(`{"type":"agent_end"}`) + bc.waitFor(t, ws.TypeTaskCompleted, 1) + + // s2 is mid-task (running). + t2, _ := rt.Enqueue(ctx, EnqueueParams{SessionID: "s2", Prompt: "b", WorkspaceID: "ws2"}) + bc.waitFor(t, ws.TypeTaskStarted, 2) + + // s3 is blocked on a question (awaiting_input) — busy, must NOT recycle. + t3, _ := rt.Enqueue(ctx, EnqueueParams{SessionID: "s3", Prompt: "c", WorkspaceID: "ws3"}) + bc.waitFor(t, ws.TypeTaskStarted, 3) + lr.handle(t, 2).push(`{"type":"extension_ui_request","id":"ui-1","params":{"prompt":"?"}}`) + bc.waitFor(t, ws.TypeTaskAwaitingInput, 1) + + rt.RecycleIdleProcesses() + + // Busy sessions keep their tasks live. + time.Sleep(150 * time.Millisecond) if store.state(t2) != StateRunning { - t.Errorf("t2 state = %q, want running after cancel-promote", store.state(t2)) + t.Errorf("s2 task state = %q, want running (busy session must not recycle)", store.state(t2)) + } + if store.state(t3) != StateAwaitingInput { + t.Errorf("s3 task state = %q, want awaiting_input (awaiting session must not recycle)", store.state(t3)) } - if lr.count() < 2 { - t.Errorf("expected a fresh process launched for t2, launches = %d", lr.count()) + // s1's idle process was deregistered synchronously: a new enqueue launches + // a FRESH process (the recycle race guard), picking up the new prompt. + launchesBefore := lr.count() + rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", Prompt: "again", WorkspaceID: "ws1"}) + bc.waitFor(t, ws.TypeTaskStarted, 4) + if lr.count() != launchesBefore+1 { + t.Errorf("expected a fresh launch for s1 after recycle, launches = %d (was %d)", lr.count(), launchesBefore) } } func TestRouteFeedsRunningRun(t *testing.T) { rt, _, bc, lr := newTestRuntime(t) ctx := context.Background() - rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", AgentID: "a1", Prompt: "go", WorkspaceID: "ws"}) + rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", Prompt: "go", WorkspaceID: "ws"}) bc.waitFor(t, ws.TypeTaskStarted, 1) - res, err := rt.RouteOrEnqueue(ctx, EnqueueParams{SessionID: "s1", AgentID: "a1", Prompt: "use staging"}) + res, err := rt.RouteOrEnqueue(ctx, EnqueueParams{SessionID: "s1", Prompt: "use staging"}) if err != nil || res != RouteFed { t.Fatalf("RouteOrEnqueue = (%v,%v), want (RouteFed,nil)", res, err) } @@ -487,7 +558,7 @@ func TestRouteFeedsRunningRun(t *testing.T) { func TestRouteAnswersAwaitingInput(t *testing.T) { rt, store, bc, lr := newTestRuntime(t) ctx := context.Background() - task, _ := rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", AgentID: "a1", Prompt: "go", WorkspaceID: "ws"}) + task, _ := rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", Prompt: "go", WorkspaceID: "ws"}) bc.waitFor(t, ws.TypeTaskStarted, 1) h := lr.handle(t, 0) @@ -497,7 +568,7 @@ func TestRouteAnswersAwaitingInput(t *testing.T) { t.Fatalf("state = %q, want awaiting_input", store.state(task)) } - res, err := rt.RouteOrEnqueue(ctx, EnqueueParams{SessionID: "s1", AgentID: "a1", Prompt: "prod"}) + res, err := rt.RouteOrEnqueue(ctx, EnqueueParams{SessionID: "s1", Prompt: "prod"}) if err != nil || res != RouteFed { t.Fatalf("RouteOrEnqueue = (%v,%v), want RouteFed", res, err) } @@ -513,13 +584,13 @@ func TestRouteAnswersAwaitingInput(t *testing.T) { func TestRouteEnqueuesWhenIdle(t *testing.T) { rt, store, bc, _ := newTestRuntime(t) ctx := context.Background() - res, err := rt.RouteOrEnqueue(ctx, EnqueueParams{SessionID: "s1", AgentID: "a1", Prompt: "brand new", WorkspaceID: "ws"}) + res, err := rt.RouteOrEnqueue(ctx, EnqueueParams{SessionID: "s1", Prompt: "brand new", WorkspaceID: "ws"}) if err != nil || res != RouteEnqueued { t.Fatalf("RouteOrEnqueue idle = (%v,%v), want RouteEnqueued", res, err) } bc.waitFor(t, ws.TypeTaskStarted, 1) // Exactly one task exists and it is running (promoted because idle). - running, ok, _ := store.RunningTask(ctx, "s1", "a1") + running, ok, _ := store.RunningTask(ctx, "s1") if !ok || store.state(running) != StateRunning { t.Errorf("expected a running task after idle enqueue") } @@ -529,7 +600,7 @@ func TestAwaitingCeilingFailsTask(t *testing.T) { rt, store, bc, lr := newTestRuntime(t) rt.awaitTimeout = 60 * time.Millisecond // ceiling for the test (same package) ctx := context.Background() - task, _ := rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", AgentID: "a1", Prompt: "go", WorkspaceID: "ws"}) + task, _ := rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", Prompt: "go", WorkspaceID: "ws"}) bc.waitFor(t, ws.TypeTaskStarted, 1) lr.handle(t, 0).push(`{"type":"extension_ui_request","id":"ui-1","params":{"prompt":"?"}}`) diff --git a/server/internal/agent/store.go b/server/internal/agent/store.go index 661a3c7..2082d4b 100644 --- a/server/internal/agent/store.go +++ b/server/internal/agent/store.go @@ -39,25 +39,34 @@ type Store interface { // reply, force-resolves any still-open actions, and returns the event seq. FinishTask(ctx context.Context, sessionID, taskID, state, reply string) (seq int64, err error) - // RunningTask returns the running (or awaiting_input) task for a key, if any. - RunningTask(ctx context.Context, sessionID, agentID string) (taskID string, ok bool, err error) + // RunningTask returns the session's running (or awaiting_input) task, if any. + RunningTask(ctx context.Context, sessionID string) (taskID string, ok bool, err error) - // NextQueuedTask returns the oldest queued task for a key, if any. - NextQueuedTask(ctx context.Context, sessionID, agentID string) (taskID, prompt string, ok bool, err error) + // NextQueuedTask returns the session's oldest queued task, if any. + NextQueuedTask(ctx context.Context, sessionID string) (taskID, prompt string, ok bool, err error) + + // QueuedTaskIDs returns every queued task in the session, oldest first. + // Backs CancelSession's queue drain (R6). + QueuedTaskIDs(ctx context.Context, sessionID string) ([]string, error) // TaskState returns the current state of a task. TaskState(ctx context.Context, taskID string) (state string, ok bool, err error) - // AgentSystemPrompt returns the agent's configured system prompt (empty - // when unset). Applied to the Pi process at launch so the agent carries its - // persona/instructions (the legacy executor did this via --append-system-prompt). - AgentSystemPrompt(ctx context.Context, agentID string) (string, error) + // DeuceSystemPrompt returns deuce's configured system prompt (empty when + // unset). Applied to the Pi process at launch via --append-system-prompt. + DeuceSystemPrompt(ctx context.Context) (string, error) } +// DeuceAgentID is the fixed UUID of the single built-in deuce agent. It MUST +// match the row seeded by migration 013_single_deuce_agent.sql and the DEUCE +// constant in src/lib/deuce.ts — message authorship, the chat visibility +// filter, and the migration's historical repoint all pin to it. The nil UUID +// stays reserved as the system-notice author sentinel. +const DeuceAgentID = "00000000-0000-0000-0000-00000000000d" + // EnqueueParams describes a new task to enqueue. type EnqueueParams struct { SessionID string - AgentID string RequestedBy string AnchorMessageID string Prompt string diff --git a/server/internal/db/activities.sql.go b/server/internal/db/activities.sql.go index 01e6dd4..7b7d513 100644 --- a/server/internal/db/activities.sql.go +++ b/server/internal/db/activities.sql.go @@ -9,21 +9,19 @@ import ( "context" "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" ) const createActivity = `-- name: CreateActivity :one -INSERT INTO activity_items (session_id, type, description, agent_id, metadata) -VALUES ($1, $2, $3, $4, $5) -RETURNING id, session_id, type, description, agent_id, metadata, created_at +INSERT INTO activity_items (session_id, type, description, metadata) +VALUES ($1, $2, $3, $4) +RETURNING id, session_id, type, description, metadata, created_at ` type CreateActivityParams struct { - SessionID uuid.UUID `json:"session_id"` - Type string `json:"type"` - Description string `json:"description"` - AgentID pgtype.UUID `json:"agent_id"` - Metadata []byte `json:"metadata"` + SessionID uuid.UUID `json:"session_id"` + Type string `json:"type"` + Description string `json:"description"` + Metadata []byte `json:"metadata"` } func (q *Queries) CreateActivity(ctx context.Context, arg CreateActivityParams) (ActivityItem, error) { @@ -31,7 +29,6 @@ func (q *Queries) CreateActivity(ctx context.Context, arg CreateActivityParams) arg.SessionID, arg.Type, arg.Description, - arg.AgentID, arg.Metadata, ) var i ActivityItem @@ -40,7 +37,6 @@ func (q *Queries) CreateActivity(ctx context.Context, arg CreateActivityParams) &i.SessionID, &i.Type, &i.Description, - &i.AgentID, &i.Metadata, &i.CreatedAt, ) @@ -48,7 +44,7 @@ func (q *Queries) CreateActivity(ctx context.Context, arg CreateActivityParams) } const listActivities = `-- name: ListActivities :many -SELECT id, session_id, type, description, agent_id, metadata, created_at FROM activity_items +SELECT id, session_id, type, description, metadata, created_at FROM activity_items WHERE session_id = $1 ORDER BY created_at DESC LIMIT $2 @@ -73,7 +69,6 @@ func (q *Queries) ListActivities(ctx context.Context, arg ListActivitiesParams) &i.SessionID, &i.Type, &i.Description, - &i.AgentID, &i.Metadata, &i.CreatedAt, ); err != nil { diff --git a/server/internal/db/agents.sql.go b/server/internal/db/agents.sql.go index dc15dab..7ca7efa 100644 --- a/server/internal/db/agents.sql.go +++ b/server/internal/db/agents.sql.go @@ -7,173 +7,29 @@ package db import ( "context" - - "github.com/google/uuid" ) -const createAgent = `-- name: CreateAgent :one -INSERT INTO agents (name, role, color, color_muted, provider, model, description, system_prompt) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -RETURNING id, name, role, color, color_muted, provider, model, description, system_prompt, deleted_at, created_at, updated_at -` - -type CreateAgentParams struct { - Name string `json:"name"` - Role string `json:"role"` - Color string `json:"color"` - ColorMuted string `json:"color_muted"` - Provider string `json:"provider"` - Model string `json:"model"` - Description string `json:"description"` - SystemPrompt string `json:"system_prompt"` -} - -func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent, error) { - row := q.db.QueryRow(ctx, createAgent, - arg.Name, - arg.Role, - arg.Color, - arg.ColorMuted, - arg.Provider, - arg.Model, - arg.Description, - arg.SystemPrompt, - ) - var i Agent - err := row.Scan( - &i.ID, - &i.Name, - &i.Role, - &i.Color, - &i.ColorMuted, - &i.Provider, - &i.Model, - &i.Description, - &i.SystemPrompt, - &i.DeletedAt, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} - -const getAgent = `-- name: GetAgent :one -SELECT id, name, role, color, color_muted, provider, model, description, system_prompt, deleted_at, created_at, updated_at FROM agents WHERE id = $1 +const getDeuceAgent = `-- name: GetDeuceAgent :one +SELECT id, name, system_prompt FROM agents LIMIT 1 ` -func (q *Queries) GetAgent(ctx context.Context, id uuid.UUID) (Agent, error) { - row := q.db.QueryRow(ctx, getAgent, id) +// The agents table holds exactly one row — the built-in "deuce" agent +// (migration 013). Single-row read backs GET /api/agent and the runtime's +// launch-time system-prompt fetch. +func (q *Queries) GetDeuceAgent(ctx context.Context) (Agent, error) { + row := q.db.QueryRow(ctx, getDeuceAgent) var i Agent - err := row.Scan( - &i.ID, - &i.Name, - &i.Role, - &i.Color, - &i.ColorMuted, - &i.Provider, - &i.Model, - &i.Description, - &i.SystemPrompt, - &i.DeletedAt, - &i.CreatedAt, - &i.UpdatedAt, - ) + err := row.Scan(&i.ID, &i.Name, &i.SystemPrompt) return i, err } -const listAgents = `-- name: ListAgents :many -SELECT id, name, role, color, color_muted, provider, model, description, system_prompt, deleted_at, created_at, updated_at FROM agents WHERE deleted_at IS NULL ORDER BY name +const updateDeuceSystemPrompt = `-- name: UpdateDeuceSystemPrompt :one +UPDATE agents SET system_prompt = $1 RETURNING id, name, system_prompt ` -func (q *Queries) ListAgents(ctx context.Context) ([]Agent, error) { - rows, err := q.db.Query(ctx, listAgents) - if err != nil { - return nil, err - } - defer rows.Close() - items := []Agent{} - for rows.Next() { - var i Agent - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Role, - &i.Color, - &i.ColorMuted, - &i.Provider, - &i.Model, - &i.Description, - &i.SystemPrompt, - &i.DeletedAt, - &i.CreatedAt, - &i.UpdatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const softDeleteAgent = `-- name: SoftDeleteAgent :exec -UPDATE agents SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL -` - -func (q *Queries) SoftDeleteAgent(ctx context.Context, id uuid.UUID) error { - _, err := q.db.Exec(ctx, softDeleteAgent, id) - return err -} - -const updateAgent = `-- name: UpdateAgent :one -UPDATE agents -SET name = $2, - role = $3, - provider = $4, - model = $5, - description = $6, - system_prompt = $7, - updated_at = now() -WHERE id = $1 AND deleted_at IS NULL -RETURNING id, name, role, color, color_muted, provider, model, description, system_prompt, deleted_at, created_at, updated_at -` - -type UpdateAgentParams struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Role string `json:"role"` - Provider string `json:"provider"` - Model string `json:"model"` - Description string `json:"description"` - SystemPrompt string `json:"system_prompt"` -} - -func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent, error) { - row := q.db.QueryRow(ctx, updateAgent, - arg.ID, - arg.Name, - arg.Role, - arg.Provider, - arg.Model, - arg.Description, - arg.SystemPrompt, - ) +func (q *Queries) UpdateDeuceSystemPrompt(ctx context.Context, systemPrompt string) (Agent, error) { + row := q.db.QueryRow(ctx, updateDeuceSystemPrompt, systemPrompt) var i Agent - err := row.Scan( - &i.ID, - &i.Name, - &i.Role, - &i.Color, - &i.ColorMuted, - &i.Provider, - &i.Model, - &i.Description, - &i.SystemPrompt, - &i.DeletedAt, - &i.CreatedAt, - &i.UpdatedAt, - ) + err := row.Scan(&i.ID, &i.Name, &i.SystemPrompt) return i, err } diff --git a/server/internal/db/messages.sql.go b/server/internal/db/messages.sql.go index 15c9740..c476a30 100644 --- a/server/internal/db/messages.sql.go +++ b/server/internal/db/messages.sql.go @@ -12,9 +12,9 @@ import ( ) const createMessage = `-- name: CreateMessage :one -INSERT INTO messages (session_id, author_id, author_type, content, expandable_content, mentions, status) -VALUES ($1, $2, $3, $4, $5, $6, $7) -RETURNING id, session_id, author_id, author_type, content, expandable_content, mentions, status, created_at +INSERT INTO messages (session_id, author_id, author_type, content, expandable_content, status) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, session_id, author_id, author_type, content, expandable_content, status, created_at ` type CreateMessageParams struct { @@ -23,7 +23,6 @@ type CreateMessageParams struct { AuthorType string `json:"author_type"` Content string `json:"content"` ExpandableContent []byte `json:"expandable_content"` - Mentions []string `json:"mentions"` Status string `json:"status"` } @@ -34,7 +33,6 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (M arg.AuthorType, arg.Content, arg.ExpandableContent, - arg.Mentions, arg.Status, ) var i Message @@ -45,7 +43,6 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (M &i.AuthorType, &i.Content, &i.ExpandableContent, - &i.Mentions, &i.Status, &i.CreatedAt, ) @@ -53,7 +50,7 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (M } const listMessages = `-- name: ListMessages :many -SELECT id, session_id, author_id, author_type, content, expandable_content, mentions, status, created_at FROM messages +SELECT id, session_id, author_id, author_type, content, expandable_content, status, created_at FROM messages WHERE session_id = $1 ORDER BY created_at DESC LIMIT $2 @@ -80,7 +77,6 @@ func (q *Queries) ListMessages(ctx context.Context, arg ListMessagesParams) ([]M &i.AuthorType, &i.Content, &i.ExpandableContent, - &i.Mentions, &i.Status, &i.CreatedAt, ); err != nil { @@ -95,7 +91,7 @@ func (q *Queries) ListMessages(ctx context.Context, arg ListMessagesParams) ([]M } const listMessagesBefore = `-- name: ListMessagesBefore :many -SELECT messages.id, messages.session_id, messages.author_id, messages.author_type, messages.content, messages.expandable_content, messages.mentions, messages.status, messages.created_at FROM messages +SELECT messages.id, messages.session_id, messages.author_id, messages.author_type, messages.content, messages.expandable_content, messages.status, messages.created_at FROM messages WHERE messages.session_id = $1 AND messages.created_at < (SELECT m2.created_at FROM messages m2 WHERE m2.id = $2) ORDER BY messages.created_at DESC @@ -124,7 +120,6 @@ func (q *Queries) ListMessagesBefore(ctx context.Context, arg ListMessagesBefore &i.AuthorType, &i.Content, &i.ExpandableContent, - &i.Mentions, &i.Status, &i.CreatedAt, ); err != nil { diff --git a/server/internal/db/migrations/013_single_deuce_agent.sql b/server/internal/db/migrations/013_single_deuce_agent.sql new file mode 100644 index 0000000..6878967 --- /dev/null +++ b/server/internal/db/migrations/013_single_deuce_agent.sql @@ -0,0 +1,104 @@ +-- +goose Up + +-- Collapse the multi-agent model into a single built-in agent "deuce" +-- (docs/plans/2026-06-09-001-refactor-single-deuce-agent-plan.md, R2/R3/R12). +-- Destructive by design — multi-agent rosters, role metadata, and per-pair Pi +-- session state are discarded. ORDER MATTERS: tasks.agent_id carries +-- ON DELETE CASCADE to agents, so the column must go BEFORE any agent rows are +-- deleted or all task history silently cascade-deletes. + +-- 1. Drop tasks.agent_id. Dropping the column drops its FK constraint and the +-- (session_id, agent_id, state) index with it. Tasks are session-scoped now. +ALTER TABLE tasks DROP COLUMN agent_id; + +-- Replacement index for the scheduler's per-session queue/state walks. +CREATE INDEX idx_tasks_session_state ON tasks (session_id, state); + +-- 2. Cancel still-queued tasks. Boot recovery only fails running/awaiting_input; +-- a queued task carrying a stale persona-targeted prompt must not be +-- promoted under deuce days later. +UPDATE tasks SET state = 'cancelled', updated_at = now() WHERE state = 'queued'; + +-- 3. Repoint historical agent-authored messages to deuce so the visibility +-- filter (pinned to deuce's UUID) treats them consistently. The IN-subquery +-- guard excludes the nil-UUID system-notice sentinel, which is not an agents +-- row and must stay nil (and visible). +UPDATE messages +SET author_id = '00000000-0000-0000-0000-00000000000d' +WHERE author_type = 'agent' + AND author_id IN (SELECT id FROM agents); + +-- 4. Drop the per-session roster (takes claude_session_id and pi_session_id +-- with it — Pi resume-across-restart was never wired up; re-add on sessions +-- when actually implemented). +DROP TABLE session_agents; + +-- 5. Reshape agents to the single built-in row: id + name + system_prompt. +-- Role/color render from a frontend constant; provider/model are owned by +-- DEUCE_PI_PROVIDER / DEUCE_PI_MODEL. +DELETE FROM agents; +ALTER TABLE agents + DROP COLUMN role, + DROP COLUMN color, + DROP COLUMN color_muted, + DROP COLUMN provider, + DROP COLUMN model, + DROP COLUMN description, + DROP COLUMN deleted_at, + DROP COLUMN created_at, + DROP COLUMN updated_at; +INSERT INTO agents (id, name, system_prompt) +VALUES ('00000000-0000-0000-0000-00000000000d', 'deuce', ''); + +-- 6. Mention plumbing is gone — the server parses @deuce from message content. +ALTER TABLE messages DROP COLUMN mentions; +ALTER TABLE activity_items DROP COLUMN agent_id; + +-- +goose Down + +-- Restores the pre-013 SCHEMA and the five role-agent seed rows so a Down→Up +-- cycle returns to a known dev state (007 precedent). Data is only partially +-- reversible: cancelled queued tasks stay cancelled, repointed message +-- authorship stays on deuce, and session_agents rosters are not restored. + +ALTER TABLE activity_items ADD COLUMN agent_id UUID; +ALTER TABLE messages ADD COLUMN mentions TEXT[] NOT NULL DEFAULT '{}'; + +-- Agents back to the 001+004 shape, then reseed the five role presets +-- (002 seed values + 004's empty system_prompt defaults). +DELETE FROM agents; +ALTER TABLE agents + ADD COLUMN role TEXT NOT NULL DEFAULT '', + ADD COLUMN color TEXT NOT NULL DEFAULT '', + ADD COLUMN color_muted TEXT NOT NULL DEFAULT '', + ADD COLUMN provider TEXT NOT NULL DEFAULT '', + ADD COLUMN model TEXT NOT NULL DEFAULT '', + ADD COLUMN description TEXT NOT NULL DEFAULT '', + ADD COLUMN deleted_at TIMESTAMPTZ, + ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT now(); +INSERT INTO agents (id, name, role, color, color_muted, provider, model, description) VALUES + ('00000000-0000-0000-0000-000000000001', 'Coder', 'coder', '#58a6ff', '#0c2d6b', 'Anthropic', 'Claude Sonnet 4', 'Writes and modifies code'), + ('00000000-0000-0000-0000-000000000002', 'Reviewer', 'reviewer', '#BE8FFF', '#3c1e70', 'Anthropic', 'Claude Sonnet 4', 'Reviews code changes'), + ('00000000-0000-0000-0000-000000000003', 'Planner', 'planner', '#3fb950', '#033a16', 'OpenAI', 'GPT-4o', 'Creates implementation plans'), + ('00000000-0000-0000-0000-000000000004', 'Tester', 'tester', '#d29922', '#4b2900', 'Anthropic', 'Claude Sonnet 4', 'Writes and runs tests'), + ('00000000-0000-0000-0000-000000000005', 'Designer', 'designer', '#f778ba', '#5e103e', 'OpenAI', 'GPT-4o', 'UI/UX suggestions'); + +CREATE TABLE session_agents ( + session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'idle', + claude_session_id TEXT NOT NULL DEFAULT '', + pi_session_id TEXT NOT NULL DEFAULT '', + PRIMARY KEY (session_id, agent_id) +); + +-- tasks.agent_id must be backfilled before NOT NULL + FK can apply against a +-- data-bearing table; existing rows repoint to the Coder seed row. +DROP INDEX idx_tasks_session_state; +ALTER TABLE tasks ADD COLUMN agent_id UUID; +UPDATE tasks SET agent_id = '00000000-0000-0000-0000-000000000001'; +ALTER TABLE tasks ALTER COLUMN agent_id SET NOT NULL; +ALTER TABLE tasks + ADD CONSTRAINT tasks_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE; +CREATE INDEX idx_tasks_session_agent_state ON tasks (session_id, agent_id, state); diff --git a/server/internal/db/models.go b/server/internal/db/models.go index ed895a2..6d92950 100644 --- a/server/internal/db/models.go +++ b/server/internal/db/models.go @@ -12,28 +12,18 @@ import ( ) type ActivityItem struct { - ID uuid.UUID `json:"id"` - SessionID uuid.UUID `json:"session_id"` - Type string `json:"type"` - Description string `json:"description"` - AgentID pgtype.UUID `json:"agent_id"` - Metadata []byte `json:"metadata"` - CreatedAt time.Time `json:"created_at"` + ID uuid.UUID `json:"id"` + SessionID uuid.UUID `json:"session_id"` + Type string `json:"type"` + Description string `json:"description"` + Metadata []byte `json:"metadata"` + CreatedAt time.Time `json:"created_at"` } type Agent struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Role string `json:"role"` - Color string `json:"color"` - ColorMuted string `json:"color_muted"` - Provider string `json:"provider"` - Model string `json:"model"` - Description string `json:"description"` - SystemPrompt string `json:"system_prompt"` - DeletedAt pgtype.Timestamptz `json:"deleted_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + Name string `json:"name"` + SystemPrompt string `json:"system_prompt"` } type Message struct { @@ -43,7 +33,6 @@ type Message struct { AuthorType string `json:"author_type"` Content string `json:"content"` ExpandableContent []byte `json:"expandable_content"` - Mentions []string `json:"mentions"` Status string `json:"status"` CreatedAt time.Time `json:"created_at"` } @@ -68,14 +57,6 @@ type Session struct { Description string `json:"description"` } -type SessionAgent struct { - SessionID uuid.UUID `json:"session_id"` - AgentID uuid.UUID `json:"agent_id"` - Status string `json:"status"` - ClaudeSessionID string `json:"claude_session_id"` - PiSessionID string `json:"pi_session_id"` -} - type SessionEventSeq struct { SessionID uuid.UUID `json:"session_id"` NextSeq int64 `json:"next_seq"` @@ -90,7 +71,6 @@ type SessionMember struct { type Task struct { ID uuid.UUID `json:"id"` SessionID uuid.UUID `json:"session_id"` - AgentID uuid.UUID `json:"agent_id"` RequestedBy pgtype.UUID `json:"requested_by"` AnchorMessageID pgtype.UUID `json:"anchor_message_id"` Prompt string `json:"prompt"` diff --git a/server/internal/db/queries/activities.sql b/server/internal/db/queries/activities.sql index 3e4126d..5bba885 100644 --- a/server/internal/db/queries/activities.sql +++ b/server/internal/db/queries/activities.sql @@ -5,6 +5,6 @@ ORDER BY created_at DESC LIMIT $2; -- name: CreateActivity :one -INSERT INTO activity_items (session_id, type, description, agent_id, metadata) -VALUES ($1, $2, $3, $4, $5) +INSERT INTO activity_items (session_id, type, description, metadata) +VALUES ($1, $2, $3, $4) RETURNING *; diff --git a/server/internal/db/queries/agents.sql b/server/internal/db/queries/agents.sql index aa9e98b..27b6cbf 100644 --- a/server/internal/db/queries/agents.sql +++ b/server/internal/db/queries/agents.sql @@ -1,25 +1,8 @@ --- name: ListAgents :many -SELECT * FROM agents WHERE deleted_at IS NULL ORDER BY name; - --- name: GetAgent :one -SELECT * FROM agents WHERE id = $1; - --- name: CreateAgent :one -INSERT INTO agents (name, role, color, color_muted, provider, model, description, system_prompt) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -RETURNING *; - --- name: UpdateAgent :one -UPDATE agents -SET name = $2, - role = $3, - provider = $4, - model = $5, - description = $6, - system_prompt = $7, - updated_at = now() -WHERE id = $1 AND deleted_at IS NULL -RETURNING *; - --- name: SoftDeleteAgent :exec -UPDATE agents SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL; +-- name: GetDeuceAgent :one +-- The agents table holds exactly one row — the built-in "deuce" agent +-- (migration 013). Single-row read backs GET /api/agent and the runtime's +-- launch-time system-prompt fetch. +SELECT * FROM agents LIMIT 1; + +-- name: UpdateDeuceSystemPrompt :one +UPDATE agents SET system_prompt = $1 RETURNING *; diff --git a/server/internal/db/queries/messages.sql b/server/internal/db/queries/messages.sql index a24aa41..2237dd4 100644 --- a/server/internal/db/queries/messages.sql +++ b/server/internal/db/queries/messages.sql @@ -12,6 +12,6 @@ ORDER BY messages.created_at DESC LIMIT $3; -- name: CreateMessage :one -INSERT INTO messages (session_id, author_id, author_type, content, expandable_content, mentions, status) -VALUES ($1, $2, $3, $4, $5, $6, $7) +INSERT INTO messages (session_id, author_id, author_type, content, expandable_content, status) +VALUES ($1, $2, $3, $4, $5, $6) RETURNING *; diff --git a/server/internal/db/queries/sessions.sql b/server/internal/db/queries/sessions.sql index 864e9a1..dcf811a 100644 --- a/server/internal/db/queries/sessions.sql +++ b/server/internal/db/queries/sessions.sql @@ -60,12 +60,6 @@ WHERE workspace_status IN ('starting', 'stopping', 'rebuilding', 'deleting'); -- name: UpdateSessionLastActivity :exec UPDATE sessions SET last_activity_at = now() WHERE id = $1; --- name: ListSessionAgents :many -SELECT a.*, sa.status as agent_status FROM agents a -JOIN session_agents sa ON a.id = sa.agent_id -WHERE sa.session_id = $1 -ORDER BY a.name; - -- name: ListSessionMembers :many SELECT u.* FROM users u JOIN session_members sm ON u.id = sm.user_id @@ -80,18 +74,6 @@ ON CONFLICT DO NOTHING; -- name: RemoveSessionMember :exec DELETE FROM session_members WHERE session_id = $1 AND user_id = $2; --- name: AddSessionAgent :exec -INSERT INTO session_agents (session_id, agent_id) -VALUES ($1, $2) -ON CONFLICT DO NOTHING; - --- name: RemoveAllSessionAgents :exec -DELETE FROM session_agents WHERE session_id = $1; - --- name: UpdateSessionAgentStatus :exec -UPDATE session_agents SET status = $3 -WHERE session_id = $1 AND agent_id = $2; - -- name: GetUnreadCount :one SELECT COUNT(*)::int FROM messages m JOIN session_members sm ON m.session_id = sm.session_id AND sm.user_id = $2 diff --git a/server/internal/db/queries/tasks.sql b/server/internal/db/queries/tasks.sql index d21cf09..07b26bd 100644 --- a/server/internal/db/queries/tasks.sql +++ b/server/internal/db/queries/tasks.sql @@ -14,8 +14,8 @@ RETURNING (next_seq - 1)::bigint AS seq; SELECT COALESCE((SELECT next_seq - 1 FROM session_event_seq WHERE session_id = $1), 0)::bigint AS seq; -- name: CreateTask :one -INSERT INTO tasks (session_id, agent_id, requested_by, anchor_message_id, prompt, state, seq) -VALUES ($1, $2, $3, $4, $5, $6, $7) +INSERT INTO tasks (session_id, requested_by, anchor_message_id, prompt, state, seq) +VALUES ($1, $2, $3, $4, $5, $6) RETURNING *; -- name: GetTask :one @@ -51,13 +51,10 @@ WHERE task_id = $1 AND call_id = $2; UPDATE task_actions SET status = 'interrupted' WHERE task_id = $1 AND status = 'started'; -- name: ListSessionTasks :many +-- A session's tasks in creation order — drives queue-position derivation (R12) +-- and promotion (R13) in the scheduler, plus the snapshot read. SELECT * FROM tasks WHERE session_id = $1 ORDER BY created_at ASC; --- name: ListAgentTasks :many --- An agent's tasks in creation order — drives queue-position derivation (R12) --- and promotion (R13) in the scheduler. -SELECT * FROM tasks WHERE session_id = $1 AND agent_id = $2 ORDER BY created_at ASC; - -- name: ListTaskActions :many SELECT * FROM task_actions WHERE task_id = $1 ORDER BY seq ASC, created_at ASC; @@ -69,12 +66,6 @@ JOIN tasks t ON t.id = ta.task_id WHERE t.session_id = $1 ORDER BY ta.task_id, ta.seq ASC; --- name: GetPiSessionID :one -SELECT pi_session_id FROM session_agents WHERE session_id = $1 AND agent_id = $2; - --- name: UpdatePiSessionID :exec -UPDATE session_agents SET pi_session_id = $3 WHERE session_id = $1 AND agent_id = $2; - -- name: IsSessionMember :one -- Membership gate for steering + snapshot authorization (KTD14). SELECT EXISTS ( @@ -83,18 +74,8 @@ SELECT EXISTS ( -- name: FailStuckTasks :exec -- Boot recovery (KTD10): tasks left running/awaiting_input by a crash are --- reconciled to failed before the scheduler starts. +-- reconciled to failed before the scheduler starts. (The pre-013 companion +-- ClearStuckPiSessions is gone with session_agents.pi_session_id — Pi +-- resume-across-restart was never wired up, so there is no session id to +-- clear; every relaunch starts a fresh Pi process.) UPDATE tasks SET state = 'failed', updated_at = now() WHERE state IN ('running', 'awaiting_input'); - --- name: ClearStuckPiSessions :exec --- Boot recovery companion: clear pi_session_id for (session, agent) pairs with --- a stuck in-flight task, so relaunch won't resume a dead Pi session. MUST run --- BEFORE FailStuckTasks — it keys on the pre-failure states, not on 'failed' --- (which would also clear legitimately-failed historical tasks). -UPDATE session_agents sa -SET pi_session_id = '' -WHERE EXISTS ( - SELECT 1 FROM tasks t - WHERE t.session_id = sa.session_id AND t.agent_id = sa.agent_id - AND t.state IN ('running', 'awaiting_input') -); diff --git a/server/internal/db/sessions.sql.go b/server/internal/db/sessions.sql.go index 964dd1f..15ff2f0 100644 --- a/server/internal/db/sessions.sql.go +++ b/server/internal/db/sessions.sql.go @@ -7,28 +7,10 @@ package db import ( "context" - "time" "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" ) -const addSessionAgent = `-- name: AddSessionAgent :exec -INSERT INTO session_agents (session_id, agent_id) -VALUES ($1, $2) -ON CONFLICT DO NOTHING -` - -type AddSessionAgentParams struct { - SessionID uuid.UUID `json:"session_id"` - AgentID uuid.UUID `json:"agent_id"` -} - -func (q *Queries) AddSessionAgent(ctx context.Context, arg AddSessionAgentParams) error { - _, err := q.db.Exec(ctx, addSessionAgent, arg.SessionID, arg.AgentID) - return err -} - const addSessionMember = `-- name: AddSessionMember :exec INSERT INTO session_members (session_id, user_id) VALUES ($1, $2) @@ -184,63 +166,6 @@ func (q *Queries) ListNonArchivedSessions(ctx context.Context) ([]Session, error return items, nil } -const listSessionAgents = `-- name: ListSessionAgents :many -SELECT a.id, a.name, a.role, a.color, a.color_muted, a.provider, a.model, a.description, a.system_prompt, a.deleted_at, a.created_at, a.updated_at, sa.status as agent_status FROM agents a -JOIN session_agents sa ON a.id = sa.agent_id -WHERE sa.session_id = $1 -ORDER BY a.name -` - -type ListSessionAgentsRow struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Role string `json:"role"` - Color string `json:"color"` - ColorMuted string `json:"color_muted"` - Provider string `json:"provider"` - Model string `json:"model"` - Description string `json:"description"` - SystemPrompt string `json:"system_prompt"` - DeletedAt pgtype.Timestamptz `json:"deleted_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - AgentStatus string `json:"agent_status"` -} - -func (q *Queries) ListSessionAgents(ctx context.Context, sessionID uuid.UUID) ([]ListSessionAgentsRow, error) { - rows, err := q.db.Query(ctx, listSessionAgents, sessionID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []ListSessionAgentsRow{} - for rows.Next() { - var i ListSessionAgentsRow - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Role, - &i.Color, - &i.ColorMuted, - &i.Provider, - &i.Model, - &i.Description, - &i.SystemPrompt, - &i.DeletedAt, - &i.CreatedAt, - &i.UpdatedAt, - &i.AgentStatus, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const listSessionMembers = `-- name: ListSessionMembers :many SELECT u.id, u.name, u.email, u.avatar, u.status, u.created_at FROM users u JOIN session_members sm ON u.id = sm.user_id @@ -334,15 +259,6 @@ func (q *Queries) MarkSessionRead(ctx context.Context, arg MarkSessionReadParams return err } -const removeAllSessionAgents = `-- name: RemoveAllSessionAgents :exec -DELETE FROM session_agents WHERE session_id = $1 -` - -func (q *Queries) RemoveAllSessionAgents(ctx context.Context, sessionID uuid.UUID) error { - _, err := q.db.Exec(ctx, removeAllSessionAgents, sessionID) - return err -} - const removeSessionMember = `-- name: RemoveSessionMember :exec DELETE FROM session_members WHERE session_id = $1 AND user_id = $2 ` @@ -368,22 +284,6 @@ func (q *Queries) ResetStaleWorkspaceTransitions(ctx context.Context) error { return err } -const updateSessionAgentStatus = `-- name: UpdateSessionAgentStatus :exec -UPDATE session_agents SET status = $3 -WHERE session_id = $1 AND agent_id = $2 -` - -type UpdateSessionAgentStatusParams struct { - SessionID uuid.UUID `json:"session_id"` - AgentID uuid.UUID `json:"agent_id"` - Status string `json:"status"` -} - -func (q *Queries) UpdateSessionAgentStatus(ctx context.Context, arg UpdateSessionAgentStatusParams) error { - _, err := q.db.Exec(ctx, updateSessionAgentStatus, arg.SessionID, arg.AgentID, arg.Status) - return err -} - const updateSessionDescription = `-- name: UpdateSessionDescription :one UPDATE sessions SET description = $2 WHERE id = $1 RETURNING id, name, project_id, status, workspace_status, plan_content, created_at, last_activity_at, repo_url, description ` diff --git a/server/internal/db/tasks.sql.go b/server/internal/db/tasks.sql.go index 2a5201a..7af37c3 100644 --- a/server/internal/db/tasks.sql.go +++ b/server/internal/db/tasks.sql.go @@ -57,25 +57,6 @@ func (q *Queries) AppendAction(ctx context.Context, arg AppendActionParams) erro return err } -const clearStuckPiSessions = `-- name: ClearStuckPiSessions :exec -UPDATE session_agents sa -SET pi_session_id = '' -WHERE EXISTS ( - SELECT 1 FROM tasks t - WHERE t.session_id = sa.session_id AND t.agent_id = sa.agent_id - AND t.state IN ('running', 'awaiting_input') -) -` - -// Boot recovery companion: clear pi_session_id for (session, agent) pairs with -// a stuck in-flight task, so relaunch won't resume a dead Pi session. MUST run -// BEFORE FailStuckTasks — it keys on the pre-failure states, not on 'failed' -// (which would also clear legitimately-failed historical tasks). -func (q *Queries) ClearStuckPiSessions(ctx context.Context) error { - _, err := q.db.Exec(ctx, clearStuckPiSessions) - return err -} - const completeAction = `-- name: CompleteAction :exec UPDATE task_actions SET status = $3, text = $4, out = $5, diff = $6, stat = $7, seq = $8 @@ -108,14 +89,13 @@ func (q *Queries) CompleteAction(ctx context.Context, arg CompleteActionParams) } const createTask = `-- name: CreateTask :one -INSERT INTO tasks (session_id, agent_id, requested_by, anchor_message_id, prompt, state, seq) -VALUES ($1, $2, $3, $4, $5, $6, $7) -RETURNING id, session_id, agent_id, requested_by, anchor_message_id, prompt, state, seq, pending_question, reply, work, created_at, updated_at, pending_question_kind, pending_question_options +INSERT INTO tasks (session_id, requested_by, anchor_message_id, prompt, state, seq) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, session_id, requested_by, anchor_message_id, prompt, state, seq, pending_question, reply, work, created_at, updated_at, pending_question_kind, pending_question_options ` type CreateTaskParams struct { SessionID uuid.UUID `json:"session_id"` - AgentID uuid.UUID `json:"agent_id"` RequestedBy pgtype.UUID `json:"requested_by"` AnchorMessageID pgtype.UUID `json:"anchor_message_id"` Prompt string `json:"prompt"` @@ -126,7 +106,6 @@ type CreateTaskParams struct { func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error) { row := q.db.QueryRow(ctx, createTask, arg.SessionID, - arg.AgentID, arg.RequestedBy, arg.AnchorMessageID, arg.Prompt, @@ -137,7 +116,6 @@ func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, e err := row.Scan( &i.ID, &i.SessionID, - &i.AgentID, &i.RequestedBy, &i.AnchorMessageID, &i.Prompt, @@ -159,7 +137,10 @@ UPDATE tasks SET state = 'failed', updated_at = now() WHERE state IN ('running', ` // Boot recovery (KTD10): tasks left running/awaiting_input by a crash are -// reconciled to failed before the scheduler starts. +// reconciled to failed before the scheduler starts. (The pre-013 companion +// ClearStuckPiSessions is gone with session_agents.pi_session_id — Pi +// resume-across-restart was never wired up, so there is no session id to +// clear; every relaunch starts a fresh Pi process.) func (q *Queries) FailStuckTasks(ctx context.Context) error { _, err := q.db.Exec(ctx, failStuckTasks) return err @@ -199,24 +180,8 @@ func (q *Queries) ForceResolveOpenActions(ctx context.Context, taskID uuid.UUID) return err } -const getPiSessionID = `-- name: GetPiSessionID :one -SELECT pi_session_id FROM session_agents WHERE session_id = $1 AND agent_id = $2 -` - -type GetPiSessionIDParams struct { - SessionID uuid.UUID `json:"session_id"` - AgentID uuid.UUID `json:"agent_id"` -} - -func (q *Queries) GetPiSessionID(ctx context.Context, arg GetPiSessionIDParams) (string, error) { - row := q.db.QueryRow(ctx, getPiSessionID, arg.SessionID, arg.AgentID) - var pi_session_id string - err := row.Scan(&pi_session_id) - return pi_session_id, err -} - const getTask = `-- name: GetTask :one -SELECT id, session_id, agent_id, requested_by, anchor_message_id, prompt, state, seq, pending_question, reply, work, created_at, updated_at, pending_question_kind, pending_question_options FROM tasks WHERE id = $1 +SELECT id, session_id, requested_by, anchor_message_id, prompt, state, seq, pending_question, reply, work, created_at, updated_at, pending_question_kind, pending_question_options FROM tasks WHERE id = $1 ` func (q *Queries) GetTask(ctx context.Context, id uuid.UUID) (Task, error) { @@ -225,7 +190,6 @@ func (q *Queries) GetTask(ctx context.Context, id uuid.UUID) (Task, error) { err := row.Scan( &i.ID, &i.SessionID, - &i.AgentID, &i.RequestedBy, &i.AnchorMessageID, &i.Prompt, @@ -261,53 +225,6 @@ func (q *Queries) IsSessionMember(ctx context.Context, arg IsSessionMemberParams return is_member, err } -const listAgentTasks = `-- name: ListAgentTasks :many -SELECT id, session_id, agent_id, requested_by, anchor_message_id, prompt, state, seq, pending_question, reply, work, created_at, updated_at, pending_question_kind, pending_question_options FROM tasks WHERE session_id = $1 AND agent_id = $2 ORDER BY created_at ASC -` - -type ListAgentTasksParams struct { - SessionID uuid.UUID `json:"session_id"` - AgentID uuid.UUID `json:"agent_id"` -} - -// An agent's tasks in creation order — drives queue-position derivation (R12) -// and promotion (R13) in the scheduler. -func (q *Queries) ListAgentTasks(ctx context.Context, arg ListAgentTasksParams) ([]Task, error) { - rows, err := q.db.Query(ctx, listAgentTasks, arg.SessionID, arg.AgentID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []Task{} - for rows.Next() { - var i Task - if err := rows.Scan( - &i.ID, - &i.SessionID, - &i.AgentID, - &i.RequestedBy, - &i.AnchorMessageID, - &i.Prompt, - &i.State, - &i.Seq, - &i.PendingQuestion, - &i.Reply, - &i.Work, - &i.CreatedAt, - &i.UpdatedAt, - &i.PendingQuestionKind, - &i.PendingQuestionOptions, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const listSessionTaskActions = `-- name: ListSessionTaskActions :many SELECT ta.id, ta.task_id, ta.call_id, ta.seq, ta.tool, ta.arg, ta.note, ta.text, ta.stat, ta.diff, ta.out, ta.status, ta.created_at FROM task_actions ta JOIN tasks t ON t.id = ta.task_id @@ -352,9 +269,11 @@ func (q *Queries) ListSessionTaskActions(ctx context.Context, sessionID uuid.UUI } const listSessionTasks = `-- name: ListSessionTasks :many -SELECT id, session_id, agent_id, requested_by, anchor_message_id, prompt, state, seq, pending_question, reply, work, created_at, updated_at, pending_question_kind, pending_question_options FROM tasks WHERE session_id = $1 ORDER BY created_at ASC +SELECT id, session_id, requested_by, anchor_message_id, prompt, state, seq, pending_question, reply, work, created_at, updated_at, pending_question_kind, pending_question_options FROM tasks WHERE session_id = $1 ORDER BY created_at ASC ` +// A session's tasks in creation order — drives queue-position derivation (R12) +// and promotion (R13) in the scheduler, plus the snapshot read. func (q *Queries) ListSessionTasks(ctx context.Context, sessionID uuid.UUID) ([]Task, error) { rows, err := q.db.Query(ctx, listSessionTasks, sessionID) if err != nil { @@ -367,7 +286,6 @@ func (q *Queries) ListSessionTasks(ctx context.Context, sessionID uuid.UUID) ([] if err := rows.Scan( &i.ID, &i.SessionID, - &i.AgentID, &i.RequestedBy, &i.AnchorMessageID, &i.Prompt, @@ -479,21 +397,6 @@ func (q *Queries) SetTaskAwaitingInput(ctx context.Context, arg SetTaskAwaitingI return err } -const updatePiSessionID = `-- name: UpdatePiSessionID :exec -UPDATE session_agents SET pi_session_id = $3 WHERE session_id = $1 AND agent_id = $2 -` - -type UpdatePiSessionIDParams struct { - SessionID uuid.UUID `json:"session_id"` - AgentID uuid.UUID `json:"agent_id"` - PiSessionID string `json:"pi_session_id"` -} - -func (q *Queries) UpdatePiSessionID(ctx context.Context, arg UpdatePiSessionIDParams) error { - _, err := q.db.Exec(ctx, updatePiSessionID, arg.SessionID, arg.AgentID, arg.PiSessionID) - return err -} - const updateTaskState = `-- name: UpdateTaskState :exec UPDATE tasks SET state = $2, seq = $3, updated_at = now() WHERE id = $1 ` diff --git a/server/internal/handler/activities.go b/server/internal/handler/activities.go index c9835c3..c73980b 100644 --- a/server/internal/handler/activities.go +++ b/server/internal/handler/activities.go @@ -7,7 +7,6 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" "encoding/json" @@ -19,17 +18,11 @@ type activityResponse struct { SessionID uuid.UUID `json:"sessionId"` Type string `json:"type"` Description string `json:"description"` - AgentID *uuid.UUID `json:"agentId"` Metadata json.RawMessage `json:"metadata"` Timestamp time.Time `json:"timestamp"` } func toActivityResponse(a db.ActivityItem) activityResponse { - var agentID *uuid.UUID - if a.AgentID.Valid { - id := uuid.UUID(a.AgentID.Bytes) - agentID = &id - } metadata := json.RawMessage("null") if a.Metadata != nil { metadata = a.Metadata @@ -39,7 +32,6 @@ func toActivityResponse(a db.ActivityItem) activityResponse { SessionID: a.SessionID, Type: a.Type, Description: a.Description, - AgentID: agentID, Metadata: metadata, Timestamp: a.CreatedAt, } @@ -85,17 +77,3 @@ func (h *Handler) ListActivities(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, result) } - -// Unused for now, but the function signature matches what the agent simulator needs -func (h *Handler) createActivity(sessionID uuid.UUID, actType, description string, agentID *uuid.UUID) { - var aid pgtype.UUID - if agentID != nil { - aid = pgtype.UUID{Bytes: *agentID, Valid: true} - } - _, _ = h.queries.CreateActivity(nil, db.CreateActivityParams{ - SessionID: sessionID, - Type: actType, - Description: description, - AgentID: aid, - }) -} diff --git a/server/internal/handler/agent_run.go b/server/internal/handler/agent_run.go index d4ad737..cb74d79 100644 --- a/server/internal/handler/agent_run.go +++ b/server/internal/handler/agent_run.go @@ -28,7 +28,6 @@ type agentActionResp struct { type agentTaskResp struct { ID string `json:"id"` SessionID string `json:"sessionId"` - AgentID string `json:"agentId"` RequestedBy string `json:"requestedBy,omitempty"` AnchorMessageID string `json:"anchorMessageId,omitempty"` Prompt string `json:"prompt"` @@ -121,7 +120,7 @@ func buildSnapshot(tasks []db.Task, actions []db.TaskAction) agentRunSnapshotRes } } resp.Tasks = append(resp.Tasks, agentTaskResp{ - ID: t.ID.String(), SessionID: t.SessionID.String(), AgentID: t.AgentID.String(), + ID: t.ID.String(), SessionID: t.SessionID.String(), RequestedBy: uuidStr(t.RequestedBy.Bytes, t.RequestedBy.Valid), AnchorMessageID: uuidStr(t.AnchorMessageID.Bytes, t.AnchorMessageID.Valid), Prompt: t.Prompt, State: t.State, Seq: t.Seq, @@ -143,18 +142,17 @@ func uuidStr(b [16]byte, valid bool) string { } // RecoverStuckTasks reconciles tasks left running/awaiting_input by a crash to -// failed and clears their Pi sessions, BEFORE the scheduler starts (KTD10). It -// retries transient DB errors a few times, then returns the error so the caller -// can abort boot rather than serve with tasks the snapshot would report live -// forever. Order matters: clear pi sessions (keyed on the pre-failure states) -// before failing the tasks. +// failed, BEFORE the scheduler starts (KTD10). It retries transient DB errors a +// few times, then returns the error so the caller can abort boot rather than +// serve with tasks the snapshot would report live forever. (The pre-013 +// ClearStuckPiSessions companion is gone with session_agents.pi_session_id — +// resume-across-restart was never wired up, so every relaunch already starts a +// fresh Pi process and there is no stale session id to clear.) func RecoverStuckTasks(ctx context.Context, q *db.Queries) error { var err error for attempt := 0; attempt < 3; attempt++ { - if err = q.ClearStuckPiSessions(ctx); err == nil { - if err = q.FailStuckTasks(ctx); err == nil { - return nil - } + if err = q.FailStuckTasks(ctx); err == nil { + return nil } time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond) } diff --git a/server/internal/handler/agent_run_test.go b/server/internal/handler/agent_run_test.go index 2dadf65..4a387d9 100644 --- a/server/internal/handler/agent_run_test.go +++ b/server/internal/handler/agent_run_test.go @@ -12,11 +12,10 @@ func TestBuildSnapshotGroupsActionsAndDerivesLatestSeq(t *testing.T) { t1 := uuid.New() t2 := uuid.New() sid := uuid.New() - aid := uuid.New() tasks := []db.Task{ - {ID: t1, SessionID: sid, AgentID: aid, State: "done", Seq: 3, Reply: "ok"}, - {ID: t2, SessionID: sid, AgentID: aid, State: "awaiting_input", Seq: 7, PendingQuestion: "which?"}, + {ID: t1, SessionID: sid, State: "done", Seq: 3, Reply: "ok"}, + {ID: t2, SessionID: sid, State: "awaiting_input", Seq: 7, PendingQuestion: "which?"}, } actions := []db.TaskAction{ {TaskID: t1, CallID: "c1", Seq: 2, Tool: "Bash", Status: "completed"}, diff --git a/server/internal/handler/agents.go b/server/internal/handler/agents.go index da6827f..0c5767b 100644 --- a/server/internal/handler/agents.go +++ b/server/internal/handler/agents.go @@ -3,185 +3,58 @@ package handler import ( "encoding/json" "net/http" - - "github.com/go-chi/chi/v5" - "github.com/google/uuid" - - db "github.com/forgeutah/deuce/server/internal/db" ) -// Agent color palette for auto-assignment -var agentColors = []struct { - Color string - ColorMuted string -}{ - {"#58a6ff", "#0c2d6b"}, - {"#BE8FFF", "#3c1e70"}, - {"#3fb950", "#033a16"}, - {"#d29922", "#4b2900"}, - {"#f778ba", "#5e103e"}, - {"#79c0ff", "#0a3069"}, - {"#ffa657", "#5a1e02"}, - {"#ff7b72", "#67060c"}, -} - -func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) { - agents, err := h.queries.ListAgents(r.Context()) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to list agents") - return - } - writeJSON(w, http.StatusOK, agents) -} - -type createAgentRequest struct { +// agentSettingsResponse is the GET/PUT /api/agent shape: the single built-in +// deuce agent's identity and configurable system prompt. Name/color render +// from the frontend DEUCE constant; only the prompt is editable. +type agentSettingsResponse struct { + ID string `json:"id"` Name string `json:"name"` - Role string `json:"role"` - Provider string `json:"provider"` - Model string `json:"model"` - Description string `json:"description"` SystemPrompt string `json:"systemPrompt"` } -func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) { - var req createAgentRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", "invalid request body") - return - } - - if req.Name == "" { - writeError(w, http.StatusBadRequest, "MISSING_NAME", "agent name is required") - return - } - - // Auto-assign color based on existing agent count - agents, _ := h.queries.ListAgents(r.Context()) - colorIdx := len(agents) % len(agentColors) - color := agentColors[colorIdx] - - agent, err := h.queries.CreateAgent(r.Context(), db.CreateAgentParams{ - Name: req.Name, - Role: req.Role, - Color: color.Color, - ColorMuted: color.ColorMuted, - Provider: req.Provider, - Model: req.Model, - Description: req.Description, - SystemPrompt: req.SystemPrompt, - }) +// GetAgentSettings handles GET /api/agent. Authenticated-user gated (any team +// member may read the prompt; it contains instructions, not secrets). +func (h *Handler) GetAgentSettings(w http.ResponseWriter, r *http.Request) { + ag, err := h.queries.GetDeuceAgent(r.Context()) if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to create agent") + writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to load agent settings") return } - - writeJSON(w, http.StatusCreated, agent) + writeJSON(w, http.StatusOK, agentSettingsResponse{ + ID: ag.ID.String(), Name: ag.Name, SystemPrompt: ag.SystemPrompt, + }) } -type updateAgentRequest struct { - Name string `json:"name"` - Role string `json:"role"` - Provider string `json:"provider"` - Model string `json:"model"` - Description string `json:"description"` +type updateAgentSettingsRequest struct { SystemPrompt string `json:"systemPrompt"` } -func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) { - agentID, err := uuid.Parse(chi.URLParam(r, "agentID")) - if err != nil { - writeError(w, http.StatusBadRequest, "INVALID_AGENT_ID", "invalid agent ID") - return - } - - var req updateAgentRequest +// UpdateAgentSettings handles PUT /api/agent. The prompt is GLOBAL — it shapes +// deuce in every session. Pi applies it only at process launch, so idle +// sessions' processes are recycled on save; sessions mid-task pick the change +// up on their next process launch. Authenticated-user gated: no finer role +// model exists yet (an audit trail / admin gate is deferred — see the plan's +// Scope Boundaries). +func (h *Handler) UpdateAgentSettings(w http.ResponseWriter, r *http.Request) { + var req updateAgentSettingsRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "INVALID_BODY", "invalid request body") return } - agent, err := h.queries.UpdateAgent(r.Context(), db.UpdateAgentParams{ - ID: agentID, - Name: req.Name, - Role: req.Role, - Provider: req.Provider, - Model: req.Model, - Description: req.Description, - SystemPrompt: req.SystemPrompt, - }) - if err != nil { - writeError(w, http.StatusNotFound, "NOT_FOUND", "agent not found") - return - } - - writeJSON(w, http.StatusOK, agent) -} - -func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) { - agentID, err := uuid.Parse(chi.URLParam(r, "agentID")) + ag, err := h.queries.UpdateDeuceSystemPrompt(r.Context(), req.SystemPrompt) if err != nil { - writeError(w, http.StatusBadRequest, "INVALID_AGENT_ID", "invalid agent ID") - return - } - - if err := h.queries.SoftDeleteAgent(r.Context(), agentID); err != nil { - writeError(w, http.StatusNotFound, "NOT_FOUND", "agent not found") + writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to update agent settings") return } - w.WriteHeader(http.StatusNoContent) -} - -type updateAgentsRequest struct { - AgentIDs []string `json:"agentIds"` -} - -func (h *Handler) UpdateSessionAgents(w http.ResponseWriter, r *http.Request) { - sessionID, err := uuid.Parse(chi.URLParam(r, "sessionID")) - if err != nil { - writeError(w, http.StatusBadRequest, "INVALID_SESSION_ID", "invalid session ID") - return + if h.runtime != nil { + h.runtime.RecycleIdleProcesses() } - // Write gate: changing a session's agent roster requires SESSION membership. - userID, err := uuid.Parse(getUserID(r)) - if err != nil { - writeError(w, http.StatusBadRequest, "INVALID_USER", "invalid user ID") - return - } - if !h.requireSessionMember(w, r, sessionID, userID) { - return - } - - var req updateAgentsRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", "invalid request body") - return - } - - // Remove all existing agents - if err := h.queries.RemoveAllSessionAgents(r.Context(), sessionID); err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to update agents") - return - } - - // Add new agents - for _, aid := range req.AgentIDs { - agentID, err := uuid.Parse(aid) - if err != nil { - continue - } - _ = h.queries.AddSessionAgent(r.Context(), db.AddSessionAgentParams{ - SessionID: sessionID, - AgentID: agentID, - }) - } - - // Return updated agent list - agents, err := h.queries.ListSessionAgents(r.Context(), sessionID) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to list agents") - return - } - writeJSON(w, http.StatusOK, agents) + writeJSON(w, http.StatusOK, agentSettingsResponse{ + ID: ag.ID.String(), Name: ag.Name, SystemPrompt: ag.SystemPrompt, + }) } diff --git a/server/internal/handler/messages.go b/server/internal/handler/messages.go index b1dcd3a..998a0cd 100644 --- a/server/internal/handler/messages.go +++ b/server/internal/handler/messages.go @@ -5,6 +5,7 @@ import ( "encoding/json" "log/slog" "net/http" + "regexp" "strconv" "strings" "time" @@ -24,7 +25,6 @@ type messageResponse struct { AuthorType string `json:"authorType"` Content string `json:"content"` ExpandableContent json.RawMessage `json:"expandableContent"` - Mentions []string `json:"mentions"` Status string `json:"status"` CreatedAt time.Time `json:"createdAt"` } @@ -34,10 +34,6 @@ func toMessageResponse(m db.Message) messageResponse { if m.ExpandableContent != nil { ec = m.ExpandableContent } - mentions := m.Mentions - if mentions == nil { - mentions = []string{} - } return messageResponse{ ID: m.ID, SessionID: m.SessionID, @@ -45,12 +41,24 @@ func toMessageResponse(m db.Message) messageResponse { AuthorType: m.AuthorType, Content: m.Content, ExpandableContent: ec, - Mentions: mentions, Status: m.Status, CreatedAt: m.CreatedAt, } } +// deuceMentionRE detects an @deuce mention in message content, server-side +// (R5). The left guard (start-of-string or a non-word character) keeps email +// addresses like clint@deuce.dev from triggering; the trailing \b keeps +// near-misses like @deucebot from triggering. Case-insensitive. +var deuceMentionRE = regexp.MustCompile(`(?i)(^|\W)@deuce\b`) + +// isStopCommand reports whether a message is the exact /stop command (R6). +// Exact match only — "@deuce make the flicker stop" must enqueue work, not +// cancel it (the old " stop"-suffix trigger was removed for this reason). +func isStopCommand(content string) bool { + return strings.TrimSpace(content) == "/stop" +} + func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) { sessionID, err := uuid.Parse(chi.URLParam(r, "sessionID")) if err != nil { @@ -122,9 +130,11 @@ func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) { }) } +// sendMessageRequest no longer carries a mentions array — mention detection is +// server-side (R5). Pre-013 clients still send the field; unknown JSON fields +// are ignored by the decoder, so stale tabs degrade gracefully. type sendMessageRequest struct { - Content string `json:"content"` - Mentions []string `json:"mentions"` + Content string `json:"content"` } func (h *Handler) SendMessage(w http.ResponseWriter, r *http.Request) { @@ -159,17 +169,12 @@ func (h *Handler) SendMessage(w http.ResponseWriter, r *http.Request) { return } - if req.Mentions == nil { - req.Mentions = []string{} - } - msg, err := h.queries.CreateMessage(r.Context(), db.CreateMessageParams{ SessionID: sessionID, AuthorID: userID, AuthorType: "human", Content: req.Content, ExpandableContent: nil, - Mentions: req.Mentions, Status: "sent", }) if err != nil { @@ -187,8 +192,7 @@ func (h *Handler) SendMessage(w http.ResponseWriter, r *http.Request) { // Find the sender's WS client to exclude — for now broadcast to all (sender checks client-side) h.hub.BroadcastToSession(sessionID.String(), wsMsg, nil) - // Handle /stop command - if strings.TrimSpace(req.Content) == "/stop" || strings.HasSuffix(strings.TrimSpace(req.Content), " stop") { + if isStopCommand(req.Content) { if h.runtime != nil { h.runtime.CancelSession(r.Context(), sessionID.String()) } @@ -196,36 +200,25 @@ func (h *Handler) SendMessage(w http.ResponseWriter, r *http.Request) { return } - // Process agent mentions through the Pi runtime. - if len(req.Mentions) > 0 && h.runtime != nil { + // @deuce mention → enqueue a task through the Pi runtime (R5). + if deuceMentionRE.MatchString(req.Content) && h.runtime != nil { session, err := h.queries.GetSession(r.Context(), sessionID) if err == nil { switch session.WorkspaceStatus { case "starting": h.postSystemMessage(sessionID, "Workspace is still starting — your agent request will run when it's ready.") case "failed", "suspended": - h.postSystemMessage(sessionID, "Workspace is not available. Please restart the workspace before using agents.") + h.postSystemMessage(sessionID, "Workspace is not available. Please restart the workspace before using the agent.") case "ready": - for _, mention := range req.Mentions { - agentID, err := uuid.Parse(mention) - if err != nil { - continue - } - if _, err := h.queries.GetAgent(r.Context(), agentID); err != nil { - continue - } - - if _, err := h.runtime.Enqueue(r.Context(), agentpkg.EnqueueParams{ - SessionID: sessionID.String(), - AgentID: agentID.String(), - RequestedBy: userID.String(), - AnchorMessageID: msg.ID.String(), - Prompt: req.Content, - WorkspaceID: session.Name, - }); err != nil { - slog.Error("failed to enqueue agent task", "error", err) - h.postSystemMessage(sessionID, "Could not start the agent. Please try again.") - } + if _, err := h.runtime.Enqueue(r.Context(), agentpkg.EnqueueParams{ + SessionID: sessionID.String(), + RequestedBy: userID.String(), + AnchorMessageID: msg.ID.String(), + Prompt: req.Content, + WorkspaceID: session.Name, + }); err != nil { + slog.Error("failed to enqueue agent task", "error", err) + h.postSystemMessage(sessionID, "Could not start the agent. Please try again.") } } } @@ -234,7 +227,9 @@ func (h *Handler) SendMessage(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, resp) } -// StopAgent handles POST /api/sessions/{id}/agents/stop +// StopAgent handles POST /api/sessions/{id}/agent/stop — cancels the session's +// running task and drains its queue (R6), same semantics as the /stop chat +// command. Session-membership gated (write class). func (h *Handler) StopAgent(w http.ResponseWriter, r *http.Request) { sessionID, err := uuid.Parse(chi.URLParam(r, "sessionID")) if err != nil { @@ -258,26 +253,23 @@ func (h *Handler) StopAgent(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -// postAgentReply posts an agent's reply as a chat message and broadcasts it. +// postAgentReply posts deuce's reply as a chat message and broadcasts it. // Wired into the Pi runtime (SetReplyPoster) so agent output shows in the // existing chat — the Super Threads task/action cards are a separate surface. -func (h *Handler) postAgentReply(sessionID, agentID, reply string) { +// Authorship pins to the fixed deuce UUID; the chat visibility filter keys on +// it (R9). +func (h *Handler) postAgentReply(sessionID, reply string) { sid, err := uuid.Parse(sessionID) if err != nil { return } - aid, err := uuid.Parse(agentID) - if err != nil { - aid = uuid.Nil - } ctx := context.Background() msg, err := h.queries.CreateMessage(ctx, db.CreateMessageParams{ SessionID: sid, - AuthorID: aid, + AuthorID: uuid.MustParse(agentpkg.DeuceAgentID), AuthorType: "agent", Content: reply, ExpandableContent: nil, - Mentions: []string{}, Status: "sent", }) if err != nil { @@ -289,6 +281,9 @@ func (h *Handler) postAgentReply(sessionID, agentID, reply string) { h.hub.BroadcastToSession(sessionID, wsMsg, nil) } +// postSystemMessage posts a system notice. The nil-UUID author is the system +// sentinel — distinct from deuce's fixed UUID, so the visibility filter keeps +// notices visible in chat while deuce's task replies stay hidden (R9). func (h *Handler) postSystemMessage(sessionID uuid.UUID, content string) { msg, err := h.queries.CreateMessage(context.Background(), db.CreateMessageParams{ SessionID: sessionID, @@ -296,7 +291,6 @@ func (h *Handler) postSystemMessage(sessionID uuid.UUID, content string) { AuthorType: "agent", Content: content, ExpandableContent: nil, - Mentions: []string{}, Status: "sent", }) if err != nil { diff --git a/server/internal/handler/messages_test.go b/server/internal/handler/messages_test.go new file mode 100644 index 0000000..b91519a --- /dev/null +++ b/server/internal/handler/messages_test.go @@ -0,0 +1,43 @@ +package handler + +import "testing" + +// R5: server-side @deuce mention detection. The left guard keeps email +// addresses from triggering; the trailing boundary keeps prefix near-misses +// from triggering; matching is case-insensitive. +func TestDeuceMentionRE(t *testing.T) { + cases := []struct { + content string + want bool + }{ + {"@deuce do x", true}, + {"@Deuce please review", true}, + {"hey @deuce!", true}, + {"(@deuce) in parens", true}, + {"prefix @DEUCE suffix", true}, + {"@deuce's task", true}, + {"clint@deuce.dev mailed me", false}, + {"@deucebot is not deuce", false}, + {"deuce without the at-sign", false}, + {"no mention at all", false}, + {"", false}, + } + for _, c := range cases { + if got := deuceMentionRE.MatchString(c.content); got != c.want { + t.Errorf("deuceMentionRE.MatchString(%q) = %v, want %v", c.content, got, c.want) + } + } +} + +// R6: /stop is an exact match — "@deuce make the flicker stop" must enqueue +// work, not cancel it. +func TestStopIsExactMatchOnly(t *testing.T) { + if !isStopCommand("/stop") || !isStopCommand(" /stop ") { + t.Error("exact /stop (with surrounding whitespace) must cancel") + } + for _, content := range []string{"@deuce make the flicker stop", "please stop", "/stop now", "stop"} { + if isStopCommand(content) { + t.Errorf("%q must NOT be treated as /stop", content) + } + } +} diff --git a/server/internal/handler/sessions.go b/server/internal/handler/sessions.go index 9bbd3df..acd9be6 100644 --- a/server/internal/handler/sessions.go +++ b/server/internal/handler/sessions.go @@ -35,29 +35,10 @@ type sessionResponse struct { CreatedAt time.Time `json:"createdAt"` LastActivityAt time.Time `json:"lastActivityAt"` UnreadCount int `json:"unreadCount"` - Agents []agentResult `json:"agents"` Members []memberResult `json:"members"` } -type agentResult struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Role string `json:"role"` - Color string `json:"color"` - ColorMuted string `json:"colorMuted"` - Status string `json:"status"` - Provider string `json:"provider"` - Model string `json:"model"` - Description string `json:"description"` - SystemPrompt string `json:"systemPrompt"` -} - func (h *Handler) buildSessionResponse(r *http.Request, s db.Session, userID uuid.UUID) (sessionResponse, error) { - agents, err := h.queries.ListSessionAgents(r.Context(), s.ID) - if err != nil { - return sessionResponse{}, err - } - members, err := h.queries.ListSessionMembers(r.Context(), s.ID) if err != nil { return sessionResponse{}, err @@ -71,22 +52,6 @@ func (h *Handler) buildSessionResponse(r *http.Request, s db.Session, userID uui unread = 0 } - agentResults := make([]agentResult, 0, len(agents)) - for _, a := range agents { - agentResults = append(agentResults, agentResult{ - ID: a.ID, - Name: a.Name, - Role: a.Role, - Color: a.Color, - ColorMuted: a.ColorMuted, - Status: a.AgentStatus, - Provider: a.Provider, - Model: a.Model, - Description: a.Description, - SystemPrompt: a.SystemPrompt, - }) - } - memberResults := make([]memberResult, 0, len(members)) for _, m := range members { memberResults = append(memberResults, memberResult{ @@ -109,7 +74,6 @@ func (h *Handler) buildSessionResponse(r *http.Request, s db.Session, userID uui CreatedAt: s.CreatedAt, LastActivityAt: s.LastActivityAt, UnreadCount: int(unread), - Agents: agentResults, Members: memberResults, }, nil } @@ -179,8 +143,10 @@ type createSessionRequest struct { Description string `json:"description"` ProjectID string `json:"projectId"` RepoURL string `json:"repoUrl"` - AgentIDs []string `json:"agentIds"` MemberIDs []string `json:"memberIds"` + // AgentIDs is tolerated-and-ignored: pre-013 clients sent a roster pick, + // but the single built-in deuce agent is implicitly part of every session. + AgentIDs []string `json:"agentIds"` } func (h *Handler) CreateSession(w http.ResponseWriter, r *http.Request) { @@ -257,18 +223,6 @@ func (h *Handler) CreateSession(w http.ResponseWriter, r *http.Request) { }) } - // Add agents - for _, aid := range req.AgentIDs { - agentID, err := uuid.Parse(aid) - if err != nil { - continue - } - _ = h.queries.AddSessionAgent(r.Context(), db.AddSessionAgentParams{ - SessionID: session.ID, - AgentID: agentID, - }) - } - sr, err := h.buildSessionResponse(r, session, userID) if err != nil { writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to build session") diff --git a/server/internal/handler/websocket.go b/server/internal/handler/websocket.go index 1109575..312125a 100644 --- a/server/internal/handler/websocket.go +++ b/server/internal/handler/websocket.go @@ -52,8 +52,11 @@ func (h *Handler) isSessionMember(userID, sessionID string) bool { } // handleSteer posts the reply to the channel for shared visibility (R15) and -// routes it into the live run (feed/answer) or enqueues a new task (R19). -func (h *Handler) handleSteer(c *ws.Client, sessionID, agentID, message string) { +// routes it into the live run (feed/answer) or enqueues a new task (R19). It +// gates on workspace status the same way SendMessage's mention path does (R8) +// — steering into a still-starting workspace gets the friendly system notice +// instead of an instantly-failed task card. +func (h *Handler) handleSteer(c *ws.Client, sessionID, message string) { if message == "" { return } @@ -69,7 +72,7 @@ func (h *Handler) handleSteer(c *ws.Client, sessionID, agentID, message string) // Post the reply as a channel message so every session member sees it. if msg, err := h.queries.CreateMessage(ctx, db.CreateMessageParams{ - SessionID: sid, AuthorID: uid, AuthorType: "human", Content: message, Mentions: []string{}, Status: "sent", + SessionID: sid, AuthorID: uid, AuthorType: "human", Content: message, Status: "sent", }); err == nil { _ = h.queries.UpdateSessionLastActivity(ctx, sid) if wsMsg, e := ws.NewServerMessage(ws.TypeNewMessage, sessionID, toMessageResponse(msg)); e == nil { @@ -78,15 +81,20 @@ func (h *Handler) handleSteer(c *ws.Client, sessionID, agentID, message string) } session, err := h.queries.GetSession(ctx, sid) - workspaceID := "" - if err == nil { - workspaceID = session.Name + if err != nil { + return + } + switch session.WorkspaceStatus { + case "starting": + h.postSystemMessage(sid, "Workspace is still starting — your agent request will run when it's ready.") + case "failed", "suspended": + h.postSystemMessage(sid, "Workspace is not available. Please restart the workspace before using the agent.") + case "ready": + _, _ = h.runtime.RouteOrEnqueue(ctx, agentpkg.EnqueueParams{ + SessionID: sessionID, + RequestedBy: c.UserID, + Prompt: message, + WorkspaceID: session.Name, + }) } - _, _ = h.runtime.RouteOrEnqueue(ctx, agentpkg.EnqueueParams{ - SessionID: sessionID, - AgentID: agentID, - RequestedBy: c.UserID, - Prompt: message, - WorkspaceID: workspaceID, - }) } diff --git a/server/internal/server/server.go b/server/internal/server/server.go index ba23062..1eb2f16 100644 --- a/server/internal/server/server.go +++ b/server/internal/server/server.go @@ -193,10 +193,11 @@ func (s *Server) Router() http.Handler { }) r.Get("/users", h.ListUsers) r.Get("/projects", h.ListProjects) - r.Get("/agents", h.ListAgents) - r.Post("/agents", h.CreateAgent) - r.Put("/agents/{agentID}", h.UpdateAgent) - r.Delete("/agents/{agentID}", h.DeleteAgent) + // Single built-in agent settings (deuce). GET: any authenticated user. + // PUT: any authenticated user — global blast radius is accepted for + // now (no finer role model exists; audit trail deferred, see plan). + r.Get("/agent", h.GetAgentSettings) + r.Put("/agent", h.UpdateAgentSettings) r.Get("/github/orgs", h.ListGitHubOrgs) r.Get("/github/repos", h.ListGitHubRepos) @@ -216,8 +217,7 @@ func (s *Server) Router() http.Handler { r.Get("/files", h.ListFiles) r.Get("/files/content", h.GetFileContent) r.Get("/vscode-uri", h.GetSessionVSCodeURI) - r.Put("/agents", h.UpdateSessionAgents) - r.Post("/agents/stop", h.StopAgent) + r.Post("/agent/stop", h.StopAgent) r.Route("/workspace", func(r chi.Router) { r.Post("/start", h.StartWorkspace) r.Post("/stop", h.StopWorkspace) diff --git a/server/internal/ws/client.go b/server/internal/ws/client.go index e931c70..670467a 100644 --- a/server/internal/ws/client.go +++ b/server/internal/ws/client.go @@ -36,8 +36,8 @@ type Client struct { // session-membership check (KTD14). When nil, access is allowed — production // wiring MUST set it; an unset gate is a misconfiguration, not a default. Authorize func(userID, sessionID string) bool - // OnSteer routes a steer reply for (sessionID, agentID); set by the handler. - OnSteer func(client *Client, sessionID, agentID, message string) + // OnSteer routes a steer reply for a session; set by the handler. + OnSteer func(client *Client, sessionID, message string) } func (c *Client) authorized(sessionID string) bool { @@ -109,7 +109,7 @@ func (c *Client) ReadPump(ctx context.Context) { text = text[:MaxSteerLen] } if c.OnSteer != nil { - c.OnSteer(c, msg.SessionID, msg.AgentID, text) + c.OnSteer(c, msg.SessionID, text) } default: slog.Warn("unknown message type", "type", msg.Type, "userID", c.UserID) diff --git a/server/internal/ws/events.go b/server/internal/ws/events.go index f80126d..51644fd 100644 --- a/server/internal/ws/events.go +++ b/server/internal/ws/events.go @@ -37,21 +37,21 @@ const ( const MaxSteerLen = 8000 // ClientMessage is a message from a client. The base shape is type+sessionId; -// steer messages additionally carry agentId + message. +// steer messages additionally carry message. (Pre-013 clients also sent an +// agentId field on steer frames — unknown JSON fields are ignored by the +// decoder, so stale tabs degrade gracefully.) type ClientMessage struct { Type string `json:"type"` SessionID string `json:"sessionId"` - AgentID string `json:"agentId,omitempty"` Message string `json:"message,omitempty"` } // TaskEventPayload is the JSON payload for the task-lifecycle AgentRunEvents // (enqueued / started / awaiting_input / completed). Fields are populated per -// event type; seq + taskId + agentId are always set. +// event type; seq + taskId are always set. type TaskEventPayload struct { Seq int64 `json:"seq"` TaskID string `json:"taskId"` - AgentID string `json:"agentId"` RequestedBy string `json:"requestedBy,omitempty"` AnchorMessageID string `json:"anchorMessageId,omitempty"` Prompt string `json:"prompt,omitempty"` @@ -68,10 +68,9 @@ type TaskEventPayload struct { // ActionEventPayload is the JSON payload for action_started / action_completed. type ActionEventPayload struct { - Seq int64 `json:"seq"` - TaskID string `json:"taskId"` - AgentID string `json:"agentId"` - CallID string `json:"callId"` + Seq int64 `json:"seq"` + TaskID string `json:"taskId"` + CallID string `json:"callId"` Tool string `json:"tool,omitempty"` Arg string `json:"arg,omitempty"` Text string `json:"text,omitempty"` diff --git a/server/internal/ws/events_test.go b/server/internal/ws/events_test.go index d4653c3..c554224 100644 --- a/server/internal/ws/events_test.go +++ b/server/internal/ws/events_test.go @@ -6,28 +6,30 @@ import ( ) func TestClientMessageSteerDecodes(t *testing.T) { + // A pre-013 steer frame carrying the retired agentId field still decodes — + // unknown JSON fields are ignored, so stale tabs degrade gracefully. var msg ClientMessage raw := `{"type":"steer","sessionId":"s1","agentId":"a1","message":"use the staging db"}` if err := json.Unmarshal([]byte(raw), &msg); err != nil { t.Fatalf("unmarshal: %v", err) } - if msg.Type != TypeSteer || msg.SessionID != "s1" || msg.AgentID != "a1" || msg.Message != "use the staging db" { + if msg.Type != TypeSteer || msg.SessionID != "s1" || msg.Message != "use the staging db" { t.Errorf("decoded steer = %+v", msg) } - // A legacy join message (no agentId/message) still decodes. + // A join message (no message field) still decodes. var join ClientMessage if err := json.Unmarshal([]byte(`{"type":"join","sessionId":"s2"}`), &join); err != nil { t.Fatalf("unmarshal join: %v", err) } - if join.Type != TypeJoin || join.AgentID != "" || join.Message != "" { + if join.Type != TypeJoin || join.Message != "" { t.Errorf("decoded join = %+v", join) } } func TestAgentRunEventPayloadShape(t *testing.T) { sm, err := NewServerMessage(TypeActionStarted, "s1", ActionEventPayload{ - Seq: 7, TaskID: "t1", AgentID: "a1", CallID: "toolu_1", Tool: "Bash", Arg: "ls -la", + Seq: 7, TaskID: "t1", CallID: "toolu_1", Tool: "Bash", Arg: "ls -la", }) if err != nil { t.Fatalf("NewServerMessage: %v", err) From 6b77e4b9678c62c564c8bd21b2857b7308edd18c Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Wed, 10 Jun 2026 02:34:33 +0000 Subject: [PATCH 3/6] refactor(server): simplify the single-agent collapse Shared workspace-status gate for the chat-mention and steer paths; scheduler lookups use filtered queries against the (session_id, state) index from 013 instead of walking full task history; the prompt-edit recycle deregisters synchronously but signals the dying process without blocking the HTTP handler; the agents row drops the name column (identity lives in the DeuceAgentName/DEUCE constants, with a drift test pinning the UUID across Go, SQL, and TS); /api/agent's contract shrinks to the system prompt. --- server/internal/agent/dbstore.go | 60 ++++------ server/internal/agent/deuce_id_test.go | 46 ++++++++ server/internal/agent/pirun/supervisor.go | 30 ++--- server/internal/agent/runtime.go | 40 +++---- server/internal/agent/runtime_test.go | 21 +--- server/internal/agent/store.go | 5 + server/internal/db/agents.sql.go | 13 ++- .../db/migrations/013_single_deuce_agent.sql | 12 +- server/internal/db/models.go | 1 - server/internal/db/queries/agents.sql | 5 +- server/internal/db/queries/tasks.sql | 27 ++++- server/internal/db/tasks.sql.go | 103 +++++++++++++++++- server/internal/handler/agents.go | 17 +-- server/internal/handler/messages.go | 46 ++++---- server/internal/handler/websocket.go | 21 ++-- server/internal/ws/events.go | 6 +- 16 files changed, 301 insertions(+), 152 deletions(-) create mode 100644 server/internal/agent/deuce_id_test.go diff --git a/server/internal/agent/dbstore.go b/server/internal/agent/dbstore.go index 7f49395..b928a78 100644 --- a/server/internal/agent/dbstore.go +++ b/server/internal/agent/dbstore.go @@ -77,19 +77,11 @@ func (s *DBStore) CreateQueuedTask(ctx context.Context, p EnqueueParams) (string return "", 0, 0, err } - // Queue position: 1-based index among the session's queued tasks. + // Queue position: 1-based index among the session's queued tasks. The + // just-inserted task is the newest, so the queued count IS its position. position := 1 - if tasks, err := s.q.ListSessionTasks(ctx, sid); err == nil { - n := 0 - for _, t := range tasks { - if t.State == StateQueued { - n++ - if t.ID.String() == taskID { - position = n - break - } - } - } + if n, err := s.q.CountQueuedTasks(ctx, sid); err == nil && n > 0 { + position = int(n) } return taskID, seq, position, nil } @@ -183,41 +175,41 @@ func (s *DBStore) FinishTask(ctx context.Context, sessionID, taskID, state, repl } func (s *DBStore) RunningTask(ctx context.Context, sessionID string) (string, bool, error) { - tasks, err := s.sessionTasks(ctx, sessionID) + sid, err := uuid.Parse(sessionID) if err != nil { return "", false, err } - for _, t := range tasks { - if t.State == StateRunning || t.State == StateAwaitingInput { - return t.ID.String(), true, nil - } + ids, err := s.q.GetActiveTaskID(ctx, sid) + if err != nil || len(ids) == 0 { + return "", false, err } - return "", false, nil + return ids[0].String(), true, nil } func (s *DBStore) NextQueuedTask(ctx context.Context, sessionID string) (string, string, bool, error) { - tasks, err := s.sessionTasks(ctx, sessionID) + sid, err := uuid.Parse(sessionID) if err != nil { return "", "", false, err } - for _, t := range tasks { - if t.State == StateQueued { - return t.ID.String(), t.Prompt, true, nil - } + rows, err := s.q.GetNextQueuedTask(ctx, sid) + if err != nil || len(rows) == 0 { + return "", "", false, err } - return "", "", false, nil + return rows[0].ID.String(), rows[0].Prompt, true, nil } func (s *DBStore) QueuedTaskIDs(ctx context.Context, sessionID string) ([]string, error) { - tasks, err := s.sessionTasks(ctx, sessionID) + sid, err := uuid.Parse(sessionID) if err != nil { return nil, err } - var ids []string - for _, t := range tasks { - if t.State == StateQueued { - ids = append(ids, t.ID.String()) - } + rows, err := s.q.ListQueuedTaskIDs(ctx, sid) + if err != nil { + return nil, err + } + ids := make([]string, 0, len(rows)) + for _, id := range rows { + ids = append(ids, id.String()) } return ids, nil } @@ -237,14 +229,6 @@ func (s *DBStore) TaskState(ctx context.Context, taskID string) (string, bool, e return task.State, true, nil } -func (s *DBStore) sessionTasks(ctx context.Context, sessionID string) ([]db.Task, error) { - sid, err := uuid.Parse(sessionID) - if err != nil { - return nil, err - } - return s.q.ListSessionTasks(ctx, sid) -} - // parseNullableUUID maps an empty string to a NULL pgtype.UUID and a valid // string to a set one. Invalid non-empty strings yield NULL rather than an // error — these fields (requested_by, anchor_message_id) are advisory. diff --git a/server/internal/agent/deuce_id_test.go b/server/internal/agent/deuce_id_test.go new file mode 100644 index 0000000..47d79b6 --- /dev/null +++ b/server/internal/agent/deuce_id_test.go @@ -0,0 +1,46 @@ +package agent + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// The deuce identity constants cross three layers (Go, the migration SQL, and +// the frontend TS constant) that no compiler links together — a typo'd nibble +// in any copy silently breaks message authorship and the chat visibility +// filter. This turns the "MUST match" comment contract into a checked one. +func TestDeuceIdentityConstantsMatchAcrossLayers(t *testing.T) { + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("cannot locate source file") + } + repoRoot := filepath.Join(filepath.Dir(thisFile), "..", "..", "..") + + cases := []struct { + path string + mustHave []string + }{ + { + path: filepath.Join(repoRoot, "src", "lib", "deuce.ts"), + mustHave: []string{DeuceAgentID, `name: "` + DeuceAgentName + `"`}, + }, + { + path: filepath.Join(repoRoot, "server", "internal", "db", "migrations", "013_single_deuce_agent.sql"), + mustHave: []string{DeuceAgentID}, + }, + } + for _, c := range cases { + raw, err := os.ReadFile(c.path) + if err != nil { + t.Fatalf("read %s: %v", c.path, err) + } + for _, want := range c.mustHave { + if !strings.Contains(string(raw), want) { + t.Errorf("%s does not contain %q — the deuce identity constants have drifted", c.path, want) + } + } + } +} diff --git a/server/internal/agent/pirun/supervisor.go b/server/internal/agent/pirun/supervisor.go index fdc0fbc..163fa62 100644 --- a/server/internal/agent/pirun/supervisor.go +++ b/server/internal/agent/pirun/supervisor.go @@ -17,9 +17,6 @@ type Key string func (k Key) String() string { return string(k) } -// SessionID returns the session the process belongs to. -func (k Key) SessionID() string { return string(k) } - // Handle is one launched Pi process's I/O plus lifecycle control. The real // implementation (devpodLauncher) wraps `devpod ssh --command "pi --mode rpc"`; // tests inject an in-memory fake. Stop must signal the process group @@ -117,7 +114,7 @@ func (p *Process) resetIdle() { // Supervisor owns the lifecycle of all Pi processes. Modeled on sshproxy.Server: // a base context cancelled on Shutdown, a WaitGroup draining the per-process -// pumps, and a registry keyed by (session, agent). +// pumps, and a registry keyed by session. type Supervisor struct { launcher Launcher apiKey string @@ -318,6 +315,11 @@ func (s *Supervisor) Stop(key Key) { if !ok { return } + stopHandle(p) +} + +// stopHandle signals one process (SIGTERM→SIGKILL) bounded by the stop grace. +func stopHandle(p *Process) { ctx, cancel := context.WithTimeout(context.Background(), stopGrace+2*time.Second) defer cancel() _ = p.h.Stop(ctx) @@ -334,13 +336,15 @@ func (s *Supervisor) LiveKeys() []Key { return keys } -// StopAndForget removes the registry entry synchronously, THEN signals the -// process. Deregistration before the signal means a racing Ensure launches a -// fresh process instead of adopting the dying one (a Stop'd process stays in -// the registry for seconds until the pump reaps it over devpod ssh). Used by -// the prompt-edit recycle path. The pump's registry delete is guarded by -// identity (cur == p), so the double-remove is safe; the Exit signal still -// fires so the scheduler can promote. +// StopAndForget removes the registry entry synchronously, then signals the +// process WITHOUT waiting for it to die. Deregistration before the signal +// means a racing Ensure launches a fresh process instead of adopting the +// dying one (a Stop'd process stays in the registry for seconds until the +// pump reaps it over devpod ssh); the async signal keeps the caller — the +// prompt-edit recycle inside an HTTP handler — from blocking on the stop +// grace per process. The pump's registry delete is guarded by identity +// (cur == p), so the double-remove is safe; the Exit signal still fires so +// the scheduler can promote. func (s *Supervisor) StopAndForget(key Key) { s.mu.Lock() p, ok := s.procs[key] @@ -351,9 +355,7 @@ func (s *Supervisor) StopAndForget(key Key) { if !ok { return } - ctx, cancel := context.WithTimeout(context.Background(), stopGrace+2*time.Second) - defer cancel() - _ = p.h.Stop(ctx) + go stopHandle(p) } // Shutdown stops every process and waits for the pumps to drain, bounded by ctx. diff --git a/server/internal/agent/runtime.go b/server/internal/agent/runtime.go index a6b775b..d5e054e 100644 --- a/server/internal/agent/runtime.go +++ b/server/internal/agent/runtime.go @@ -214,13 +214,13 @@ func (r *Runtime) RouteOrEnqueue(ctx context.Context, p EnqueueParams) (RouteRes // even if the DB resolve below fails (the next event reconciles). r.clearPending(taskID) r.exitAwaiting(key, taskID) - seq, rerr := r.store.ResolveAwaitingInput(ctx, key.SessionID(), taskID) + seq, rerr := r.store.ResolveAwaitingInput(ctx, string(key), taskID) if rerr != nil { slog.Error("runtime: resolve awaiting input", "task", taskID, "error", rerr) } else { r.broadcastTask(ws.TypeTaskStarted, ws.TaskEventPayload{ Seq: seq, TaskID: taskID, State: StateRunning, - }, key.SessionID()) + }, string(key)) } return RouteFed, nil } @@ -252,13 +252,13 @@ func (r *Runtime) promote(ctx context.Context, key pirun.Key) { // promoteLocked assumes the per-key lock is held. func (r *Runtime) promoteLocked(ctx context.Context, key pirun.Key) { - if _, busy, err := r.store.RunningTask(ctx, key.SessionID()); err != nil { + if _, busy, err := r.store.RunningTask(ctx, string(key)); err != nil { slog.Error("runtime: running-task lookup", "key", key.String(), "error", err) return } else if busy { return // one running task per session (R11) } - taskID, prompt, ok, err := r.store.NextQueuedTask(ctx, key.SessionID()) + taskID, prompt, ok, err := r.store.NextQueuedTask(ctx, string(key)) if err != nil { slog.Error("runtime: next-queued lookup", "key", key.String(), "error", err) return @@ -267,7 +267,7 @@ func (r *Runtime) promoteLocked(ctx context.Context, key pirun.Key) { return } - seq, err := r.store.MarkRunning(ctx, key.SessionID(), taskID) + seq, err := r.store.MarkRunning(ctx, string(key), taskID) if err != nil { slog.Error("runtime: mark running", "task", taskID, "error", err) return @@ -275,7 +275,7 @@ func (r *Runtime) promoteLocked(ctx context.Context, key pirun.Key) { r.setRunning(key, taskID) r.broadcastTask(ws.TypeTaskStarted, ws.TaskEventPayload{ Seq: seq, TaskID: taskID, State: StateRunning, - }, key.SessionID()) + }, string(key)) // Ensure the Pi process + its consumer, then send the prompt. Continuity // across a process restart is a tolerated v1 degradation — within a @@ -356,27 +356,27 @@ func (r *Runtime) translate(key pirun.Key, ev pirun.Event) { ctx := r.ctx switch ev.Kind { case pirun.KindToolStarted: - seq, err := r.store.AppendActionStarted(ctx, key.SessionID(), taskID, ev.ToolCallID, ev.Tool, ev.Arg) + seq, err := r.store.AppendActionStarted(ctx, string(key), taskID, ev.ToolCallID, ev.Tool, ev.Arg) if err != nil { slog.Error("runtime: append action", "task", taskID, "error", err) return } r.broadcastAction(ws.TypeActionStarted, ws.ActionEventPayload{ Seq: seq, TaskID: taskID, CallID: ev.ToolCallID, Tool: ev.Tool, Arg: ev.Arg, - }, key.SessionID()) + }, string(key)) case pirun.KindToolCompleted: - seq, err := r.store.CompleteAction(ctx, key.SessionID(), taskID, ev.ToolCallID, ev.Output, ev.IsError) + seq, err := r.store.CompleteAction(ctx, string(key), taskID, ev.ToolCallID, ev.Output, ev.IsError) if err != nil { slog.Error("runtime: complete action", "task", taskID, "error", err) return } r.broadcastAction(ws.TypeActionCompleted, ws.ActionEventPayload{ Seq: seq, TaskID: taskID, CallID: ev.ToolCallID, Tool: ev.Tool, Text: ev.Output, IsError: ev.IsError, - }, key.SessionID()) + }, string(key)) case pirun.KindAssistantText: r.appendReply(taskID, ev.Text) case pirun.KindAwaitingInput: - seq, err := r.store.SetAwaitingInput(ctx, key.SessionID(), taskID, ev.Prompt, ev.RequestKind, ev.Options) + seq, err := r.store.SetAwaitingInput(ctx, string(key), taskID, ev.Prompt, ev.RequestKind, ev.Options) if err != nil { slog.Error("runtime: set awaiting input", "task", taskID, "error", err) return @@ -386,7 +386,7 @@ func (r *Runtime) translate(key pirun.Key, ev pirun.Event) { r.broadcastTask(ws.TypeTaskAwaitingInput, ws.TaskEventPayload{ Seq: seq, TaskID: taskID, State: StateAwaitingInput, PendingQuestion: ev.Prompt, PendingQuestionKind: ev.RequestKind, PendingQuestionOptions: ev.Options, - }, key.SessionID()) + }, string(key)) case pirun.KindRunCompleted: unlock := r.keys.lock(key) // Pi finished cleanly: reuse the live process for the next task (promote @@ -442,7 +442,7 @@ func (r *Runtime) finalizeLocked(ctx context.Context, key pirun.Key, taskID, sta // (the ask-user extension didn't fire), rewrite it to the plain question // before it is persisted, broadcast, and posted to chat (R9/R11/R12). reply = sanitizeNarratedQuestion(reply) - seq, err := r.store.FinishTask(ctx, key.SessionID(), taskID, state, reply) + seq, err := r.store.FinishTask(ctx, string(key), taskID, state, reply) if err != nil { slog.Error("runtime: finish task", "task", taskID, "state", state, "error", err) return @@ -458,7 +458,7 @@ func (r *Runtime) finalizeLocked(ctx context.Context, key pirun.Key, taskID, sta } r.broadcastTask(ws.TypeTaskCompleted, ws.TaskEventPayload{ Seq: seq, TaskID: taskID, State: state, Status: state, Reply: reply, - }, key.SessionID()) + }, string(key)) slog.Info("runtime: task finalized", "key", key.String(), "task", taskID, "state", state, "replyLen", len(reply)) // Surface the result in the existing chat. For a done task with no streamed // text (e.g. the model produced only tool calls, or the run errored without @@ -469,7 +469,7 @@ func (r *Runtime) finalizeLocked(ctx context.Context, key pirun.Key, taskID, sta msg = "(The agent finished without a text response.)" } if msg != "" { - r.replyPoster(key.SessionID(), msg) + r.replyPoster(string(key), msg) } } } @@ -495,20 +495,20 @@ func (r *Runtime) CancelSession(ctx context.Context, sessionID string) { // chat notice for the running task is enough; queued-card state changes carry // the rest. Assumes the per-key lock is held. func (r *Runtime) cancelQueuedLocked(ctx context.Context, key pirun.Key) { - ids, err := r.store.QueuedTaskIDs(ctx, key.SessionID()) + ids, err := r.store.QueuedTaskIDs(ctx, string(key)) if err != nil { slog.Error("runtime: queued-task lookup", "key", key.String(), "error", err) return } for _, taskID := range ids { - seq, err := r.store.FinishTask(ctx, key.SessionID(), taskID, StateCancelled, "") + seq, err := r.store.FinishTask(ctx, string(key), taskID, StateCancelled, "") if err != nil { slog.Error("runtime: cancel queued task", "task", taskID, "error", err) continue } r.broadcastTask(ws.TypeTaskCompleted, ws.TaskEventPayload{ Seq: seq, TaskID: taskID, State: StateCancelled, Status: StateCancelled, - }, key.SessionID()) + }, string(key)) } } @@ -696,8 +696,8 @@ func (r *Runtime) broadcastAction(typ string, p ws.ActionEventPayload, sessionID r.bc.BroadcastToSession(sessionID, msg, nil) } -// keyedMutex provides a mutex per key so transitions on distinct (session, -// agent) keys run concurrently while a single key stays serial. +// keyedMutex provides a mutex per session key so transitions on distinct +// sessions run concurrently while a single session stays serial. type keyedMutex struct { mu sync.Mutex m map[pirun.Key]*sync.Mutex diff --git a/server/internal/agent/runtime_test.go b/server/internal/agent/runtime_test.go index dce1a14..669dfe4 100644 --- a/server/internal/agent/runtime_test.go +++ b/server/internal/agent/runtime_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "sort" "sync" "testing" "time" @@ -115,26 +116,16 @@ func (s *fakeStore) NextQueuedTask(_ context.Context, sessionID string) (string, func (s *fakeStore) QueuedTaskIDs(_ context.Context, sessionID string) ([]string, error) { s.mu.Lock() defer s.mu.Unlock() - type ot struct { - id string - order int - } - var queued []ot + var queued []*fakeTask for _, t := range s.tasks { if t.sessionID == sessionID && t.state == StateQueued { - queued = append(queued, ot{t.id, t.order}) + queued = append(queued, t) } } + sort.Slice(queued, func(i, j int) bool { return queued[i].order < queued[j].order }) ids := make([]string, 0, len(queued)) - for len(queued) > 0 { - bi := 0 - for i := range queued { - if queued[i].order < queued[bi].order { - bi = i - } - } - ids = append(ids, queued[bi].id) - queued = append(queued[:bi], queued[bi+1:]...) + for _, t := range queued { + ids = append(ids, t.id) } return ids, nil } diff --git a/server/internal/agent/store.go b/server/internal/agent/store.go index 2082d4b..dce7502 100644 --- a/server/internal/agent/store.go +++ b/server/internal/agent/store.go @@ -64,6 +64,11 @@ type Store interface { // stays reserved as the system-notice author sentinel. const DeuceAgentID = "00000000-0000-0000-0000-00000000000d" +// DeuceAgentName is the agent's name — the @mention token the server detects +// and the display name the frontend DEUCE constant mirrors. The DB row does +// not carry a name; this constant is the single server-side source. +const DeuceAgentName = "deuce" + // EnqueueParams describes a new task to enqueue. type EnqueueParams struct { SessionID string diff --git a/server/internal/db/agents.sql.go b/server/internal/db/agents.sql.go index 7ca7efa..f678d04 100644 --- a/server/internal/db/agents.sql.go +++ b/server/internal/db/agents.sql.go @@ -10,26 +10,27 @@ import ( ) const getDeuceAgent = `-- name: GetDeuceAgent :one -SELECT id, name, system_prompt FROM agents LIMIT 1 +SELECT id, system_prompt FROM agents LIMIT 1 ` // The agents table holds exactly one row — the built-in "deuce" agent -// (migration 013). Single-row read backs GET /api/agent and the runtime's -// launch-time system-prompt fetch. +// (migration 013; id + system_prompt only, identity renders from constants). +// Single-row read backs GET /api/agent and the runtime's launch-time +// system-prompt fetch. func (q *Queries) GetDeuceAgent(ctx context.Context) (Agent, error) { row := q.db.QueryRow(ctx, getDeuceAgent) var i Agent - err := row.Scan(&i.ID, &i.Name, &i.SystemPrompt) + err := row.Scan(&i.ID, &i.SystemPrompt) return i, err } const updateDeuceSystemPrompt = `-- name: UpdateDeuceSystemPrompt :one -UPDATE agents SET system_prompt = $1 RETURNING id, name, system_prompt +UPDATE agents SET system_prompt = $1 RETURNING id, system_prompt ` func (q *Queries) UpdateDeuceSystemPrompt(ctx context.Context, systemPrompt string) (Agent, error) { row := q.db.QueryRow(ctx, updateDeuceSystemPrompt, systemPrompt) var i Agent - err := row.Scan(&i.ID, &i.Name, &i.SystemPrompt) + err := row.Scan(&i.ID, &i.SystemPrompt) return i, err } diff --git a/server/internal/db/migrations/013_single_deuce_agent.sql b/server/internal/db/migrations/013_single_deuce_agent.sql index 6878967..2121eb3 100644 --- a/server/internal/db/migrations/013_single_deuce_agent.sql +++ b/server/internal/db/migrations/013_single_deuce_agent.sql @@ -33,11 +33,12 @@ WHERE author_type = 'agent' -- when actually implemented). DROP TABLE session_agents; --- 5. Reshape agents to the single built-in row: id + name + system_prompt. --- Role/color render from a frontend constant; provider/model are owned by --- DEUCE_PI_PROVIDER / DEUCE_PI_MODEL. +-- 5. Reshape agents to the single built-in row: id + system_prompt. Name and +-- color render from constants (agent.DeuceAgentName / the frontend DEUCE +-- constant); provider/model are owned by DEUCE_PI_PROVIDER / DEUCE_PI_MODEL. DELETE FROM agents; ALTER TABLE agents + DROP COLUMN name, DROP COLUMN role, DROP COLUMN color, DROP COLUMN color_muted, @@ -47,8 +48,8 @@ ALTER TABLE agents DROP COLUMN deleted_at, DROP COLUMN created_at, DROP COLUMN updated_at; -INSERT INTO agents (id, name, system_prompt) -VALUES ('00000000-0000-0000-0000-00000000000d', 'deuce', ''); +INSERT INTO agents (id, system_prompt) +VALUES ('00000000-0000-0000-0000-00000000000d', ''); -- 6. Mention plumbing is gone — the server parses @deuce from message content. ALTER TABLE messages DROP COLUMN mentions; @@ -68,6 +69,7 @@ ALTER TABLE messages ADD COLUMN mentions TEXT[] NOT NULL DEFAULT '{}'; -- (002 seed values + 004's empty system_prompt defaults). DELETE FROM agents; ALTER TABLE agents + ADD COLUMN name TEXT NOT NULL DEFAULT '', ADD COLUMN role TEXT NOT NULL DEFAULT '', ADD COLUMN color TEXT NOT NULL DEFAULT '', ADD COLUMN color_muted TEXT NOT NULL DEFAULT '', diff --git a/server/internal/db/models.go b/server/internal/db/models.go index 6d92950..d159735 100644 --- a/server/internal/db/models.go +++ b/server/internal/db/models.go @@ -22,7 +22,6 @@ type ActivityItem struct { type Agent struct { ID uuid.UUID `json:"id"` - Name string `json:"name"` SystemPrompt string `json:"system_prompt"` } diff --git a/server/internal/db/queries/agents.sql b/server/internal/db/queries/agents.sql index 27b6cbf..a3d437c 100644 --- a/server/internal/db/queries/agents.sql +++ b/server/internal/db/queries/agents.sql @@ -1,7 +1,8 @@ -- name: GetDeuceAgent :one -- The agents table holds exactly one row — the built-in "deuce" agent --- (migration 013). Single-row read backs GET /api/agent and the runtime's --- launch-time system-prompt fetch. +-- (migration 013; id + system_prompt only, identity renders from constants). +-- Single-row read backs GET /api/agent and the runtime's launch-time +-- system-prompt fetch. SELECT * FROM agents LIMIT 1; -- name: UpdateDeuceSystemPrompt :one diff --git a/server/internal/db/queries/tasks.sql b/server/internal/db/queries/tasks.sql index 07b26bd..d4dfe04 100644 --- a/server/internal/db/queries/tasks.sql +++ b/server/internal/db/queries/tasks.sql @@ -51,10 +51,33 @@ WHERE task_id = $1 AND call_id = $2; UPDATE task_actions SET status = 'interrupted' WHERE task_id = $1 AND status = 'started'; -- name: ListSessionTasks :many --- A session's tasks in creation order — drives queue-position derivation (R12) --- and promotion (R13) in the scheduler, plus the snapshot read. +-- A session's tasks in creation order — the snapshot read. SELECT * FROM tasks WHERE session_id = $1 ORDER BY created_at ASC; +-- The scheduler's hot-path lookups below are filtered server-side against the +-- (session_id, state) index from migration 013 instead of walking the +-- session's whole task history. + +-- name: GetActiveTaskID :many +-- The session's running-or-awaiting task (at most one, R11). LIMIT 1 + :many +-- instead of :one so "no active task" is an empty slice, not an error. +SELECT id FROM tasks +WHERE session_id = $1 AND state IN ('running', 'awaiting_input') +ORDER BY created_at ASC LIMIT 1; + +-- name: GetNextQueuedTask :many +SELECT id, prompt FROM tasks +WHERE session_id = $1 AND state = 'queued' +ORDER BY created_at ASC LIMIT 1; + +-- name: ListQueuedTaskIDs :many +SELECT id FROM tasks +WHERE session_id = $1 AND state = 'queued' +ORDER BY created_at ASC; + +-- name: CountQueuedTasks :one +SELECT count(*) FROM tasks WHERE session_id = $1 AND state = 'queued'; + -- name: ListTaskActions :many SELECT * FROM task_actions WHERE task_id = $1 ORDER BY seq ASC, created_at ASC; diff --git a/server/internal/db/tasks.sql.go b/server/internal/db/tasks.sql.go index 7af37c3..b0f1340 100644 --- a/server/internal/db/tasks.sql.go +++ b/server/internal/db/tasks.sql.go @@ -88,6 +88,17 @@ func (q *Queries) CompleteAction(ctx context.Context, arg CompleteActionParams) return err } +const countQueuedTasks = `-- name: CountQueuedTasks :one +SELECT count(*) FROM tasks WHERE session_id = $1 AND state = 'queued' +` + +func (q *Queries) CountQueuedTasks(ctx context.Context, sessionID uuid.UUID) (int64, error) { + row := q.db.QueryRow(ctx, countQueuedTasks, sessionID) + var count int64 + err := row.Scan(&count) + return count, err +} + const createTask = `-- name: CreateTask :one INSERT INTO tasks (session_id, requested_by, anchor_message_id, prompt, state, seq) VALUES ($1, $2, $3, $4, $5, $6) @@ -180,6 +191,69 @@ func (q *Queries) ForceResolveOpenActions(ctx context.Context, taskID uuid.UUID) return err } +const getActiveTaskID = `-- name: GetActiveTaskID :many + +SELECT id FROM tasks +WHERE session_id = $1 AND state IN ('running', 'awaiting_input') +ORDER BY created_at ASC LIMIT 1 +` + +// The scheduler's hot-path lookups below are filtered server-side against the +// (session_id, state) index from migration 013 instead of walking the +// session's whole task history. +// The session's running-or-awaiting task (at most one, R11). LIMIT 1 + :many +// instead of :one so "no active task" is an empty slice, not an error. +func (q *Queries) GetActiveTaskID(ctx context.Context, sessionID uuid.UUID) ([]uuid.UUID, error) { + rows, err := q.db.Query(ctx, getActiveTaskID, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []uuid.UUID{} + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getNextQueuedTask = `-- name: GetNextQueuedTask :many +SELECT id, prompt FROM tasks +WHERE session_id = $1 AND state = 'queued' +ORDER BY created_at ASC LIMIT 1 +` + +type GetNextQueuedTaskRow struct { + ID uuid.UUID `json:"id"` + Prompt string `json:"prompt"` +} + +func (q *Queries) GetNextQueuedTask(ctx context.Context, sessionID uuid.UUID) ([]GetNextQueuedTaskRow, error) { + rows, err := q.db.Query(ctx, getNextQueuedTask, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetNextQueuedTaskRow{} + for rows.Next() { + var i GetNextQueuedTaskRow + if err := rows.Scan(&i.ID, &i.Prompt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getTask = `-- name: GetTask :one SELECT id, session_id, requested_by, anchor_message_id, prompt, state, seq, pending_question, reply, work, created_at, updated_at, pending_question_kind, pending_question_options FROM tasks WHERE id = $1 ` @@ -225,6 +299,32 @@ func (q *Queries) IsSessionMember(ctx context.Context, arg IsSessionMemberParams return is_member, err } +const listQueuedTaskIDs = `-- name: ListQueuedTaskIDs :many +SELECT id FROM tasks +WHERE session_id = $1 AND state = 'queued' +ORDER BY created_at ASC +` + +func (q *Queries) ListQueuedTaskIDs(ctx context.Context, sessionID uuid.UUID) ([]uuid.UUID, error) { + rows, err := q.db.Query(ctx, listQueuedTaskIDs, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []uuid.UUID{} + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listSessionTaskActions = `-- name: ListSessionTaskActions :many SELECT ta.id, ta.task_id, ta.call_id, ta.seq, ta.tool, ta.arg, ta.note, ta.text, ta.stat, ta.diff, ta.out, ta.status, ta.created_at FROM task_actions ta JOIN tasks t ON t.id = ta.task_id @@ -272,8 +372,7 @@ const listSessionTasks = `-- name: ListSessionTasks :many SELECT id, session_id, requested_by, anchor_message_id, prompt, state, seq, pending_question, reply, work, created_at, updated_at, pending_question_kind, pending_question_options FROM tasks WHERE session_id = $1 ORDER BY created_at ASC ` -// A session's tasks in creation order — drives queue-position derivation (R12) -// and promotion (R13) in the scheduler, plus the snapshot read. +// A session's tasks in creation order — the snapshot read. func (q *Queries) ListSessionTasks(ctx context.Context, sessionID uuid.UUID) ([]Task, error) { rows, err := q.db.Query(ctx, listSessionTasks, sessionID) if err != nil { diff --git a/server/internal/handler/agents.go b/server/internal/handler/agents.go index 0c5767b..8c8fd79 100644 --- a/server/internal/handler/agents.go +++ b/server/internal/handler/agents.go @@ -5,12 +5,11 @@ import ( "net/http" ) -// agentSettingsResponse is the GET/PUT /api/agent shape: the single built-in -// deuce agent's identity and configurable system prompt. Name/color render -// from the frontend DEUCE constant; only the prompt is editable. +// agentSettingsResponse is the GET/PUT /api/agent shape: deuce's configurable +// system prompt. Identity (id/name/color) renders from constants +// (agent.DeuceAgentID / DeuceAgentName, the frontend DEUCE constant) and is +// not part of the contract. type agentSettingsResponse struct { - ID string `json:"id"` - Name string `json:"name"` SystemPrompt string `json:"systemPrompt"` } @@ -22,9 +21,7 @@ func (h *Handler) GetAgentSettings(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to load agent settings") return } - writeJSON(w, http.StatusOK, agentSettingsResponse{ - ID: ag.ID.String(), Name: ag.Name, SystemPrompt: ag.SystemPrompt, - }) + writeJSON(w, http.StatusOK, agentSettingsResponse{SystemPrompt: ag.SystemPrompt}) } type updateAgentSettingsRequest struct { @@ -54,7 +51,5 @@ func (h *Handler) UpdateAgentSettings(w http.ResponseWriter, r *http.Request) { h.runtime.RecycleIdleProcesses() } - writeJSON(w, http.StatusOK, agentSettingsResponse{ - ID: ag.ID.String(), Name: ag.Name, SystemPrompt: ag.SystemPrompt, - }) + writeJSON(w, http.StatusOK, agentSettingsResponse{SystemPrompt: ag.SystemPrompt}) } diff --git a/server/internal/handler/messages.go b/server/internal/handler/messages.go index 998a0cd..e98dd83 100644 --- a/server/internal/handler/messages.go +++ b/server/internal/handler/messages.go @@ -50,7 +50,23 @@ func toMessageResponse(m db.Message) messageResponse { // (R5). The left guard (start-of-string or a non-word character) keeps email // addresses like clint@deuce.dev from triggering; the trailing \b keeps // near-misses like @deucebot from triggering. Case-insensitive. -var deuceMentionRE = regexp.MustCompile(`(?i)(^|\W)@deuce\b`) +var deuceMentionRE = regexp.MustCompile(`(?i)(^|\W)@` + agentpkg.DeuceAgentName + `\b`) + +// gateWorkspaceForAgent reports whether the session's workspace can run the +// agent, posting the appropriate system notice when it cannot. Shared by the +// chat mention path and the drawer steer path (R8) so the gate and its +// user-facing copy cannot drift. +func (h *Handler) gateWorkspaceForAgent(sessionID uuid.UUID, workspaceStatus string) bool { + switch workspaceStatus { + case "ready": + return true + case "starting": + h.postSystemMessage(sessionID, "Workspace is still starting — your agent request will run when it's ready.") + case "failed", "suspended": + h.postSystemMessage(sessionID, "Workspace is not available. Please restart the workspace before using the agent.") + } + return false +} // isStopCommand reports whether a message is the exact /stop command (R6). // Exact match only — "@deuce make the flicker stop" must enqueue work, not @@ -203,23 +219,16 @@ func (h *Handler) SendMessage(w http.ResponseWriter, r *http.Request) { // @deuce mention → enqueue a task through the Pi runtime (R5). if deuceMentionRE.MatchString(req.Content) && h.runtime != nil { session, err := h.queries.GetSession(r.Context(), sessionID) - if err == nil { - switch session.WorkspaceStatus { - case "starting": - h.postSystemMessage(sessionID, "Workspace is still starting — your agent request will run when it's ready.") - case "failed", "suspended": - h.postSystemMessage(sessionID, "Workspace is not available. Please restart the workspace before using the agent.") - case "ready": - if _, err := h.runtime.Enqueue(r.Context(), agentpkg.EnqueueParams{ - SessionID: sessionID.String(), - RequestedBy: userID.String(), - AnchorMessageID: msg.ID.String(), - Prompt: req.Content, - WorkspaceID: session.Name, - }); err != nil { - slog.Error("failed to enqueue agent task", "error", err) - h.postSystemMessage(sessionID, "Could not start the agent. Please try again.") - } + if err == nil && h.gateWorkspaceForAgent(sessionID, session.WorkspaceStatus) { + if _, err := h.runtime.Enqueue(r.Context(), agentpkg.EnqueueParams{ + SessionID: sessionID.String(), + RequestedBy: userID.String(), + AnchorMessageID: msg.ID.String(), + Prompt: req.Content, + WorkspaceID: session.Name, + }); err != nil { + slog.Error("failed to enqueue agent task", "error", err) + h.postSystemMessage(sessionID, "Could not start the agent. Please try again.") } } } @@ -300,4 +309,3 @@ func (h *Handler) postSystemMessage(sessionID uuid.UUID, content string) { wsMsg, _ := ws.NewServerMessage(ws.TypeNewMessage, sessionID.String(), toMessageResponse(msg)) h.hub.BroadcastToSession(sessionID.String(), wsMsg, nil) } - diff --git a/server/internal/handler/websocket.go b/server/internal/handler/websocket.go index 312125a..4686201 100644 --- a/server/internal/handler/websocket.go +++ b/server/internal/handler/websocket.go @@ -81,20 +81,13 @@ func (h *Handler) handleSteer(c *ws.Client, sessionID, message string) { } session, err := h.queries.GetSession(ctx, sid) - if err != nil { + if err != nil || !h.gateWorkspaceForAgent(sid, session.WorkspaceStatus) { return } - switch session.WorkspaceStatus { - case "starting": - h.postSystemMessage(sid, "Workspace is still starting — your agent request will run when it's ready.") - case "failed", "suspended": - h.postSystemMessage(sid, "Workspace is not available. Please restart the workspace before using the agent.") - case "ready": - _, _ = h.runtime.RouteOrEnqueue(ctx, agentpkg.EnqueueParams{ - SessionID: sessionID, - RequestedBy: c.UserID, - Prompt: message, - WorkspaceID: session.Name, - }) - } + _, _ = h.runtime.RouteOrEnqueue(ctx, agentpkg.EnqueueParams{ + SessionID: sessionID, + RequestedBy: c.UserID, + Prompt: message, + WorkspaceID: session.Name, + }) } diff --git a/server/internal/ws/events.go b/server/internal/ws/events.go index 51644fd..b549e4a 100644 --- a/server/internal/ws/events.go +++ b/server/internal/ws/events.go @@ -68,9 +68,9 @@ type TaskEventPayload struct { // ActionEventPayload is the JSON payload for action_started / action_completed. type ActionEventPayload struct { - Seq int64 `json:"seq"` - TaskID string `json:"taskId"` - CallID string `json:"callId"` + Seq int64 `json:"seq"` + TaskID string `json:"taskId"` + CallID string `json:"callId"` Tool string `json:"tool,omitempty"` Arg string `json:"arg,omitempty"` Text string `json:"text,omitempty"` From 737a0fc768e3843b568e307dc467390ec57c262a Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Wed, 10 Jun 2026 02:34:33 +0000 Subject: [PATCH 4/6] refactor(web): collapse frontend to the single deuce agent The DEUCE constant (src/lib/deuce.ts, id matching the Go constant and migration 013) carries the agent's identity; Session.agents, the Agent type, mention parsing, and the legacy agent_status/typing_indicator/ agent_output handling are gone. The chat visibility filter pins agent- typed messages to hidden unless nil-UUID system notices. Stop relocates to the running task card and the thread-drawer header via a shared atom. The drawer is the session's single deuce thread; the summary panel and drawer derive the agent's status from task state. The settings dialog becomes a single system-prompt editor disclosing global scope, next-launch staleness, and save errors. The session-create agent picker, role color tokens, and the unreferenced src/mocks prototype data are removed. Wires vitest (npm test) for the pure-logic suites. --- package-lock.json | 364 +++++++- package.json | 6 +- src/components/chat/ChatView.tsx | 220 +---- .../chat/message-visibility.test.ts | 56 +- src/components/chat/message-visibility.ts | 35 +- src/components/layout/SummaryPanel.tsx | 55 +- .../session/CreateSessionDialog.tsx | 68 +- .../settings/AgentSettingsDialog.tsx | 386 ++------ .../super-threads/AgentTaskCard.tsx | 44 +- .../super-threads/AgentThreadDrawer.tsx | 78 +- .../super-threads/ThreadDrawerPanel.tsx | 17 +- src/components/super-threads/atoms.tsx | 45 +- src/hooks/use-websocket.ts | 58 +- src/lib/api.ts | 43 +- src/lib/deuce.ts | 15 + src/mocks/data/seed.ts | 858 ------------------ src/stores/agent-runs.test.ts | 55 +- src/stores/agent-runs.ts | 48 +- src/stores/session-store.ts | 90 +- src/styles/globals.css | 30 +- src/types/index.ts | 23 +- 21 files changed, 823 insertions(+), 1771 deletions(-) create mode 100644 src/lib/deuce.ts delete mode 100644 src/mocks/data/seed.ts diff --git a/package-lock.json b/package-lock.json index d7b8e99..2754558 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,8 @@ "globals": "^17.5.0", "typescript": "~6.0.2", "typescript-eslint": "^8.58.2", - "vite": "^8.0.10" + "vite": "^8.0.10", + "vitest": "^4.1.8" } }, "node_modules/@babel/code-frame": { @@ -2510,6 +2511,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", @@ -2942,6 +2950,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -2951,6 +2970,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -3324,6 +3350,119 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xterm/addon-fit": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", @@ -3397,6 +3536,16 @@ "node": ">=10" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -3508,6 +3657,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -3707,6 +3866,13 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3912,6 +4078,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3922,6 +4098,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -5578,6 +5764,20 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", + "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5673,6 +5873,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6136,6 +6343,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6155,6 +6369,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -6216,6 +6444,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -6232,6 +6477,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -6614,6 +6869,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6630,6 +6975,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 4b00ac6..6d16961 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "@radix-ui/react-slot": "^1.2.4", @@ -43,6 +44,7 @@ "globals": "^17.5.0", "typescript": "~6.0.2", "typescript-eslint": "^8.58.2", - "vite": "^8.0.10" + "vite": "^8.0.10", + "vitest": "^4.1.8" } } diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index b1bd393..9759e96 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -2,7 +2,6 @@ import { useState, useRef, useEffect } from "react"; import { SendHorizontal, Bot, - Square, Play, RefreshCw, AlertCircle, @@ -18,7 +17,8 @@ import { useSessionStore } from "@/stores/session-store"; import { tasksByAnchor, queuePositions } from "@/stores/agent-runs"; import { AgentTaskCard } from "@/components/super-threads/AgentTaskCard"; import { visibleChatMessages } from "@/components/chat/message-visibility"; -import type { Agent, Message, User, Session, WorkspaceStatus } from "@/types"; +import { DEUCE } from "@/lib/deuce"; +import type { Message, User, Session, WorkspaceStatus } from "@/types"; function isWorkspaceLive(status: WorkspaceStatus | undefined): boolean { return status === "ready" || status === "starting"; @@ -183,92 +183,19 @@ function JoinSessionGate({ session }: { session: Session }) { ); } -function TypingIndicator({ - agentName, - agentColor, - sessionId, - streamingOutput, -}: { - agentName: string; - agentColor: string; - sessionId: string; - streamingOutput: { content: string; contentType: string }[]; -}) { - const streamEndRef = useRef(null); - - useEffect(() => { - streamEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [streamingOutput.length]); - - const handleStop = async () => { - try { - await api.stopAgent(sessionId); - } catch (err) { - console.error("Failed to stop agent:", err); - } - }; - - return ( -
-
-
- {agentName[0]} -
- {agentName} is working -
- - - -
- -
- - {/* Streaming output */} - {streamingOutput.length > 0 && ( -
- {streamingOutput.map((item, i) => ( - - {item.contentType === "tool_use" ? `[${item.content}] ` : item.content} - - ))} -
-
- )} -
- ); -} - function MessageBubble({ message, author, showHeader, }: { message: Message; - author: Agent | User | undefined; + author: User | undefined; showHeader: boolean; }) { const [expandedItems, setExpandedItems] = useState>(new Set()); - const isAgent = message.authorType === "agent"; - const agentAuthor = isAgent ? (author as Agent) : undefined; + // The only agent-typed messages that pass the visibility filter are system + // notices (nil author) — deuce's task replies render on the task cards. + const isSystem = message.authorType === "agent"; const toggleExpand = (index: number) => { setExpandedItems((prev) => { @@ -283,35 +210,32 @@ function MessageBubble({
{showHeader && (
- {isAgent && agentAuthor ? ( -
- {agentAuthor.name[0]} + {isSystem ? ( +
+
) : ( )} - {isAgent ? agentAuthor?.name : (author as User)?.name} + {isSystem ? "system" : author?.name} {new Date(message.createdAt).toLocaleTimeString([], { @@ -353,8 +277,6 @@ export function ChatView() { activeSessionId, sessions, messages, - thinkingAgents, - agentOutput, addMessage, agentRuns, openAgentThread, @@ -369,22 +291,11 @@ export function ChatView() { const sessionMessages = activeSessionId ? (messages[activeSessionId] ?? []) : []; - const thinking = activeSessionId - ? (thinkingAgents[activeSessionId] ?? []) - : []; - const streamOutput = activeSessionId - ? (agentOutput[activeSessionId] ?? []) - : []; - - const allParticipants = session - ? [...session.agents, ...session.members] - : []; - // Agent task replies render on the super-thread surfaces (inline card + + // Deuce's task replies render on the super-thread surfaces (inline card + // drawer), not as chat bubbles. System notices (agent-typed, nil author) - // are not in agentIds and stay visible. - const agentIds = new Set(session?.agents.map((a) => a.id) ?? []); - const visibleMessages = visibleChatMessages(sessionMessages, agentIds); + // stay visible. + const visibleMessages = visibleChatMessages(sessionMessages); // Super Threads: inline task cards anchored to the message that spawned them. const sessionRuns = activeSessionId ? agentRuns[activeSessionId] : undefined; @@ -403,7 +314,7 @@ export function ChatView() { if (isNearBottomRef.current) { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); } - }, [sessionMessages.length, thinking.length]); + }, [sessionMessages.length]); // Always scroll to bottom on session switch useEffect(() => { @@ -418,22 +329,10 @@ export function ChatView() { const content = input.trim(); setInput(""); - // Detect @mentions — match agent names to IDs - const mentionMatch = content.match(/@(\w+)/g); - const mentions: string[] = []; - if (mentionMatch) { - for (const mention of mentionMatch) { - const agentName = mention.slice(1); - const agent = session.agents.find( - (a) => a.name.toLowerCase() === agentName.toLowerCase(), - ); - if (agent) mentions.push(agent.id); - } - } - try { - // POST to API — server handles persistence, broadcasting, and agent responses - const msg = await api.sendMessage(activeSessionId, { content, mentions }); + // POST to API — the server persists, broadcasts, and detects the @deuce + // mention itself (no client-side mention parsing). + const msg = await api.sendMessage(activeSessionId, { content }); // Add our own message locally (server broadcasts to OTHER clients) addMessage(msg); } catch (err) { @@ -474,17 +373,22 @@ export function ChatView() { Start a conversation

- @mention an agent to get started. - {session?.agents.map((a) => ( + Mention + {isMember && workspaceLive ? ( - ))} + ) : ( + @{DEUCE.name} + )} + to bring the agent into the conversation.

)} @@ -499,11 +403,10 @@ export function ChatView() { 300000; const author = - msg.authorType === "agent" - ? session?.agents.find((a) => a.id === msg.authorId) - : allParticipants.find( - (p) => "email" in p && p.id === msg.authorId, - ) ?? session?.members.find((m) => m.id === msg.authorId); + msg.authorType === "human" + ? (session?.members.find((m) => m.id === msg.authorId) ?? + (currentUser?.id === msg.authorId ? currentUser : undefined)) + : undefined; const anchoredTasks = cardsByAnchor[msg.id] ?? []; @@ -514,44 +417,21 @@ export function ChatView() { author={author} showHeader={showHeader} /> - {anchoredTasks.map((task) => { - const taskAgent = session?.agents.find( - (a) => a.id === task.agentId, - ); - if (!taskAgent) return null; - return ( - - activeSessionId && - openAgentThread(activeSessionId, taskAgent.id) - } - /> - ); - })} + {anchoredTasks.map((task) => ( + + activeSessionId && openAgentThread(activeSessionId) + } + /> + ))}
); })} - {/* Typing indicators with streaming output */} - {thinking.map((agentId) => { - const agent = session?.agents.find((a) => a.id === agentId); - if (!agent) return null; - const agentStream = streamOutput.filter((o) => o.agentId === agentId); - return ( - - ); - })} -
@@ -581,7 +461,7 @@ export function ChatView() { value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} - placeholder="Message (@ to mention an agent)" + placeholder="Message (@deuce to bring in the agent)" rows={1} className="flex-1 resize-none rounded-md border border-border-muted bg-background-input px-3 py-2 text-sm text-foreground placeholder:text-foreground-subtle focus:border-accent focus:outline-none" /> diff --git a/src/components/chat/message-visibility.test.ts b/src/components/chat/message-visibility.test.ts index abda97c..2ba35e0 100644 --- a/src/components/chat/message-visibility.test.ts +++ b/src/components/chat/message-visibility.test.ts @@ -1,13 +1,8 @@ import { describe, expect, it } from "vitest"; import { isVisibleInChat, visibleChatMessages } from "./message-visibility"; +import { DEUCE, SYSTEM_AUTHOR_ID } from "@/lib/deuce"; import type { Message } from "@/types"; -// NOTE: The frontend has no test runner wired up yet (see CLAUDE.md). These -// Vitest-style specs capture the intended behavior and run as soon as a runner -// is added. Until then, `npx tsc --noEmit` keeps them type-checked. - -const NIL_UUID = "00000000-0000-0000-0000-000000000000"; -const AGENT_ID = "a1111111-1111-1111-1111-111111111111"; const USER_ID = "u1111111-1111-1111-1111-111111111111"; function msg(overrides: Partial): Message { @@ -17,7 +12,6 @@ function msg(overrides: Partial): Message { authorId: USER_ID, authorType: "human", content: "hello", - mentions: [], createdAt: "2026-06-08T12:00:00Z", status: "sent", ...overrides, @@ -25,54 +19,46 @@ function msg(overrides: Partial): Message { } describe("isVisibleInChat", () => { - it("keeps human messages visible regardless of agentIds", () => { - const m = msg({ authorType: "human", authorId: USER_ID }); - expect(isVisibleInChat(m, new Set())).toBe(true); - expect(isVisibleInChat(m, new Set([AGENT_ID, USER_ID]))).toBe(true); + it("keeps human messages visible", () => { + expect(isVisibleInChat(msg({ authorType: "human" }))).toBe(true); }); - it("hides an agent reply whose author is a session agent", () => { - const m = msg({ authorType: "agent", authorId: AGENT_ID }); - expect(isVisibleInChat(m, new Set([AGENT_ID]))).toBe(false); + it("hides deuce's task replies", () => { + const m = msg({ authorType: "agent", authorId: DEUCE.id }); + expect(isVisibleInChat(m)).toBe(false); }); it("keeps system notices visible (agent-typed, nil author ID)", () => { - const m = msg({ authorType: "agent", authorId: NIL_UUID }); - expect(isVisibleInChat(m, new Set([AGENT_ID]))).toBe(true); + const m = msg({ authorType: "agent", authorId: SYSTEM_AUTHOR_ID }); + expect(isVisibleInChat(m)).toBe(true); }); - it("keeps agent-typed messages visible when the author is not a session agent", () => { - // Defensive: e.g. the agent was removed from the session. + it("hides agent-typed messages with an unexpected legacy author", () => { + // Post-migration these shouldn't exist (013 repoints history to DEUCE.id); + // hiding is the safe shape — an unknown agent author must not surface a + // duplicate reply in chat. const m = msg({ authorType: "agent", authorId: "gone-agent" }); - expect(isVisibleInChat(m, new Set([AGENT_ID]))).toBe(true); - }); - - it("hides nothing when agentIds is empty", () => { - const m = msg({ authorType: "agent", authorId: AGENT_ID }); - expect(isVisibleInChat(m, new Set())).toBe(true); + expect(isVisibleInChat(m)).toBe(false); }); }); + describe("visibleChatMessages", () => { it("returns only visible messages in original order without mutating input", () => { const human1 = msg({ id: "m1", authorType: "human", authorId: USER_ID }); - const reply = msg({ id: "m2", authorType: "agent", authorId: AGENT_ID }); - const notice = msg({ id: "m3", authorType: "agent", authorId: NIL_UUID }); + const reply = msg({ id: "m2", authorType: "agent", authorId: DEUCE.id }); + const notice = msg({ + id: "m3", + authorType: "agent", + authorId: SYSTEM_AUTHOR_ID, + }); const human2 = msg({ id: "m4", authorType: "human", authorId: USER_ID }); const input = [human1, reply, notice, human2]; - const out = visibleChatMessages(input, new Set([AGENT_ID])); + const out = visibleChatMessages(input); expect(out).toEqual([human1, notice, human2]); expect(input).toHaveLength(4); expect(input[1]).toBe(reply); }); - - it("returns everything when agentIds is empty", () => { - const input = [ - msg({ id: "m1", authorType: "agent", authorId: AGENT_ID }), - msg({ id: "m2", authorType: "human", authorId: USER_ID }), - ]; - expect(visibleChatMessages(input, new Set())).toEqual(input); - }); }); diff --git a/src/components/chat/message-visibility.ts b/src/components/chat/message-visibility.ts index fb994a7..ef79774 100644 --- a/src/components/chat/message-visibility.ts +++ b/src/components/chat/message-visibility.ts @@ -1,25 +1,22 @@ import type { Message } from "@/types"; +import { SYSTEM_AUTHOR_ID } from "@/lib/deuce"; -// Agent task replies are surfaced on the super-thread surfaces (the inline -// AgentTaskCard and the agent thread drawer), so their chat-bubble copy is -// hidden from the main chat list. System/operational notices are posted with -// authorType "agent" but a nil author ID that matches no session agent -// (postSystemMessage in server/internal/handler/messages.go) — they fall -// through the agentIds check and stay visible, as do all human messages. -export function isVisibleInChat( - message: Message, - agentIds: Set, -): boolean { - return !(message.authorType === "agent" && agentIds.has(message.authorId)); +// Deuce's task replies are surfaced on the super-thread surfaces (the inline +// AgentTaskCard and the thread drawer), so their chat-bubble copy is hidden +// from the main chat list. System/operational notices are posted with +// authorType "agent" but the nil author ID (postSystemMessage in +// server/internal/handler/messages.go) — they stay visible, as do all human +// messages. Agent-typed messages with any other author should not exist +// post-migration (013 repoints history to DEUCE.id), but hide them too so an +// unexpected author can't leak a duplicate reply into chat. +export function isVisibleInChat(message: Message): boolean { + return !( + message.authorType === "agent" && message.authorId !== SYSTEM_AUTHOR_ID + ); } // visibleChatMessages filters a message list for the main chat surface, -// preserving order and never mutating the input. agentIds is the set of the -// session's agent IDs (derived from session.agents by the caller — this -// module stays free of store/session imports). -export function visibleChatMessages( - messages: Message[], - agentIds: Set, -): Message[] { - return messages.filter((m) => isVisibleInChat(m, agentIds)); +// preserving order and never mutating the input. +export function visibleChatMessages(messages: Message[]): Message[] { + return messages.filter(isVisibleInChat); } diff --git a/src/components/layout/SummaryPanel.tsx b/src/components/layout/SummaryPanel.tsx index 497ba22..5f9be86 100644 --- a/src/components/layout/SummaryPanel.tsx +++ b/src/components/layout/SummaryPanel.tsx @@ -4,34 +4,44 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { cn } from "@/lib/utils"; import { useSessionStore } from "@/stores/session-store"; +import { deuceStatus, type DeuceStatus } from "@/stores/agent-runs"; +import { DEUCE } from "@/lib/deuce"; import { ActivityFeed } from "@/components/activity/ActivityFeed"; import { ManageMembersDialog } from "@/components/session/ManageMembersDialog"; -import type { Agent, User } from "@/types"; +import type { User } from "@/types"; -function AgentRow({ agent }: { agent: Agent }) { +// DeuceRow shows the one built-in agent. Status derives from task state (the +// agentRuns reducer) — working while a task runs, waiting on a pending +// question, idle otherwise. +function DeuceRow({ status }: { status: DeuceStatus }) { const statusStyles = { idle: "bg-neutral-8", working: "bg-success animate-pulse-dot", - "warming-up": "bg-warning", - error: "bg-danger", - }[agent.status]; + waiting: "bg-warning animate-pulse-dot", + }[status]; + const label = + status === "working" + ? "working" + : status === "waiting" + ? "needs input" + : "idle"; return (
- {agent.name[0]} + {DEUCE.name[0]}
- {agent.name} + {DEUCE.name}
- {agent.description} + {label}
); @@ -61,7 +71,7 @@ function UserRow({ user }: { user: User }) { } export function SummaryPanel() { - const { activeSessionId, sessions, activities } = useSessionStore(); + const { activeSessionId, sessions, activities, agentRuns } = useSessionStore(); const [membersOpen, setMembersOpen] = useState(false); if (!activeSessionId) { @@ -78,6 +88,7 @@ export function SummaryPanel() { if (!session) return null; const sessionActivities = activities[activeSessionId] ?? []; + const status = deuceStatus(agentRuns[activeSessionId]); return (
@@ -89,24 +100,20 @@ export function SummaryPanel() { Participants - ({session.agents.length + session.members.length}) + ({session.members.length + 1})
- {/* Agents */} - {session.agents.length > 0 && ( -
-
- - Agents -
-
- {session.agents.map((agent) => ( - - ))} -
+ {/* The built-in agent */} +
+
+ + Agent +
+
+
- )} +
{/* Humans */}
diff --git a/src/components/session/CreateSessionDialog.tsx b/src/components/session/CreateSessionDialog.tsx index f1d2f17..15b0017 100644 --- a/src/components/session/CreateSessionDialog.tsx +++ b/src/components/session/CreateSessionDialog.tsx @@ -27,14 +27,6 @@ interface GitHubRepo { defaultBranch: string; } -interface AgentPreset { - id: string; - name: string; - role: string; - color: string; - description: string; -} - function slugify(input: string): string { return input .toLowerCase() @@ -74,11 +66,6 @@ export function CreateSessionDialog({ const [selectedRepo, setSelectedRepo] = useState(null); const [repoSearch, setRepoSearch] = useState(""); - // Agents - const [agents, setAgents] = useState([]); - const [selectedAgentIds, setSelectedAgentIds] = useState>( - new Set(), - ); const [creating, setCreating] = useState(false); const [error, setError] = useState(null); @@ -98,7 +85,7 @@ export function CreateSessionDialog({ ); }, [repos, repoSearch]); - // Load orgs and agents when dialog opens + // Load orgs when dialog opens useEffect(() => { if (!open) return; @@ -117,14 +104,6 @@ export function CreateSessionDialog({ }) .catch(() => {}) .finally(() => setOrgsLoading(false)); - - api.listAgents().then((agentList) => { - setAgents(agentList); - const coder = agentList.find((a: AgentPreset) => a.role === "coder"); - if (coder) { - setSelectedAgentIds(new Set([coder.id])); - } - }); }, [open]); // Load repos when org changes @@ -147,15 +126,6 @@ export function CreateSessionDialog({ .finally(() => setReposLoading(false)); }, [selectedOrg]); - const toggleAgent = (id: string) => { - setSelectedAgentIds((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; - const handleCreate = async () => { if (!slugValid || !selectedRepo) return; setCreating(true); @@ -174,7 +144,6 @@ export function CreateSessionDialog({ description: description.trim(), projectId, repoUrl: selectedRepo.cloneUrl, - agentIds: Array.from(selectedAgentIds), memberIds: [], }); @@ -197,7 +166,6 @@ export function CreateSessionDialog({ setSelectedOrg(null); setSelectedRepo(null); setRepoSearch(""); - setSelectedAgentIds(new Set()); setError(null); }; @@ -412,40 +380,6 @@ export function CreateSessionDialog({ )}
- {/* Agents */} -
- -
- {agents.map((agent) => ( - - ))} -
-
- {/* Error */} {error &&

{error}

} diff --git a/src/components/settings/AgentSettingsDialog.tsx b/src/components/settings/AgentSettingsDialog.tsx index d7b0595..0b7a36c 100644 --- a/src/components/settings/AgentSettingsDialog.tsx +++ b/src/components/settings/AgentSettingsDialog.tsx @@ -1,5 +1,11 @@ +// AgentSettingsDialog — edits deuce's system prompt. There is one built-in +// agent; its prompt is a GLOBAL setting (it shapes deuce in every session, +// not just the one this dialog was opened from), and Pi applies it only at +// process launch — idle sessions pick an edit up on their next task, sessions +// mid-task on their next process launch. + import { useState, useEffect } from "react"; -import { Bot, Plus, Pencil, Trash2, X } from "lucide-react"; +import { Loader2 } from "lucide-react"; import { Dialog, DialogContent, @@ -7,23 +13,8 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Separator } from "@/components/ui/separator"; -import { cn } from "@/lib/utils"; -import { api } from "@/lib/api"; -import type { Agent } from "@/types"; - -// AgentDraft mirrors Agent but drops the status field, which is server-managed -// (idle/working/etc.) and never edited from this dialog. -type AgentDraft = Omit; - -const MODEL_OPTIONS = [ - "claude-opus-4-6", - "claude-sonnet-4-6", - "claude-haiku-4-5", -]; +import { api, ApiError } from "@/lib/api"; +import { DEUCE } from "@/lib/deuce"; export function AgentSettingsDialog({ open, @@ -32,304 +23,107 @@ export function AgentSettingsDialog({ open: boolean; onOpenChange: (open: boolean) => void; }) { - const [agents, setAgents] = useState([]); - const [editing, setEditing] = useState(null); - const [isNew, setIsNew] = useState(false); - const [deleting, setDeleting] = useState(null); + const [prompt, setPrompt] = useState(""); const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); useEffect(() => { - if (open) loadAgents(); + if (!open) return; + // eslint-disable-next-line react-hooks/set-state-in-effect + setLoading(true); + setError(null); + setSaved(false); + api + .getAgentSettings() + .then((settings) => setPrompt(settings.systemPrompt)) + .catch((err) => + setError( + err instanceof ApiError ? err.message : "Failed to load settings.", + ), + ) + .finally(() => setLoading(false)); }, [open]); - async function loadAgents() { - try { - const data = await api.listAgents(); - setAgents(data); - } catch (err) { - console.error("Failed to load agents:", err); - } - } - - function startCreate() { - setEditing({ - id: "", - name: "", - role: "", - color: "", - colorMuted: "", - provider: "Anthropic", - model: "claude-sonnet-4-6", - description: "", - systemPrompt: "", - }); - setIsNew(true); - } - - function startEdit(agent: Agent) { - setEditing({ ...agent }); - setIsNew(false); - } - - function cancelEdit() { - setEditing(null); - setIsNew(false); - } - - async function saveAgent() { - if (!editing || !editing.name.trim()) return; - setLoading(true); + async function save() { + if (saving) return; + setSaving(true); + setError(null); + setSaved(false); try { - if (isNew) { - await api.createAgent({ - name: editing.name, - role: editing.role, - provider: editing.provider, - model: editing.model, - description: editing.description, - systemPrompt: editing.systemPrompt, - }); - } else { - await api.updateAgent(editing.id, { - name: editing.name, - role: editing.role, - provider: editing.provider, - model: editing.model, - description: editing.description, - systemPrompt: editing.systemPrompt, - }); - } - await loadAgents(); - setEditing(null); - setIsNew(false); + const settings = await api.updateAgentSettings(prompt); + setPrompt(settings.systemPrompt); + setSaved(true); } catch (err) { - console.error("Failed to save agent:", err); + setError( + err instanceof ApiError + ? err.message + : "Failed to save. Your edit was not stored — try again.", + ); } finally { - setLoading(false); - } - } - - async function deleteAgent(id: string) { - try { - await api.deleteAgent(id); - await loadAgents(); - setDeleting(null); - } catch (err: unknown) { - console.error("Failed to delete agent:", err); + setSaving(false); } } return ( - + - - - Agent Settings + +
+ {DEUCE.name[0]} +
+ {DEUCE.name} settings
- {editing ? ( - - ) : ( -
-
-

- {agents.length} agent{agents.length !== 1 ? "s" : ""} configured -

- -
- - - - -
- {agents.map((agent) => ( -
-
- {agent.name.charAt(0).toUpperCase()} -
-
-
- - {agent.name} - - {agent.role && ( - - {agent.role} - - )} -
-

- {agent.model} {agent.description && `· ${agent.description}`} -

-
-
- - {deleting === agent.id ? ( -
- - -
- ) : ( - - )} -
-
- ))} -
-
-
- )} -
-
- ); -} - -function AgentForm({ - agent, - isNew, - loading, - onChange, - onSave, - onCancel, -}: { - agent: AgentDraft; - isNew: boolean; - loading: boolean; - onChange: (agent: AgentDraft) => void; - onSave: () => void; - onCancel: () => void; -}) { - return ( -
-
-
- - onChange({ ...agent, name: e.target.value })} - placeholder="e.g., Coder" - className="h-8 bg-background border-border-muted text-sm" - /> -
-
- - onChange({ ...agent, role: e.target.value })} - placeholder="e.g., coder, reviewer" - className="h-8 bg-background border-border-muted text-sm" - /> -
-
- -
-
- - -
-
- - onChange({ ...agent, description: e.target.value })} - placeholder="Brief description" - className="h-8 bg-background border-border-muted text-sm" + System prompt + +