diff --git a/AGENTS.md b/AGENTS.md index 911603f19c..210fdc55bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ make -C apps/backend lint ## Backend Architecture (High Level) -- **Transport**: WebSocket-only for client <-> backend. +- **Transport**: WebSocket for realtime updates; HTTP for CRUD + SSR fetches. - **Gateway**: Single WS endpoint at `ws://localhost:8080/ws`. - **Orchestrator**: Starts/stops/resumes tasks, manages sessions. - **Agent lifecycle**: @@ -92,7 +92,11 @@ Client (WS) Backend Runtime agentctl ### Subscription Strategy -TODO +- Components subscribe to the channels they need (task, session, repo), using hooks. +- WebSocket client deduplicates subscriptions with reference counting. +- Reconnects re-subscribe all active channels automatically. +- Event handlers update store slices; components only read from store. +- URL params set active task/session in store for consistent view state. **Why This Works** @@ -111,15 +115,30 @@ Layer 3: Components (subscribe + render) ### Store Structure -TODO +- Task/session scoped data is keyed in store: + - `messages.bySession[sessionId]` + - `messages.metaBySession[sessionId]` + - `gitStatus` keyed by `sessionId` + - `shell.outputs` keyed by `sessionId` +- Active selection state lives in store: + - `tasks.activeTaskId` + - `tasks.activeSessionId` ### Custom Hooks Pattern -TODO +- Hooks own subscriptions and return data from store: + - `useSessionMessages(sessionId)` → messages + loading/meta + - `useLazyLoadMessages(sessionId)` → pagination + - `useSessionGitStatus(sessionId)` → git state + - `useSessionShellOutput(sessionId)` → shell output +- Hooks subscribe on mount, cleanup on unmount; no manual dedup in components. ### Page-Level Responsibilities -TODO +- SSR fetches initial task/session + board snapshot, hydrates store. +- Page reads URL `sessionId`, resolves task via session, and calls `setActiveSession(taskId, sessionId)` after load. +- Page coordinates task list + session list loading (e.g., `listTaskSessions`). +- Components render from store and trigger hooks for live updates. ## Agents and ACP diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index 9fc6ea3ddc..0000000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,436 +0,0 @@ -# Kandev Architecture - -> Quick reference for developers and AI agents working on this codebase. - -## System Overview - -Kandev is a kanban board with AI agent orchestration. Three main components: - -``` -┌──────────────────────────────────────────────────────────┐ -│ Web UI (Next.js 16 + React 19) │ -│ http://localhost:3000 │ -└───────────────────────┬──────────────────────────────────┘ - │ WebSocket (ws://localhost:8080/ws) -┌───────────────────────▼──────────────────────────────────┐ -│ Backend (Go + Gin + SQLite) │ -│ ws://localhost:8080/ws │ -│ │ -│ [Task Service] [Agent Manager] [Orchestrator] │ -│ [Event Bus: In-Memory/NATS] │ -└───────────────────────┬──────────────────────────────────┘ - │ ACP (JSON-RPC over stdout) -┌───────────────────────▼──────────────────────────────────┐ -│ AI Agents (Docker) │ -│ auggie --acp │ -└──────────────────────────────────────────────────────────┘ -``` - -## Quick Start - -```bash -# Install dependencies -make install - -# Terminal 1: Backend -make dev-backend - -# Terminal 2: Web app -make dev-web - -# Build everything -make build - -# Run tests -make test -``` - -## Tech Stack - -### Web App (`apps/web/`) -- **Framework**: Next.js 16 (App Router), React 19 -- **Styling**: Tailwind CSS 4, shadcn/ui -- **Language**: TypeScript -- **Port**: 3000 - -### Backend (`apps/backend/`) -- **Language**: Go 1.21+ -- **Framework**: Gin, Gorilla WebSocket -- **Protocol**: WebSocket (message-based API) -- **Database**: SQLite (WAL mode) -- **Container**: Docker client -- **Logging**: Zap -- **Port**: 8080 - -### Agents -- **Runtime**: Docker containers -- **Protocol**: ACP (JSON-RPC 2.0 over stdout) -- **Current**: Augment Agent (`auggie --acp`) - -## Project Structure - -``` -. -├── Makefile # Root build orchestration -├── ARCHITECTURE.md # This file -├── AGENTS.md # Agent protocol details -├── apps/ -│ ├── web/ # Next.js web application -│ │ ├── app/ # Pages (App Router) -│ │ ├── components/ # React components -│ │ └── lib/ # Utilities -│ └── backend/ # Go backend services -│ ├── cmd/kandev/ # Main entry point -│ ├── internal/ -│ │ ├── agent/ # Agent manager (Docker, ACP, lifecycle) -│ │ ├── task/ # Task service (CRUD, SQLite) -│ │ ├── orchestrator/ # Task execution coordinator -│ │ ├── events/ # Event bus (in-memory/NATS) -│ │ ├── gateway/ # WebSocket gateway -│ │ └── common/ # Shared utilities (config, logger, db) -│ └── pkg/ -│ ├── acp/ # ACP protocol types -│ └── api/v1/ # API models -└── docs/ - └── asyncapi.yaml # WebSocket API specification -``` - -## Data Flow - -1. User creates task in web UI -2. WebSocket `task.create` message → SQLite -3. User launches agent for task -4. WebSocket `agent.launch` message → Backend spawns Docker container -5. Agent streams progress via ACP -6. Events published to event bus -7. WebSocket notifications pushed to UI (`task.updated`, `agent.updated`) - -## Backend Services - -### Task Service -- **Responsibility**: Manage boards, columns, tasks -- **Storage**: SQLite (`kandev.db`) -- **Tables**: `boards`, `columns`, `tasks` - -### Agent Manager -- **Responsibility**: Docker lifecycle, ACP streaming -- **Key Components**: - - Docker client (container management) - - ACP session manager (JSON-RPC handler) - - Streaming reader (parse stdout) - - Credentials manager (mount secrets) - -### Orchestrator -- **Responsibility**: Queue tasks, coordinate execution -- **Features**: Task queue, agent launch, monitoring - -## WebSocket Message Types - -**Connection**: `ws://localhost:8080/ws` - -### Message Envelope -```json -{ - "id": "correlation-uuid", - "type": "request|response|notification|error", - "action": "action.name", - "payload": {}, - "timestamp": "2026-01-10T10:30:00Z" -} -``` - -### Request/Response Pattern -- Requests include `id` for correlation -- Responses include matching `id` -- Notifications have no `id` (one-way) - -### Message Actions - -| Action | Description | -|--------|-------------| -| `health.check` | Health check | -| `board.list` | List boards | -| `board.create` | Create board | -| `board.get` | Get board | -| `board.update` | Update board | -| `board.delete` | Delete board | -| `column.list` | List columns | -| `column.create` | Create column | -| `task.list` | List tasks | -| `task.create` | Create task | -| `task.get` | Get task | -| `task.update` | Update task | -| `task.delete` | Delete task | -| `task.move` | Move task | -| `task.state` | Update state | -| `task.subscribe` | Subscribe to task updates | -| `task.unsubscribe` | Unsubscribe from task updates | -| `agent.list` | List agents | -| `agent.launch` | Launch agent | -| `agent.status` | Get status | -| `agent.logs` | Get logs | -| `agent.stop` | Stop agent | -| `agent.prompt` | Send prompt | -| `agent.cancel` | Cancel agent | -| `agent.session` | Get session | -| `agent.types` | List types | -| `orchestrator.status` | Get status | -| `orchestrator.queue` | Get queue | -| `orchestrator.trigger` | Trigger task | -| `orchestrator.start` | Start task | -| `orchestrator.stop` | Stop task | -| `orchestrator.prompt` | Send prompt | -| `orchestrator.complete` | Complete task | - -### Notification Types (Server → Client) - -| Action | Description | -|--------|-------------| -| `acp.progress` | Agent progress update | -| `acp.log` | Agent log message | -| `acp.result` | Agent result | -| `acp.error` | Agent error | -| `acp.status` | Agent status change | -| `acp.heartbeat` | Keep-alive | -| `task.updated` | Task was updated | -| `agent.updated` | Agent status changed | - -### Error Response Format -```json -{ - "id": "correlation-uuid", - "type": "error", - "action": "original.action", - "payload": { - "code": "ERROR_CODE", - "message": "Human readable message", - "details": {} - }, - "timestamp": "2026-01-10T10:30:00Z" -} -``` - -**Full API**: See `docs/asyncapi.yaml` - -## Agent Communication Protocol (ACP) - -Agents use JSON-RPC 2.0 over stdout for bidirectional communication. - -### Key Methods - -**Backend → Agent:** -- `initialize` - Start connection -- `session/new` - Create session -- `session/load` - Resume session -- `session/prompt` - Send task -- `session/cancel` - Cancel operation - -**Agent → Backend:** -- `session/update` - Progress notifications - -### Example Flow - -```json -// Backend sends -{"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}} - -// Agent responds -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.0",...}} - -// Backend sends task -{"jsonrpc":"2.0","id":2,"method":"session/prompt","params":{"message":"Fix bug"}} - -// Agent streams updates -{"jsonrpc":"2.0","method":"session/update","params":{"type":"content","data":{...}}} -{"jsonrpc":"2.0","method":"session/update","params":{"type":"complete","data":{...}}} -``` - -**Details**: See `AGENTS.md` - -## Configuration - -### Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `KANDEV_DB_PATH` | `./kandev.db` | SQLite database | -| `PORT` | `8080` | HTTP port | -| `KANDEV_LOGGING_LEVEL` | `info` | Log level | -| `KANDEV_CREDENTIALS_FILE` | - | Agent credentials | - -### Agent Types - -**Augment Agent** (`augment-agent`): -- Image: `kandev/augment-agent:latest` -- Memory: 4GB, CPU: 2 cores, Timeout: 1 hour -- Required env: `AUGMENT_SESSION_AUTH`, `TASK_DESCRIPTION` -- Optional env: `AUGGIE_SESSION_ID` (for resumption) - -## Development Workflow - -### WebSocket Usage Example (JavaScript) - -```javascript -const ws = new WebSocket('ws://localhost:8080/ws'); - -// Send request -ws.send(JSON.stringify({ - id: crypto.randomUUID(), - type: 'request', - action: 'board.create', - payload: { name: 'My Project' } -})); - -// Handle messages -ws.onmessage = (event) => { - const msg = JSON.parse(event.data); - if (msg.type === 'response') { - console.log('Response:', msg.payload); - } else if (msg.type === 'notification') { - console.log('Notification:', msg.action, msg.payload); - } -}; -``` - -### Create and Launch Agent - -```javascript -// Create board -ws.send(JSON.stringify({ - id: crypto.randomUUID(), - type: 'request', - action: 'board.create', - payload: { name: 'My Project' } -})); - -// Create task (after receiving board.create response) -ws.send(JSON.stringify({ - id: crypto.randomUUID(), - type: 'request', - action: 'task.create', - payload: { board_id: BOARD_ID, title: 'Fix bug' } -})); - -// Launch agent (after receiving task.create response) -ws.send(JSON.stringify({ - id: crypto.randomUUID(), - type: 'request', - action: 'agent.launch', - payload: { - task_id: TASK_ID, - agent_type: 'augment-agent', - workspace_path: '/path/to/project', - env: { - AUGMENT_SESSION_AUTH: sessionAuth, - TASK_DESCRIPTION: 'Fix the bug' - } - } -})); -``` - -### Session Resumption - -```javascript -// Get session ID from completed task -ws.send(JSON.stringify({ - id: crypto.randomUUID(), - type: 'request', - action: 'agent.session', - payload: { task_id: TASK_ID } -})); - -// Launch with resumption (after receiving session response) -ws.send(JSON.stringify({ - id: crypto.randomUUID(), - type: 'request', - action: 'agent.launch', - payload: { - task_id: NEW_TASK_ID, - agent_type: 'augment-agent', - workspace_path: '/path/to/project', - env: { - AUGMENT_SESSION_AUTH: sessionAuth, - AUGGIE_SESSION_ID: SESSION_ID, - TASK_DESCRIPTION: 'Follow-up question' - } - } -})); -``` - -## Database - -**Location**: `./kandev.db` (SQLite with WAL mode) - -**Schema** (`apps/backend/internal/task/repository/sqlite.go`): -- `boards` - Board records -- `columns` - Column definitions with state mapping -- `tasks` - Task records with JSON metadata - -**Inspect**: -```bash -sqlite3 kandev.db -> .tables -> SELECT * FROM tasks; -``` - -**Backup**: -```bash -cp kandev.db kandev.db.backup -``` - -## Testing - -```bash -# All tests -make test - -# Backend only (216 tests) -make test-backend - -# Web only -make test-web -``` - -## Troubleshooting - -### Backend won't start -- Port 8080 in use: `lsof -i :8080` -- Docker not running: `docker ps` -- DB permissions: `ls -la kandev.db` - -### Web app won't start -- Port 3000 in use: `lsof -i :3000` -- Missing deps: `cd apps/web && npm install` -- Node version: `node --version` (need 18+) - -### Agents won't launch -- Docker accessible: `docker info` -- Image exists: `docker images | grep kandev` -- Credentials: `cat ~/.augment/session.json` -- Container logs: `docker logs ` - -## Key Files - -| File | Purpose | -|------|---------| -| `Makefile` | Build orchestration | -| `ARCHITECTURE.md` | This file | -| `AGENTS.md` | Agent protocol details | -| `docs/asyncapi.yaml` | WebSocket API spec | -| `apps/backend/cmd/kandev/main.go` | Entry point | -| `apps/backend/internal/agent/registry/defaults.go` | Agent type registry | -| `apps/backend/internal/task/repository/sqlite.go` | Database schema | -| `apps/web/app/page.tsx` | Web app home | - -## Contributing - -1. Update docs when changing API (AGENTS.md + asyncapi.yaml) -2. Run tests: `make test` -3. Format code: `make fmt` -4. Test full stack: `make dev-backend` + `make dev-web` - ---- - -**For Agent Protocol Details**: See `AGENTS.md` -**For API Reference**: See `docs/asyncapi.yaml` -**Last Updated**: 2026-01-10 diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 6aadf2ee5c..d9ad32f574 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -28,8 +28,6 @@ import ( gateways "github.com/kandev/kandev/internal/gateway/websocket" // Agent Manager packages - agentctl "github.com/kandev/kandev/internal/agentctl/client" - "github.com/kandev/kandev/internal/agentctl/client/launcher" agentcontroller "github.com/kandev/kandev/internal/agent/controller" "github.com/kandev/kandev/internal/agent/credentials" "github.com/kandev/kandev/internal/agent/discovery" @@ -41,6 +39,8 @@ import ( agentsettingshandlers "github.com/kandev/kandev/internal/agent/settings/handlers" settingsstore "github.com/kandev/kandev/internal/agent/settings/store" "github.com/kandev/kandev/internal/agent/worktree" + agentctl "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/agentctl/client/launcher" notificationcontroller "github.com/kandev/kandev/internal/notifications/controller" notificationhandlers "github.com/kandev/kandev/internal/notifications/handlers" notificationservice "github.com/kandev/kandev/internal/notifications/service" @@ -481,7 +481,7 @@ func main() { _, err = eventBus.Subscribe(events.TaskSessionStateChanged, func(ctx context.Context, event *bus.Event) error { data := event.Data taskID, _ := data["task_id"].(string) - sessionID, _ := data["task_session_id"].(string) + sessionID, _ := data["session_id"].(string) newState, _ := data["new_state"].(string) notificationSvc.HandleTaskSessionStateChanged(ctx, taskID, sessionID, newState) return nil @@ -510,17 +510,17 @@ func main() { result := make([]*ws.Message, 0, len(messages)) for _, message := range messages { - action := ws.ActionMessageAdded + action := ws.ActionSessionMessageAdded payload := map[string]interface{}{ - "message_id": message.ID, - "task_session_id": message.TaskSessionID, - "task_id": message.TaskID, - "author_type": string(message.AuthorType), - "author_id": message.AuthorID, - "content": message.Content, - "type": string(message.Type), - "requests_input": message.RequestsInput, - "created_at": message.CreatedAt.Format(time.RFC3339), + "message_id": message.ID, + "session_id": message.TaskSessionID, + "task_id": message.TaskID, + "author_type": string(message.AuthorType), + "author_id": message.AuthorID, + "content": message.Content, + "type": string(message.Type), + "requests_input": message.RequestsInput, + "created_at": message.CreatedAt.Format(time.RFC3339), } if message.Metadata != nil { payload["metadata"] = message.Metadata @@ -543,7 +543,7 @@ func main() { } gitStatusData["task_id"] = taskID - gitStatusNotification, err := ws.NewNotification("git.status", gitStatusData) + gitStatusNotification, err := ws.NewNotification("session.git.status", gitStatusData) if err == nil { result = append(result, gitStatusNotification) } @@ -559,7 +559,11 @@ func main() { if lifecycleMgr == nil { return nil, nil } - permissions, err := lifecycleMgr.GetPendingPermissionsForTask(ctx, taskID) + session, err := taskRepo.GetActiveTaskSessionByTaskID(ctx, taskID) + if err != nil || session == nil || session.ID == "" { + return nil, nil + } + permissions, err := lifecycleMgr.GetPendingPermissionsForSession(ctx, session.ID) if err != nil { return nil, err } @@ -599,6 +603,12 @@ func main() { orchestratorSvc.RegisterACPHandler(func(taskID string, msg *protocol.Message) { // Check for permission_request events (type is in msg.Type, payload in msg.Data) if msg.Type == "permission_request" { + sessionID := msg.SessionID + if sessionID == "" { + log.Warn("permission_request missing session_id, skipping", + zap.String("task_id", taskID)) + return + } // Build payload for frontend with all necessary fields payload := map[string]interface{}{ "type": "permission_request", @@ -612,7 +622,7 @@ func main() { } // Broadcast permission.requested notification to task subscribers notification, _ := ws.NewNotification(ws.ActionPermissionRequested, payload) - gateway.Hub.BroadcastToTask(taskID, notification) + gateway.Hub.BroadcastToSession(sessionID, notification) log.Info("broadcasted permission.requested notification", zap.String("task_id", taskID), zap.Any("pending_id", payload["pending_id"])) @@ -629,37 +639,43 @@ func main() { protocol.MessageTypeStatus, protocol.MessageTypeHeartbeat, protocol.MessageTypeInputRequired: + if msg.SessionID == "" { + log.Warn("ACP notification missing session_id, skipping", + zap.String("task_id", taskID), + zap.String("type", string(msg.Type))) + return + } // Convert ACP message to WebSocket notification action := "acp." + string(msg.Type) notification, _ := ws.NewNotification(action, map[string]interface{}{ - "task_id": taskID, - "type": msg.Type, - "data": msg.Data, - "timestamp": msg.Timestamp, + "task_id": taskID, + "session_id": msg.SessionID, + "type": msg.Type, + "data": msg.Data, + "timestamp": msg.Timestamp, }) - gateway.Hub.BroadcastToTask(taskID, notification) + gateway.Hub.BroadcastToSession(msg.SessionID, notification) default: // Skip other message types (session/update, etc.) } }) // Wire input request handler to create agent messages when input is requested - orchestratorSvc.SetInputRequestHandler(func(ctx context.Context, taskID, agentID, message string) error { + orchestratorSvc.SetInputRequestHandler(func(ctx context.Context, taskID, sessionID, agentID, message string) error { log.Info("agent requesting user input, creating message", zap.String("task_id", taskID), + zap.String("session_id", sessionID), zap.String("agent_id", agentID)) - session, err := taskRepo.GetActiveTaskSessionByTaskID(ctx, taskID) - if err != nil { - log.Error("failed to resolve active agent session for input request", - zap.String("task_id", taskID), - zap.Error(err)) - return err + if sessionID == "" { + log.Error("missing task session id for input request", + zap.String("task_id", taskID)) + return fmt.Errorf("missing task session id for input request") } // Create a message from the agent messageRecord, err := taskSvc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ - TaskSessionID: session.ID, + TaskSessionID: sessionID, TaskID: taskID, Content: message, AuthorType: "agent", @@ -673,22 +689,23 @@ func main() { return err } - // Broadcast message.added notification to task subscribers - notification, _ := ws.NewNotification(ws.ActionMessageAdded, map[string]interface{}{ - "task_id": taskID, - "task_session_id": session.ID, - "message": messageRecord.ToAPI(), - "requests_input": true, + // Broadcast message.added notification to session subscribers + notification, _ := ws.NewNotification(ws.ActionSessionMessageAdded, map[string]interface{}{ + "task_id": taskID, + "session_id": sessionID, + "message": messageRecord.ToAPI(), + "requests_input": true, }) - gateway.Hub.BroadcastToTask(taskID, notification) + gateway.Hub.BroadcastToSession(sessionID, notification) // Also broadcast input.requested notification inputNotification, _ := ws.NewNotification(ws.ActionInputRequested, map[string]interface{}{ "task_id": taskID, + "session_id": sessionID, "message_id": messageRecord.ID, "message": message, }) - gateway.Hub.BroadcastToTask(taskID, inputNotification) + gateway.Hub.BroadcastToSession(sessionID, inputNotification) return nil }) @@ -701,32 +718,32 @@ func main() { // Subscribe to message.added events and broadcast to WebSocket subscribers _, err = eventBus.Subscribe(events.MessageAdded, func(ctx context.Context, event *bus.Event) error { data := event.Data - taskSessionID, _ := data["task_session_id"].(string) + taskSessionID, _ := data["session_id"].(string) taskID, _ := data["task_id"].(string) if taskSessionID == "" { return nil } payload := map[string]interface{}{ - "task_id": taskID, - "task_session_id": taskSessionID, - "message_id": data["message_id"], - "author_type": data["author_type"], - "author_id": data["author_id"], - "content": data["content"], - "type": data["type"], - "requests_input": data["requests_input"], - "created_at": data["created_at"], + "task_id": taskID, + "session_id": taskSessionID, + "message_id": data["message_id"], + "author_type": data["author_type"], + "author_id": data["author_id"], + "content": data["content"], + "type": data["type"], + "requests_input": data["requests_input"], + "created_at": data["created_at"], } if metadata, ok := data["metadata"]; ok && metadata != nil { payload["metadata"] = metadata } - notification, err := ws.NewNotification(ws.ActionMessageAdded, payload) + notification, err := ws.NewNotification(ws.ActionSessionMessageAdded, payload) if err != nil { log.Error("Failed to create message.added notification", zap.Error(err)) return nil } - gateway.Hub.BroadcastToTask(taskID, notification) + gateway.Hub.BroadcastToSession(taskSessionID, notification) return nil }) if err != nil { @@ -738,32 +755,32 @@ func main() { // Subscribe to message.updated events and broadcast to WebSocket subscribers _, err = eventBus.Subscribe(events.MessageUpdated, func(ctx context.Context, event *bus.Event) error { data := event.Data - taskSessionID, _ := data["task_session_id"].(string) + taskSessionID, _ := data["session_id"].(string) taskID, _ := data["task_id"].(string) if taskSessionID == "" { - log.Warn("message.updated event has no task_session_id, skipping") + log.Warn("message.updated event has no session_id, skipping") return nil } payload := map[string]interface{}{ - "message_id": data["message_id"], - "task_session_id": taskSessionID, - "task_id": taskID, - "author_type": data["author_type"], - "author_id": data["author_id"], - "content": data["content"], - "type": data["type"], - "requests_input": data["requests_input"], - "created_at": data["created_at"], + "message_id": data["message_id"], + "session_id": taskSessionID, + "task_id": taskID, + "author_type": data["author_type"], + "author_id": data["author_id"], + "content": data["content"], + "type": data["type"], + "requests_input": data["requests_input"], + "created_at": data["created_at"], } if metadata, ok := data["metadata"]; ok && metadata != nil { payload["metadata"] = metadata } - notification, err := ws.NewNotification(ws.ActionMessageUpdated, payload) + notification, err := ws.NewNotification(ws.ActionSessionMessageUpdated, payload) if err != nil { log.Error("Failed to create message.updated notification", zap.Error(err)) return nil } - gateway.Hub.BroadcastToTask(taskID, notification) + gateway.Hub.BroadcastToSession(taskSessionID, notification) return nil }) if err != nil { @@ -775,16 +792,16 @@ func main() { // Subscribe to task_session.state_changed events and broadcast to task subscribers _, err = eventBus.Subscribe(events.TaskSessionStateChanged, func(ctx context.Context, event *bus.Event) error { data := event.Data - taskID, _ := data["task_id"].(string) - if taskID == "" { + taskSessionID, _ := data["session_id"].(string) + if taskSessionID == "" { return nil } - notification, err := ws.NewNotification(ws.ActionTaskSessionStateChanged, data) + notification, err := ws.NewNotification(ws.ActionSessionStateChanged, data) if err != nil { log.Error("Failed to create task_session.state_changed notification", zap.Error(err)) return nil } - gateway.Hub.BroadcastToTask(taskID, notification) + gateway.Hub.BroadcastToSession(taskSessionID, notification) return nil }) if err != nil { @@ -796,14 +813,14 @@ func main() { // Subscribe to git status events and broadcast to WebSocket subscribers _, err = eventBus.Subscribe(events.BuildGitStatusWildcardSubject(), func(ctx context.Context, event *bus.Event) error { data := event.Data - taskID, _ := data["task_id"].(string) - if taskID == "" { + taskSessionID, _ := data["session_id"].(string) + if taskSessionID == "" { return nil } - // Broadcast git status update to task subscribers - notification, _ := ws.NewNotification("git.status", data) - gateway.Hub.BroadcastToTask(taskID, notification) + // Broadcast git status update to session subscribers + notification, _ := ws.NewNotification("session.git.status", data) + gateway.Hub.BroadcastToSession(taskSessionID, notification) return nil }) if err != nil { @@ -815,14 +832,14 @@ func main() { // Subscribe to file change events and broadcast to WebSocket subscribers _, err = eventBus.Subscribe(events.BuildFileChangeWildcardSubject(), func(ctx context.Context, event *bus.Event) error { data := event.Data - taskID, _ := data["task_id"].(string) - if taskID == "" { + taskSessionID, _ := data["session_id"].(string) + if taskSessionID == "" { return nil } - // Broadcast file change notification to task subscribers - notification, _ := ws.NewNotification("workspace.file.changes", data) - gateway.Hub.BroadcastToTask(taskID, notification) + // Broadcast file change notification to session subscribers + notification, _ := ws.NewNotification(ws.ActionWorkspaceFileChanges, data) + gateway.Hub.BroadcastToSession(taskSessionID, notification) return nil }) if err != nil { @@ -847,7 +864,7 @@ func main() { // Task Service handlers (HTTP + WebSocket) taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log) taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log) - taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, log) + taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, orchestratorSvc, log) taskhandlers.RegisterRepositoryRoutes(router, gateway.Dispatcher, repositoryController, log) taskhandlers.RegisterExecutorRoutes(router, gateway.Dispatcher, executorController, log) taskhandlers.RegisterEnvironmentRoutes(router, gateway.Dispatcher, environmentController, log) @@ -909,16 +926,23 @@ func main() { // ============================================ // GRACEFUL SHUTDOWN // ============================================ - quit := make(chan os.Signal, 1) + quit := make(chan os.Signal, 2) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) sig := <-quit + // If we get a second signal, exit immediately. + go func() { + second := <-quit + log.Warn("Received second shutdown signal, forcing exit", zap.String("signal", second.String())) + os.Exit(1) + }() + log.Info("Received shutdown signal", zap.String("signal", sig.String())) log.Info("Shutting down Kandev...") cancel() - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) defer shutdownCancel() if err := server.Shutdown(shutdownCtx); err != nil { @@ -931,7 +955,7 @@ func main() { if lifecycleMgr != nil { log.Info("Stopping agents gracefully...") - stopCtx, stopCancel := context.WithTimeout(context.Background(), 20*time.Second) + stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second) if err := lifecycleMgr.StopAllAgents(stopCtx); err != nil { log.Error("Graceful agent stop error", zap.Error(err)) } @@ -1060,11 +1084,11 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch return &executor.LaunchAgentResponse{ AgentExecutionID: execution.ID, - ContainerID: execution.ContainerID, - Status: execution.Status, - WorktreeID: worktreeID, - WorktreePath: worktreePath, - WorktreeBranch: worktreeBranch, + ContainerID: execution.ContainerID, + Status: execution.Status, + WorktreeID: worktreeID, + WorktreePath: worktreePath, + WorktreeBranch: worktreeBranch, }, nil } @@ -1132,9 +1156,9 @@ func (a *lifecycleAdapter) PromptAgent(ctx context.Context, agentInstanceID stri }, nil } -// RespondToPermissionByTaskID sends a response to a permission request for a task -func (a *lifecycleAdapter) RespondToPermissionByTaskID(ctx context.Context, taskID, pendingID, optionID string, cancelled bool) error { - return a.mgr.RespondToPermissionByTaskID(taskID, pendingID, optionID, cancelled) +// RespondToPermissionBySessionID sends a response to a permission request for a session +func (a *lifecycleAdapter) RespondToPermissionBySessionID(ctx context.Context, sessionID, pendingID, optionID string, cancelled bool) error { + return a.mgr.RespondToPermissionBySessionID(sessionID, pendingID, optionID, cancelled) } // GetRecoveredExecutions returns executions recovered from Docker during startup @@ -1145,6 +1169,7 @@ func (a *lifecycleAdapter) GetRecoveredExecutions() []executor.RecoveredExecutio result[i] = executor.RecoveredExecutionInfo{ ExecutionID: r.ExecutionID, TaskID: r.TaskID, + SessionID: r.SessionID, ContainerID: r.ContainerID, AgentProfileID: r.AgentProfileID, } @@ -1152,15 +1177,15 @@ func (a *lifecycleAdapter) GetRecoveredExecutions() []executor.RecoveredExecutio return result } -// IsAgentRunningForTask checks if an agent is actually running for a task +// IsAgentRunningForSession checks if an agent is actually running for a session // This probes the actual agent (Docker container or standalone process) -func (a *lifecycleAdapter) IsAgentRunningForTask(ctx context.Context, taskID string) bool { - return a.mgr.IsAgentRunningForTask(ctx, taskID) +func (a *lifecycleAdapter) IsAgentRunningForSession(ctx context.Context, sessionID string) bool { + return a.mgr.IsAgentRunningForSession(ctx, sessionID) } -// CleanupStaleExecutionByTaskID removes a stale agent execution from tracking without trying to stop it. -func (a *lifecycleAdapter) CleanupStaleExecutionByTaskID(ctx context.Context, taskID string) error { - return a.mgr.CleanupStaleExecutionByTaskID(ctx, taskID) +// CleanupStaleExecutionBySessionID removes a stale agent execution from tracking without trying to stop it. +func (a *lifecycleAdapter) CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error { + return a.mgr.CleanupStaleExecutionBySessionID(ctx, sessionID) } // corsMiddleware returns a CORS middleware for HTTP and WebSocket connections @@ -1186,8 +1211,8 @@ type orchestratorAdapter struct { } // PromptTask forwards to the orchestrator service and converts the result type -func (a *orchestratorAdapter) PromptTask(ctx context.Context, taskID string, prompt string) (*taskhandlers.PromptResult, error) { - result, err := a.svc.PromptTask(ctx, taskID, prompt) +func (a *orchestratorAdapter) PromptTask(ctx context.Context, taskID, taskSessionID, prompt string) (*taskhandlers.PromptResult, error) { + result, err := a.svc.PromptTask(ctx, taskID, taskSessionID, prompt) if err != nil { return nil, err } diff --git a/apps/backend/internal/agent/handlers/shell_handlers.go b/apps/backend/internal/agent/handlers/shell_handlers.go index 141aecae65..7ec815efe5 100644 --- a/apps/backend/internal/agent/handlers/shell_handlers.go +++ b/apps/backend/internal/agent/handlers/shell_handlers.go @@ -5,9 +5,10 @@ import ( "context" "fmt" "sync" + "time" - agentctl "github.com/kandev/kandev/internal/agentctl/client" "github.com/kandev/kandev/internal/agent/lifecycle" + agentctl "github.com/kandev/kandev/internal/agentctl/client" "github.com/kandev/kandev/internal/common/logger" ws "github.com/kandev/kandev/pkg/websocket" "go.uber.org/zap" @@ -25,7 +26,7 @@ type ShellHandlers struct { hub ShellOutputBroadcaster sessionResumer SessionResumer - // Track active shell streams per task + // Track active shell streams per session activeStreams map[string]context.CancelFunc mu sync.RWMutex @@ -54,6 +55,7 @@ func (h *ShellHandlers) SetSessionResumer(resumer SessionResumer) { h.sessionResumer = resumer } + // RegisterHandlers registers shell handlers with the WebSocket dispatcher func (h *ShellHandlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionShellStatus, h.wsShellStatus) @@ -63,32 +65,32 @@ func (h *ShellHandlers) RegisterHandlers(d *ws.Dispatcher) { // ShellStatusRequest for shell.status action type ShellStatusRequest struct { - TaskID string `json:"task_id"` + SessionID string `json:"session_id"` } // ShellInputRequest for shell.input action type ShellInputRequest struct { - TaskID string `json:"task_id"` - Data string `json:"data"` + SessionID string `json:"session_id"` + Data string `json:"data"` } -// wsShellStatus returns the status of a shell session for a task +// wsShellStatus returns the status of a shell session for a session func (h *ShellHandlers) wsShellStatus(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req ShellStatusRequest if err := msg.ParsePayload(&req); err != nil { return nil, fmt.Errorf("invalid payload: %w", err) } - if req.TaskID == "" { - return nil, fmt.Errorf("task_id is required") + if req.SessionID == "" { + return nil, fmt.Errorf("session_id is required") } - // Get the agent execution for this task - execution, ok := h.lifecycleMgr.GetExecutionByTaskID(req.TaskID) + // Get the agent execution for this session + execution, ok := h.lifecycleMgr.GetExecutionBySessionID(req.SessionID) if !ok { return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ "available": false, - "error": "no agent running for this task", + "error": "no agent running for this session", }) } @@ -121,28 +123,28 @@ func (h *ShellHandlers) wsShellStatus(ctx context.Context, msg *ws.Message) (*ws // ShellSubscribeRequest for shell.subscribe action type ShellSubscribeRequest struct { - TaskID string `json:"task_id"` + SessionID string `json:"session_id"` } -// wsShellSubscribe subscribes to shell output for a task and starts the shell stream if needed +// wsShellSubscribe subscribes to shell output for a session and starts the shell stream if needed func (h *ShellHandlers) wsShellSubscribe(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req ShellSubscribeRequest if err := msg.ParsePayload(&req); err != nil { return nil, fmt.Errorf("invalid payload: %w", err) } - if req.TaskID == "" { - return nil, fmt.Errorf("task_id is required") + if req.SessionID == "" { + return nil, fmt.Errorf("session_id is required") } - // Verify the agent execution exists for this task - execution, ok := h.lifecycleMgr.GetExecutionByTaskID(req.TaskID) + // Verify the agent execution exists for this session + execution, ok := h.lifecycleMgr.GetExecutionBySessionID(req.SessionID) if !ok { - return nil, fmt.Errorf("no agent running for task %s", req.TaskID) + return nil, fmt.Errorf("no agent running for session %s", req.SessionID) } // Start the shell stream (idempotent - does nothing if already running) - if err := h.StartShellStream(ctx, req.TaskID); err != nil { + if err := h.StartShellStream(ctx, req.SessionID); err != nil { return nil, fmt.Errorf("failed to start shell stream: %w", err) } @@ -156,9 +158,9 @@ func (h *ShellHandlers) wsShellSubscribe(ctx context.Context, msg *ws.Message) ( } return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ - "success": true, - "task_id": req.TaskID, - "buffer": buffer, + "success": true, + "session_id": req.SessionID, + "buffer": buffer, }) } @@ -169,17 +171,17 @@ func (h *ShellHandlers) wsShellInput(ctx context.Context, msg *ws.Message) (*ws. return nil, fmt.Errorf("invalid payload: %w", err) } - if req.TaskID == "" { - return nil, fmt.Errorf("task_id is required") + if req.SessionID == "" { + return nil, fmt.Errorf("session_id is required") } - // Verify the agent execution exists for this task - if _, ok := h.lifecycleMgr.GetExecutionByTaskID(req.TaskID); !ok { - return nil, fmt.Errorf("no agent running for task %s", req.TaskID) + // Verify the agent execution exists for this session + if _, ok := h.lifecycleMgr.GetExecutionBySessionID(req.SessionID); !ok { + return nil, fmt.Errorf("no agent running for session %s", req.SessionID) } // Send input via the shell stream - if err := h.SendShellInput(req.TaskID, req.Data); err != nil { + if err := h.SendShellInput(req.SessionID, req.Data); err != nil { return nil, err } @@ -188,30 +190,30 @@ func (h *ShellHandlers) wsShellInput(ctx context.Context, msg *ws.Message) (*ws. }) } -// StartShellStream starts streaming shell output for a task using the configured hub. +// StartShellStream starts streaming shell output for a session using the configured hub. // This implements the lifecycle.ShellStreamStarter interface. -func (h *ShellHandlers) StartShellStream(ctx context.Context, taskID string) error { +func (h *ShellHandlers) StartShellStream(ctx context.Context, sessionID string) error { if h.hub == nil { return fmt.Errorf("hub not configured") } - return h.StartShellStreamWithHub(ctx, taskID, h.hub) + return h.StartShellStreamWithHub(ctx, sessionID, h.hub) } -// StartShellStreamWithHub starts streaming shell output for a task to a specific hub -func (h *ShellHandlers) StartShellStreamWithHub(ctx context.Context, taskID string, hub ShellOutputBroadcaster) error { +// StartShellStreamWithHub starts streaming shell output for a session to a specific hub +func (h *ShellHandlers) StartShellStreamWithHub(ctx context.Context, sessionID string, hub ShellOutputBroadcaster) error { h.mu.Lock() - if _, exists := h.activeStreams[taskID]; exists { + if _, exists := h.activeStreams[sessionID]; exists { h.mu.Unlock() return nil // Already streaming } streamCtx, cancel := context.WithCancel(context.Background()) // Use background context so stream survives request - h.activeStreams[taskID] = cancel + h.activeStreams[sessionID] = cancel h.mu.Unlock() // Use a channel to wait for stream setup to complete readyCh := make(chan error, 1) - go h.runShellStreamWithReady(streamCtx, taskID, hub, readyCh) + go h.runShellStreamWithReady(streamCtx, sessionID, hub, readyCh) // Wait for stream to be ready (or fail) select { @@ -223,55 +225,61 @@ func (h *ShellHandlers) StartShellStreamWithHub(ctx context.Context, taskID stri } } -// StopShellStream stops the shell stream for a task -func (h *ShellHandlers) StopShellStream(taskID string) { +// StopShellStream stops the shell stream for a session +func (h *ShellHandlers) StopShellStream(sessionID string) { h.mu.Lock() - if cancel, exists := h.activeStreams[taskID]; exists { + if cancel, exists := h.activeStreams[sessionID]; exists { cancel() - delete(h.activeStreams, taskID) + delete(h.activeStreams, sessionID) } h.mu.Unlock() } // ShellOutputBroadcaster interface for broadcasting shell output type ShellOutputBroadcaster interface { - BroadcastToTask(taskID string, msg *ws.Message) + BroadcastToSession(sessionID string, msg *ws.Message) } -// runShellStreamWithReady runs the shell output stream for a task and signals when ready -func (h *ShellHandlers) runShellStreamWithReady(ctx context.Context, taskID string, hub ShellOutputBroadcaster, readyCh chan<- error) { +// runShellStreamWithReady runs the shell output stream for a session and signals when ready +func (h *ShellHandlers) runShellStreamWithReady(ctx context.Context, sessionID string, hub ShellOutputBroadcaster, readyCh chan<- error) { defer func() { h.mu.Lock() - delete(h.activeStreams, taskID) + delete(h.activeStreams, sessionID) h.mu.Unlock() }() - execution, ok := h.lifecycleMgr.GetExecutionByTaskID(taskID) + execution, ok := h.lifecycleMgr.GetExecutionBySessionID(sessionID) if !ok { - h.logger.Debug("no agent execution for shell stream", zap.String("task_id", taskID)) - readyCh <- fmt.Errorf("no agent execution for task %s", taskID) + h.logger.Debug("no agent execution for shell stream", zap.String("session_id", sessionID)) + readyCh <- fmt.Errorf("no agent execution for session %s", sessionID) return } client := execution.GetAgentCtlClient() if client == nil { - h.logger.Debug("no client for shell stream", zap.String("task_id", taskID)) - readyCh <- fmt.Errorf("no agent client for task %s", taskID) + h.logger.Debug("no client for shell stream", zap.String("session_id", sessionID)) + readyCh <- fmt.Errorf("no agent client for session %s", sessionID) + return + } + + if err := client.WaitForReady(ctx, 10*time.Second); err != nil { + h.logger.Error("agentctl not ready for shell stream", zap.String("session_id", sessionID), zap.Error(err)) + readyCh <- fmt.Errorf("agentctl not ready: %w", err) return } outputCh, inputCh, err := client.StreamShell(ctx) if err != nil { - h.logger.Error("failed to start shell stream", zap.String("task_id", taskID), zap.Error(err)) + h.logger.Error("failed to start shell stream", zap.String("session_id", sessionID), zap.Error(err)) readyCh <- fmt.Errorf("failed to start shell stream: %w", err) return } // Store input channel for sending input - h.storeInputChannel(taskID, inputCh) - defer h.removeInputChannel(taskID) + h.storeInputChannel(sessionID, inputCh) + defer h.removeInputChannel(sessionID) - h.logger.Info("shell stream started", zap.String("task_id", taskID)) + h.logger.Info("shell stream started", zap.String("session_id", sessionID)) // Signal that we're ready - input channel is now stored readyCh <- nil @@ -284,45 +292,46 @@ func (h *ShellHandlers) runShellStreamWithReady(ctx context.Context, taskID stri if !ok { return } - // Broadcast shell output to task subscribers + // Broadcast shell output to session subscribers notification, err := ws.NewNotification(ws.ActionShellOutput, map[string]interface{}{ - "task_id": taskID, - "type": msg.Type, - "data": msg.Data, - "code": msg.Code, + "session_id": sessionID, + "type": msg.Type, + "data": msg.Data, + "code": msg.Code, }) if err != nil { h.logger.Debug("failed to create shell output notification", zap.Error(err)) continue } - hub.BroadcastToTask(taskID, notification) + hub.BroadcastToSession(sessionID, notification) } } } -func (h *ShellHandlers) storeInputChannel(taskID string, ch chan<- agentctl.ShellMessage) { + +func (h *ShellHandlers) storeInputChannel(sessionID string, ch chan<- agentctl.ShellMessage) { h.inputMu.Lock() - h.inputChannels[taskID] = ch + h.inputChannels[sessionID] = ch h.inputMu.Unlock() } -func (h *ShellHandlers) removeInputChannel(taskID string) { +func (h *ShellHandlers) removeInputChannel(sessionID string) { h.inputMu.Lock() - if ch, exists := h.inputChannels[taskID]; exists { + if ch, exists := h.inputChannels[sessionID]; exists { close(ch) - delete(h.inputChannels, taskID) + delete(h.inputChannels, sessionID) } h.inputMu.Unlock() } // SendShellInput sends input to an active shell stream -func (h *ShellHandlers) SendShellInput(taskID, data string) error { +func (h *ShellHandlers) SendShellInput(sessionID, data string) error { h.inputMu.RLock() - ch, exists := h.inputChannels[taskID] + ch, exists := h.inputChannels[sessionID] h.inputMu.RUnlock() if !exists { - return fmt.Errorf("no active shell stream for task %s", taskID) + return fmt.Errorf("no active shell stream for session %s", sessionID) } select { @@ -332,4 +341,3 @@ func (h *ShellHandlers) SendShellInput(taskID, data string) error { return fmt.Errorf("shell input channel full") } } - diff --git a/apps/backend/internal/agent/handlers/shell_handlers_test.go b/apps/backend/internal/agent/handlers/shell_handlers_test.go index 3e4471ec4b..d16469b1e7 100644 --- a/apps/backend/internal/agent/handlers/shell_handlers_test.go +++ b/apps/backend/internal/agent/handlers/shell_handlers_test.go @@ -138,15 +138,15 @@ func TestWsShellStatus_MissingTaskID(t *testing.T) { log := newTestLogger() handlers := NewShellHandlers(nil, log) - // Create a message with empty task_id - msg, _ := ws.NewRequest("test-1", ws.ActionShellStatus, ShellStatusRequest{TaskID: ""}) + // Create a message with empty session_id + msg, _ := ws.NewRequest("test-1", ws.ActionShellStatus, ShellStatusRequest{SessionID: ""}) _, err := handlers.wsShellStatus(context.Background(), msg) if err == nil { - t.Error("expected error for missing task_id") + t.Error("expected error for missing session_id") } - if err.Error() != "task_id is required" { - t.Errorf("expected 'task_id is required' error, got: %v", err) + if err.Error() != "session_id is required" { + t.Errorf("expected 'session_id is required' error, got: %v", err) } } @@ -170,14 +170,14 @@ func TestWsShellInput_MissingTaskID(t *testing.T) { log := newTestLogger() handlers := NewShellHandlers(nil, log) - msg, _ := ws.NewRequest("test-1", ws.ActionShellInput, ShellInputRequest{TaskID: "", Data: "test"}) + msg, _ := ws.NewRequest("test-1", ws.ActionShellInput, ShellInputRequest{SessionID: "", Data: "test"}) _, err := handlers.wsShellInput(context.Background(), msg) if err == nil { - t.Error("expected error for missing task_id") + t.Error("expected error for missing session_id") } - if err.Error() != "task_id is required" { - t.Errorf("expected 'task_id is required' error, got: %v", err) + if err.Error() != "session_id is required" { + t.Errorf("expected 'session_id is required' error, got: %v", err) } } @@ -197,7 +197,7 @@ func TestWsShellStatus_NoInstanceFound(t *testing.T) { mgr := newTestManager() handlers := NewShellHandlers(mgr, log) - msg, _ := ws.NewRequest("test-1", ws.ActionShellStatus, ShellStatusRequest{TaskID: "non-existent-task"}) + msg, _ := ws.NewRequest("test-1", ws.ActionShellStatus, ShellStatusRequest{SessionID: "session-1"}) resp, err := handlers.wsShellStatus(context.Background(), msg) if err != nil { @@ -213,8 +213,8 @@ func TestWsShellStatus_NoInstanceFound(t *testing.T) { if payload["available"] != false { t.Errorf("expected available=false, got %v", payload["available"]) } - if payload["error"] != "no agent running for this task" { - t.Errorf("expected 'no agent running for this task', got %v", payload["error"]) + if payload["error"] != "no agent running for this session" { + t.Errorf("expected 'no agent running for this session', got %v", payload["error"]) } } @@ -227,7 +227,7 @@ func TestWsShellStatus_NoClientAvailable(t *testing.T) { // Note: Testing the "agent client not available" path requires injecting an // instance with nil agentctl client, which requires access to lifecycle.Manager // internal maps. This test verifies the handler works correctly with the manager. - msg, _ := ws.NewRequest("test-1", ws.ActionShellStatus, ShellStatusRequest{TaskID: "test-task-id"}) + msg, _ := ws.NewRequest("test-1", ws.ActionShellStatus, ShellStatusRequest{SessionID: "session-1"}) resp, err := handlers.wsShellStatus(context.Background(), msg) if err != nil { @@ -251,15 +251,15 @@ func TestWsShellInput_NoInstanceFound(t *testing.T) { handlers := NewShellHandlers(mgr, log) msg, _ := ws.NewRequest("test-1", ws.ActionShellInput, ShellInputRequest{ - TaskID: "non-existent-task", - Data: "test input", + SessionID: "session-1", + Data: "test input", }) _, err := handlers.wsShellInput(context.Background(), msg) if err == nil { - t.Error("expected error for non-existent task") + t.Error("expected error for non-existent session") } - expectedErr := "no agent running for task non-existent-task" + expectedErr := "no agent running for session session-1" if err.Error() != expectedErr { t.Errorf("expected '%s', got: %v", expectedErr, err) } @@ -269,11 +269,11 @@ func TestSendShellInput_NoActiveStream(t *testing.T) { log := newTestLogger() handlers := NewShellHandlers(nil, log) - err := handlers.SendShellInput("non-existent-task", "test input") + err := handlers.SendShellInput("non-existent-session", "test input") if err == nil { t.Error("expected error for non-existent stream") } - expectedErr := "no active shell stream for task non-existent-task" + expectedErr := "no active shell stream for session non-existent-session" if err.Error() != expectedErr { t.Errorf("expected '%s', got: %v", expectedErr, err) } @@ -283,8 +283,8 @@ func TestStopShellStream_NonExistent(t *testing.T) { log := newTestLogger() handlers := NewShellHandlers(nil, log) - // StopShellStream should not panic when called with non-existent taskID - handlers.StopShellStream("non-existent-task") + // StopShellStream should not panic when called with non-existent sessionID + handlers.StopShellStream("non-existent-session") // No panic = success } @@ -310,10 +310,10 @@ func TestRegisterHandlers_DispatcherReceivesMessages(t *testing.T) { handlers.RegisterHandlers(dispatcher) // Test that dispatcher can dispatch to shell.status - msg, _ := ws.NewRequest("test-1", ws.ActionShellStatus, ShellStatusRequest{TaskID: ""}) + msg, _ := ws.NewRequest("test-1", ws.ActionShellStatus, ShellStatusRequest{SessionID: ""}) _, err := dispatcher.Dispatch(context.Background(), msg) - // Should get "task_id is required" error since we passed empty task ID + // Should get "session_id is required" error since we passed empty session ID if err == nil { t.Error("expected error from dispatcher") } diff --git a/apps/backend/internal/agent/handlers/workspace_file_handlers.go b/apps/backend/internal/agent/handlers/workspace_file_handlers.go index b64b254d1b..8a7b5e19e1 100644 --- a/apps/backend/internal/agent/handlers/workspace_file_handlers.go +++ b/apps/backend/internal/agent/handlers/workspace_file_handlers.go @@ -12,8 +12,8 @@ import ( // WorkspaceFileHandlers handles workspace file operations type WorkspaceFileHandlers struct { - lifecycle *lifecycle.Manager - logger *logger.Logger + lifecycle *lifecycle.Manager + logger *logger.Logger } // NewWorkspaceFileHandlers creates new workspace file handlers @@ -33,23 +33,22 @@ func (h *WorkspaceFileHandlers) RegisterHandlers(d *ws.Dispatcher) { // wsGetFileTree handles workspace.tree.get action func (h *WorkspaceFileHandlers) wsGetFileTree(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req struct { - TaskID string `json:"task_id"` - Path string `json:"path"` - Depth int `json:"depth"` + SessionID string `json:"session_id"` + Path string `json:"path"` + Depth int `json:"depth"` } if err := msg.ParsePayload(&req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } - if req.TaskID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + if req.SessionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) } - - // Get agent execution for this task - execution, found := h.lifecycle.GetExecutionByTaskID(req.TaskID) + // Get agent execution for this session + execution, found := h.lifecycle.GetExecutionBySessionID(req.SessionID) if !found { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "No agent found for task", nil) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "No agent found for session", nil) } // Get agentctl client @@ -61,7 +60,7 @@ func (h *WorkspaceFileHandlers) wsGetFileTree(ctx context.Context, msg *ws.Messa // Request file tree from agentctl response, err := client.RequestFileTree(ctx, req.Path, req.Depth) if err != nil { - h.logger.Error("failed to get file tree", zap.Error(err), zap.String("task_id", req.TaskID)) + h.logger.Error("failed to get file tree", zap.Error(err), zap.String("session_id", req.SessionID)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, fmt.Sprintf("Failed to get file tree: %v", err), nil) } @@ -71,26 +70,25 @@ func (h *WorkspaceFileHandlers) wsGetFileTree(ctx context.Context, msg *ws.Messa // wsGetFileContent handles workspace.file.get action func (h *WorkspaceFileHandlers) wsGetFileContent(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req struct { - TaskID string `json:"task_id"` - Path string `json:"path"` + SessionID string `json:"session_id"` + Path string `json:"path"` } if err := msg.ParsePayload(&req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } - if req.TaskID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + if req.SessionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) } - if req.Path == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "path is required", nil) } - // Get agent execution for this task - execution, found := h.lifecycle.GetExecutionByTaskID(req.TaskID) + // Get agent execution for this session + execution, found := h.lifecycle.GetExecutionBySessionID(req.SessionID) if !found { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "No agent found for task", nil) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "No agent found for session", nil) } // Get agentctl client @@ -102,7 +100,7 @@ func (h *WorkspaceFileHandlers) wsGetFileContent(ctx context.Context, msg *ws.Me // Request file content from agentctl response, err := client.RequestFileContent(ctx, req.Path) if err != nil { - h.logger.Error("failed to get file content", zap.Error(err), zap.String("task_id", req.TaskID), zap.String("path", req.Path)) + h.logger.Error("failed to get file content", zap.Error(err), zap.String("session_id", req.SessionID), zap.String("path", req.Path)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, fmt.Sprintf("Failed to get file content: %v", err), nil) } diff --git a/apps/backend/internal/agent/lifecycle/container.go b/apps/backend/internal/agent/lifecycle/container.go index bce1766f80..4e6f9501b9 100644 --- a/apps/backend/internal/agent/lifecycle/container.go +++ b/apps/backend/internal/agent/lifecycle/container.go @@ -208,6 +208,7 @@ func (cm *ContainerManager) buildContainerConfig(config ContainerConfig) (docker "kandev.managed": "true", "kandev.instance_id": config.InstanceID, "kandev.task_id": config.TaskID, + "kandev.session_id": config.SessionID, }, AutoRemove: false, // We manage cleanup ourselves }, nil diff --git a/apps/backend/internal/agent/lifecycle/events.go b/apps/backend/internal/agent/lifecycle/events.go index 6d1df10a5a..06d567278f 100644 --- a/apps/backend/internal/agent/lifecycle/events.go +++ b/apps/backend/internal/agent/lifecycle/events.go @@ -66,6 +66,33 @@ func (p *EventPublisher) PublishAgentEvent(ctx context.Context, eventType string } } +// PublishAgentctlEvent publishes an agentctl lifecycle event (starting, ready, error). +func (p *EventPublisher) PublishAgentctlEvent(ctx context.Context, eventType string, execution *AgentExecution, errMsg string) { + if p.eventBus == nil { + return + } + + sessionID := execution.SessionID + + data := map[string]interface{}{ + "task_id": execution.TaskID, + "session_id": sessionID, + "agent_execution_id": execution.ID, + } + + if errMsg != "" { + data["error_message"] = errMsg + } + + event := bus.NewEvent(eventType, "agent-manager", data) + if err := p.eventBus.Publish(ctx, eventType, event); err != nil { + p.logger.Error("failed to publish agentctl event", + zap.String("event_type", eventType), + zap.String("instance_id", execution.ID), + zap.Error(err)) + } +} + // PublishACPSessionCreated publishes an event when an ACP session is created. func (p *EventPublisher) PublishACPSessionCreated(execution *AgentExecution, sessionID string) { if p.eventBus == nil || sessionID == "" { @@ -156,8 +183,11 @@ func (p *EventPublisher) PublishGitStatus(execution *AgentExecution, update *age return } + sessionID := execution.SessionID + data := map[string]interface{}{ "task_id": execution.TaskID, + "session_id": sessionID, "agent_id": execution.ID, "branch": update.Branch, "remote_branch": update.RemoteBranch, @@ -189,12 +219,15 @@ func (p *EventPublisher) PublishFileChange(execution *AgentExecution, notificati return } + sessionID := execution.SessionID + data := map[string]interface{}{ - "task_id": execution.TaskID, - "agent_id": execution.ID, - "path": notification.Path, - "operation": notification.Operation, - "timestamp": notification.Timestamp.Format(time.RFC3339Nano), + "task_id": execution.TaskID, + "session_id": sessionID, + "agent_id": execution.ID, + "path": notification.Path, + "operation": notification.Operation, + "timestamp": notification.Timestamp.Format(time.RFC3339Nano), } event := bus.NewEvent(events.FileChangeNotified, "agent-manager", data) @@ -220,11 +253,14 @@ func (p *EventPublisher) PublishPromptComplete(execution *AgentExecution, agentM return } + sessionID := execution.SessionID + data := map[string]interface{}{ "type": "prompt_complete", "timestamp": time.Now().UTC().Format(time.RFC3339Nano), "agent_id": execution.ID, "task_id": execution.TaskID, + "session_id": sessionID, "agent_message": agentMessage, } if reasoning != "" { @@ -252,11 +288,14 @@ func (p *EventPublisher) PublishToolCall(execution *AgentExecution, toolCallID, return } + sessionID := execution.SessionID + data := map[string]interface{}{ "type": "tool_call", "timestamp": time.Now().UTC().Format(time.RFC3339Nano), "agent_id": execution.ID, "task_id": execution.TaskID, + "session_id": sessionID, "tool_call_id": toolCallID, "title": title, "status": status, @@ -287,11 +326,14 @@ func (p *EventPublisher) PublishToolCallComplete(execution *AgentExecution, upda return } + sessionID := execution.SessionID + data := map[string]interface{}{ "type": "tool_call_complete", "timestamp": time.Now().UTC().Format(time.RFC3339Nano), "agent_id": execution.ID, "task_id": execution.TaskID, + "session_id": sessionID, "tool_call_id": update.ToolCallID, "status": update.ToolStatus, } @@ -338,11 +380,12 @@ func (p *EventPublisher) PublishPermissionRequest(execution *AgentExecution, not // Structure as protocol.Message so watcher can parse it correctly data := map[string]interface{}{ - "type": "permission_request", - "timestamp": time.Now().UTC().Format(time.RFC3339Nano), - "agent_id": execution.ID, - "task_id": execution.TaskID, - "data": payloadData, + "type": "permission_request", + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), + "agent_id": execution.ID, + "task_id": execution.TaskID, + "session_id": notification.SessionID, + "data": payloadData, } event := bus.NewEvent(events.ACPMessage, "agent-manager", data) diff --git a/apps/backend/internal/agent/lifecycle/execution_store.go b/apps/backend/internal/agent/lifecycle/execution_store.go index cae89598a7..f138cc751f 100644 --- a/apps/backend/internal/agent/lifecycle/execution_store.go +++ b/apps/backend/internal/agent/lifecycle/execution_store.go @@ -9,10 +9,10 @@ import ( ) // ExecutionStore provides thread-safe storage and retrieval of agent executions. -// It maintains three indexes for efficient lookup by execution ID, task ID, and container ID. +// It maintains three indexes for efficient lookup by execution ID, session ID, and container ID. type ExecutionStore struct { executions map[string]*AgentExecution - byTask map[string]string // taskID -> executionID + bySession map[string]string // sessionID -> executionID byContainer map[string]string // containerID -> executionID mu sync.RWMutex } @@ -21,13 +21,13 @@ type ExecutionStore struct { func NewExecutionStore() *ExecutionStore { return &ExecutionStore{ executions: make(map[string]*AgentExecution), - byTask: make(map[string]string), + bySession: make(map[string]string), byContainer: make(map[string]string), } } // Add adds an agent execution to all tracking maps. -// The execution must have a valid ID. TaskID and ContainerID are optional +// The execution must have a valid ID. SessionID and ContainerID are optional // but will be indexed if present. func (s *ExecutionStore) Add(execution *AgentExecution) { if execution == nil || execution.ID == "" { @@ -39,8 +39,8 @@ func (s *ExecutionStore) Add(execution *AgentExecution) { s.executions[execution.ID] = execution - if execution.TaskID != "" { - s.byTask[execution.TaskID] = execution.ID + if execution.SessionID != "" { + s.bySession[execution.SessionID] = execution.ID } if execution.ContainerID != "" { @@ -59,8 +59,8 @@ func (s *ExecutionStore) Remove(executionID string) { } // Remove from secondary indexes - if execution.TaskID != "" { - delete(s.byTask, execution.TaskID) + if execution.SessionID != "" { + delete(s.bySession, execution.SessionID) } if execution.ContainerID != "" { delete(s.byContainer, execution.ContainerID) @@ -79,12 +79,12 @@ func (s *ExecutionStore) Get(executionID string) (*AgentExecution, bool) { return execution, exists } -// GetByTaskID returns the agent execution associated with a task ID. -func (s *ExecutionStore) GetByTaskID(taskID string) (*AgentExecution, bool) { +// GetBySessionID returns the agent execution associated with a session ID. +func (s *ExecutionStore) GetBySessionID(sessionID string) (*AgentExecution, bool) { s.mu.RLock() defer s.mu.RUnlock() - executionID, exists := s.byTask[taskID] + executionID, exists := s.bySession[sessionID] if !exists { return nil, false } @@ -190,4 +190,3 @@ func (s *ExecutionStore) WithRLock(executionID string, fn func(execution *AgentE fn(execution) return true } - diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index db24bb9eea..800d45ec20 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -14,9 +14,9 @@ import ( "github.com/google/uuid" "go.uber.org/zap" - agentctl "github.com/kandev/kandev/internal/agentctl/client" "github.com/kandev/kandev/internal/agent/registry" "github.com/kandev/kandev/internal/agent/worktree" + agentctl "github.com/kandev/kandev/internal/agentctl/client" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" @@ -130,44 +130,13 @@ func (m *Manager) SetWorkspaceInfoProvider(provider WorkspaceInfoProvider) { m.workspaceInfoProvider = provider } -// EnsureWorkspaceExecution ensures an agentctl execution exists for a task's workspace. -// If an execution already exists, it returns it. Otherwise, it creates a new execution -// for workspace access (shell, git, file operations) WITHOUT starting the agent process. -// This is used to restore workspace access after backend restart. -func (m *Manager) EnsureWorkspaceExecution(ctx context.Context, taskID string) (*AgentExecution, error) { - // Check if execution already exists - if execution, exists := m.executionStore.GetByTaskID(taskID); exists { - return execution, nil - } - - // Need to create a new execution - get workspace info - if m.workspaceInfoProvider == nil { - return nil, fmt.Errorf("workspace info provider not configured") - } - - info, err := m.workspaceInfoProvider.GetWorkspaceInfo(ctx, taskID) - if err != nil { - return nil, fmt.Errorf("failed to get workspace info: %w", err) - } - - if info.WorkspacePath == "" { - return nil, fmt.Errorf("no workspace path available for task %s", taskID) - } - - m.logger.Info("creating execution for task", - zap.String("task_id", taskID), - zap.String("workspace_path", info.WorkspacePath)) - - return m.createExecution(ctx, taskID, info) -} - // EnsureWorkspaceExecutionForSession ensures an agentctl execution exists for a specific task session. // This is used when the frontend provides a session ID (e.g., from URL path /task/[id]/[sessionId]). -// If an execution already exists for the task, it returns it. Otherwise, it creates a new execution +// If an execution already exists for the session, it returns it. Otherwise, it creates a new execution // using the session's workspace configuration from the database. func (m *Manager) EnsureWorkspaceExecutionForSession(ctx context.Context, taskID, sessionID string) (*AgentExecution, error) { // Check if execution already exists - if execution, exists := m.executionStore.GetByTaskID(taskID); exists { + if execution, exists := m.executionStore.GetBySessionID(sessionID); exists { return execution, nil } @@ -215,6 +184,7 @@ func (m *Manager) createExecution(ctx context.Context, taskID string, info *Work req := &RuntimeCreateRequest{ InstanceID: executionID, TaskID: taskID, + SessionID: info.SessionID, AgentProfileID: info.AgentProfileID, WorkspacePath: info.WorkspacePath, Protocol: string(agentConfig.Protocol), @@ -277,6 +247,7 @@ func (m *Manager) Start(ctx context.Context) error { execution := &AgentExecution{ ID: ri.InstanceID, TaskID: ri.TaskID, + SessionID: ri.SessionID, ContainerID: ri.ContainerID, ContainerIP: ri.ContainerIP, WorkspacePath: ri.WorkspacePath, @@ -311,6 +282,7 @@ func (m *Manager) GetRecoveredExecutions() []RecoveredExecution { result = append(result, RecoveredExecution{ ExecutionID: exec.ID, TaskID: exec.TaskID, + SessionID: exec.SessionID, ContainerID: exec.ContainerID, AgentProfileID: exec.AgentProfileID, }) @@ -387,9 +359,11 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecuti return nil, fmt.Errorf("agent type %q is disabled", agentTypeName) } - // 3. Check if task already has an agent running - if existingExecution, exists := m.executionStore.GetByTaskID(req.TaskID); exists { - return nil, fmt.Errorf("task %q already has an agent running (execution: %s)", req.TaskID, existingExecution.ID) + // 3. Check if session already has an agent running + if req.SessionID != "" { + if existingExecution, exists := m.executionStore.GetBySessionID(req.SessionID); exists { + return nil, fmt.Errorf("session %q already has an agent running (execution: %s)", req.SessionID, existingExecution.ID) + } } // 4. Handle worktree creation/reuse if enabled @@ -435,6 +409,9 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecuti if req.TaskDescription != "" { reqWithWorktree.Metadata["task_description"] = req.TaskDescription } + if req.SessionID != "" { + reqWithWorktree.Metadata["session_id"] = req.SessionID + } // 6b. Add profile settings to environment if profileInfo != nil { @@ -468,6 +445,7 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecuti runtimeReq := &RuntimeCreateRequest{ InstanceID: executionID, TaskID: reqWithWorktree.TaskID, + SessionID: reqWithWorktree.SessionID, AgentProfileID: reqWithWorktree.AgentProfileID, WorkspacePath: reqWithWorktree.WorkspacePath, Protocol: string(agentConfig.Protocol), @@ -511,6 +489,7 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecuti // 9. Publish agent.started event m.eventPublisher.PublishAgentEvent(ctx, events.AgentStarted, execution) + m.eventPublisher.PublishAgentctlEvent(ctx, events.AgentctlStarting, execution, "") // 10. Wait for agentctl to be ready (for shell/workspace access) // NOTE: This does NOT start the agent process - call StartAgentProcess() explicitly @@ -612,6 +591,7 @@ func (m *Manager) buildEnvForRuntime(executionID string, req *LaunchRequest, age // Add standard variables for recovery after backend restart env["KANDEV_INSTANCE_ID"] = executionID env["KANDEV_TASK_ID"] = req.TaskID + env["KANDEV_SESSION_ID"] = req.SessionID env["KANDEV_AGENT_PROFILE_ID"] = req.AgentProfileID env["TASK_DESCRIPTION"] = req.TaskDescription @@ -688,15 +668,15 @@ func (m *Manager) waitForAgentctlReady(execution *AgentExecution) { zap.String("execution_id", execution.ID), zap.Error(err)) m.updateExecutionError(execution.ID, "agentctl not ready: "+err.Error()) + m.eventPublisher.PublishAgentctlEvent(context.Background(), events.AgentctlError, execution, err.Error()) return } m.logger.Info("agentctl ready - shell/workspace access available", zap.String("execution_id", execution.ID)) + m.eventPublisher.PublishAgentctlEvent(context.Background(), events.AgentctlReady, execution, "") } - - // getAgentConfigForExecution retrieves the agent configuration for an execution. // The execution must have AgentCommand set (which includes the agent type). func (m *Manager) getAgentConfigForExecution(execution *AgentExecution) (*registry.AgentTypeConfig, error) { @@ -940,11 +920,11 @@ func (m *Manager) StopAgent(ctx context.Context, executionID string, force bool) return nil } -// StopByTaskID stops the agent for a specific task -func (m *Manager) StopByTaskID(ctx context.Context, taskID string, force bool) error { - execution, exists := m.executionStore.GetByTaskID(taskID) +// StopBySessionID stops the agent for a specific session +func (m *Manager) StopBySessionID(ctx context.Context, sessionID string, force bool) error { + execution, exists := m.executionStore.GetBySessionID(sessionID) if !exists { - return fmt.Errorf("no agent running for task %q", taskID) + return fmt.Errorf("no agent running for session %q", sessionID) } return m.StopAgent(ctx, execution.ID, force) @@ -955,9 +935,9 @@ func (m *Manager) GetExecution(executionID string) (*AgentExecution, bool) { return m.executionStore.Get(executionID) } -// GetExecutionByTaskID returns the agent execution for a task -func (m *Manager) GetExecutionByTaskID(taskID string) (*AgentExecution, bool) { - return m.executionStore.GetByTaskID(taskID) +// GetExecutionBySessionID returns the agent execution for a session +func (m *Manager) GetExecutionBySessionID(sessionID string) (*AgentExecution, bool) { + return m.executionStore.GetBySessionID(sessionID) } // GetExecutionByContainerID returns the agent execution for a container @@ -970,11 +950,11 @@ func (m *Manager) ListExecutions() []*AgentExecution { return m.executionStore.List() } -// IsAgentRunningForTask checks if an agent is actually running for a task +// IsAgentRunningForSession checks if an agent is actually running for a session // This probes agentctl's status endpoint to verify the agent process is running -func (m *Manager) IsAgentRunningForTask(ctx context.Context, taskID string) bool { - // First check if we have an execution tracked for this task - execution, exists := m.GetExecutionByTaskID(taskID) +func (m *Manager) IsAgentRunningForSession(ctx context.Context, sessionID string) bool { + // First check if we have an execution tracked for this session + execution, exists := m.GetExecutionBySessionID(sessionID) if !exists { return false } @@ -987,7 +967,7 @@ func (m *Manager) IsAgentRunningForTask(ctx context.Context, taskID string) bool status, err := execution.agentctl.GetStatus(ctx) if err != nil { m.logger.Debug("failed to get agentctl status", - zap.String("task_id", taskID), + zap.String("session_id", sessionID), zap.Error(err)) return false } @@ -1086,16 +1066,16 @@ func (m *Manager) RemoveExecution(executionID string) { zap.String("execution_id", executionID)) } -// CleanupStaleExecutionByTaskID removes a stale agent execution from tracking without trying to stop it. +// CleanupStaleExecutionBySessionID removes a stale agent execution from tracking without trying to stop it. // This is used when we detect the agent process has stopped but the execution is still tracked. -func (m *Manager) CleanupStaleExecutionByTaskID(ctx context.Context, taskID string) error { - execution, exists := m.executionStore.GetByTaskID(taskID) +func (m *Manager) CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error { + execution, exists := m.executionStore.GetBySessionID(sessionID) if !exists { return nil // No execution to clean up } m.logger.Info("cleaning up stale agent execution", - zap.String("task_id", taskID), + zap.String("session_id", sessionID), zap.String("execution_id", execution.ID)) // Close agentctl connection if it exists @@ -1205,21 +1185,21 @@ func (m *Manager) RespondToPermission(executionID, pendingID, optionID string, c return execution.agentctl.RespondToPermission(ctx, pendingID, optionID, cancelled) } -// RespondToPermissionByTaskID sends a response to a permission request for a task -func (m *Manager) RespondToPermissionByTaskID(taskID, pendingID, optionID string, cancelled bool) error { - execution, exists := m.executionStore.GetByTaskID(taskID) +// RespondToPermissionBySessionID sends a response to a permission request for a session +func (m *Manager) RespondToPermissionBySessionID(sessionID, pendingID, optionID string, cancelled bool) error { + execution, exists := m.executionStore.GetBySessionID(sessionID) if !exists { - return fmt.Errorf("no agent execution found for task: %s", taskID) + return fmt.Errorf("no agent execution found for session: %s", sessionID) } return m.RespondToPermission(execution.ID, pendingID, optionID, cancelled) } -// GetPendingPermissionsForTask returns all pending permissions for a task -func (m *Manager) GetPendingPermissionsForTask(ctx context.Context, taskID string) ([]agentctl.PermissionNotification, error) { - execution, exists := m.executionStore.GetByTaskID(taskID) +// GetPendingPermissionsForSession returns all pending permissions for a session +func (m *Manager) GetPendingPermissionsForSession(ctx context.Context, sessionID string) ([]agentctl.PermissionNotification, error) { + execution, exists := m.executionStore.GetBySessionID(sessionID) if !exists { - // No active execution for this task, so no pending permissions + // No active execution for this session, so no pending permissions return nil, nil } diff --git a/apps/backend/internal/agent/lifecycle/manager_test.go b/apps/backend/internal/agent/lifecycle/manager_test.go index af522585f6..58b9311203 100644 --- a/apps/backend/internal/agent/lifecycle/manager_test.go +++ b/apps/backend/internal/agent/lifecycle/manager_test.go @@ -198,12 +198,13 @@ func TestManager_GetExecution(t *testing.T) { } } -func TestManager_GetExecutionByTaskID(t *testing.T) { +func TestManager_GetExecutionBySessionID(t *testing.T) { mgr := newTestManager() execution := &AgentExecution{ ID: "test-execution-id", TaskID: "test-task-id", + SessionID: "test-session-id", AgentProfileID: "test-agent", ContainerID: "container-123", Status: v1.AgentStatusRunning, @@ -212,17 +213,17 @@ func TestManager_GetExecutionByTaskID(t *testing.T) { mgr.executionStore.Add(execution) - // Test GetExecutionByTaskID - got, found := mgr.GetExecutionByTaskID("test-task-id") + // Test GetExecutionBySessionID + got, found := mgr.GetExecutionBySessionID("test-session-id") if !found { t.Fatal("expected to find execution") } - if got.TaskID != execution.TaskID { - t.Errorf("expected TaskID %q, got %q", execution.TaskID, got.TaskID) + if got.SessionID != execution.SessionID { + t.Errorf("expected SessionID %q, got %q", execution.SessionID, got.SessionID) } // Test not found - _, found = mgr.GetExecutionByTaskID("non-existent") + _, found = mgr.GetExecutionBySessionID("non-existent") if found { t.Error("expected not to find execution") } @@ -419,6 +420,7 @@ func TestManager_RemoveExecution(t *testing.T) { execution := &AgentExecution{ ID: "test-execution-id", TaskID: "test-task-id", + SessionID: "test-session-id", ContainerID: "container-123", } @@ -431,8 +433,8 @@ func TestManager_RemoveExecution(t *testing.T) { if _, found := mgr.GetExecution("test-execution-id"); found { t.Error("execution should be removed from executions map") } - if _, found := mgr.GetExecutionByTaskID("test-task-id"); found { - t.Error("execution should be removed from byTask map") + if _, found := mgr.GetExecutionBySessionID("test-session-id"); found { + t.Error("execution should be removed from bySession map") } if _, found := mgr.GetExecutionByContainerID("container-123"); found { t.Error("execution should be removed from byContainer map") diff --git a/apps/backend/internal/agent/lifecycle/runtime.go b/apps/backend/internal/agent/lifecycle/runtime.go index ddb0300b09..ae3b8f4dc9 100644 --- a/apps/backend/internal/agent/lifecycle/runtime.go +++ b/apps/backend/internal/agent/lifecycle/runtime.go @@ -37,6 +37,7 @@ type Runtime interface { type RuntimeCreateRequest struct { InstanceID string TaskID string + SessionID string AgentProfileID string WorkspacePath string Protocol string @@ -55,6 +56,7 @@ type RuntimeInstance struct { // Core identifiers InstanceID string TaskID string + SessionID string // Agentctl client for communicating with this instance Client *agentctl.Client @@ -89,6 +91,7 @@ func (ri *RuntimeInstance) ToAgentExecution(req *RuntimeCreateRequest) *AgentExe return &AgentExecution{ ID: ri.InstanceID, TaskID: req.TaskID, + SessionID: req.SessionID, AgentProfileID: req.AgentProfileID, ContainerID: ri.ContainerID, ContainerIP: ri.ContainerIP, @@ -102,4 +105,3 @@ func (ri *RuntimeInstance) ToAgentExecution(req *RuntimeCreateRequest) *AgentExe standalonePort: ri.StandalonePort, } } - diff --git a/apps/backend/internal/agent/lifecycle/runtime_docker.go b/apps/backend/internal/agent/lifecycle/runtime_docker.go index 73eee1f7da..1087958641 100644 --- a/apps/backend/internal/agent/lifecycle/runtime_docker.go +++ b/apps/backend/internal/agent/lifecycle/runtime_docker.go @@ -47,6 +47,7 @@ func (r *DockerRuntime) CreateInstance(ctx context.Context, req *RuntimeCreateRe AgentConfig: req.AgentConfig, WorkspacePath: req.WorkspacePath, TaskID: req.TaskID, + SessionID: req.SessionID, InstanceID: req.InstanceID, MainRepoGitDir: req.MainRepoGitDir, Credentials: req.Env, // Env contains credentials from the caller @@ -77,6 +78,7 @@ func (r *DockerRuntime) CreateInstance(ctx context.Context, req *RuntimeCreateRe return &RuntimeInstance{ InstanceID: req.InstanceID, TaskID: req.TaskID, + SessionID: req.SessionID, Client: client, ContainerID: containerID, ContainerIP: containerIP, @@ -134,6 +136,7 @@ func (r *DockerRuntime) RecoverInstances(ctx context.Context) ([]*RuntimeInstanc instanceID := labels["kandev.instance_id"] taskID := labels["kandev.task_id"] + sessionID := labels["kandev.session_id"] agentProfileID := labels["kandev.agent_profile_id"] if instanceID == "" || taskID == "" { @@ -167,6 +170,7 @@ func (r *DockerRuntime) RecoverInstances(ctx context.Context) ([]*RuntimeInstanc recovered = append(recovered, &RuntimeInstance{ InstanceID: instanceID, TaskID: taskID, + SessionID: sessionID, Client: client, ContainerID: ctr.ID, ContainerIP: containerIP, @@ -183,4 +187,3 @@ func (r *DockerRuntime) RecoverInstances(ctx context.Context) ([]*RuntimeInstanc return recovered, nil } - diff --git a/apps/backend/internal/agent/lifecycle/runtime_standalone.go b/apps/backend/internal/agent/lifecycle/runtime_standalone.go index 5655412cfb..04b3eb989b 100644 --- a/apps/backend/internal/agent/lifecycle/runtime_standalone.go +++ b/apps/backend/internal/agent/lifecycle/runtime_standalone.go @@ -44,6 +44,7 @@ func (r *StandaloneRuntime) CreateInstance(ctx context.Context, req *RuntimeCrea env = make(map[string]string) } env["KANDEV_TASK_ID"] = req.TaskID + env["KANDEV_SESSION_ID"] = req.SessionID // Create instance via control API // Agent command is NOT set - workspace access only. Agent is started explicitly via agentctl client. @@ -81,6 +82,7 @@ func (r *StandaloneRuntime) CreateInstance(ctx context.Context, req *RuntimeCrea return &RuntimeInstance{ InstanceID: req.InstanceID, TaskID: req.TaskID, + SessionID: req.SessionID, Client: client, StandaloneInstanceID: resp.ID, StandalonePort: resp.Port, diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go index f3fb11aa97..94fed70293 100644 --- a/apps/backend/internal/agent/lifecycle/types.go +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -19,6 +19,7 @@ const AgentCtlPort = 9999 type AgentExecution struct { ID string TaskID string + SessionID string AgentProfileID string ContainerID string ContainerIP string // IP address of the container for agentctl communication @@ -95,7 +96,7 @@ type ProfileResolver interface { // ShellStreamStarter is called to start shell streaming for an agent execution type ShellStreamStarter interface { - StartShellStream(ctx context.Context, taskID string) error + StartShellStream(ctx context.Context, sessionID string) error } // WorkspaceInfo contains information about a task's workspace for on-demand execution creation @@ -110,8 +111,6 @@ type WorkspaceInfo struct { // WorkspaceInfoProvider provides workspace information for tasks type WorkspaceInfoProvider interface { - // GetWorkspaceInfo returns workspace info for a task (uses most recent session) - GetWorkspaceInfo(ctx context.Context, taskID string) (*WorkspaceInfo, error) // GetWorkspaceInfoForSession returns workspace info for a specific task session GetWorkspaceInfoForSession(ctx context.Context, taskID, sessionID string) (*WorkspaceInfo, error) } @@ -120,6 +119,7 @@ type WorkspaceInfoProvider interface { type RecoveredExecution struct { ExecutionID string TaskID string + SessionID string ContainerID string AgentProfileID string } @@ -129,4 +129,3 @@ type PromptResult struct { StopReason string // The reason the agent stopped (e.g., "end_turn") AgentMessage string // The agent's accumulated response message } - diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index 408d036f43..7a2999781e 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -83,6 +83,9 @@ const ( AgentFailed = "agent.failed" AgentStopped = "agent.stopped" AgentACPSessionCreated = "agent.acp_session_created" + AgentctlStarting = "agentctl.starting" + AgentctlReady = "agentctl.ready" + AgentctlError = "agentctl.error" ) // Event types for ACP messages diff --git a/apps/backend/internal/gateway/websocket/client.go b/apps/backend/internal/gateway/websocket/client.go index 7bb0af8d45..c23cecdacf 100644 --- a/apps/backend/internal/gateway/websocket/client.go +++ b/apps/backend/internal/gateway/websocket/client.go @@ -29,27 +29,29 @@ const ( // Client represents a single WebSocket connection type Client struct { - ID string - conn *websocket.Conn - hub *Hub - send chan []byte - subscriptions map[string]bool // Task IDs this client is subscribed to - userSubscriptions map[string]bool // User IDs this client is subscribed to - mu sync.RWMutex - closed bool - logger *logger.Logger + ID string + conn *websocket.Conn + hub *Hub + send chan []byte + subscriptions map[string]bool // Task IDs this client is subscribed to + sessionSubscriptions map[string]bool // Session IDs this client is subscribed to + userSubscriptions map[string]bool // User IDs this client is subscribed to + mu sync.RWMutex + closed bool + logger *logger.Logger } // NewClient creates a new WebSocket client func NewClient(id string, conn *websocket.Conn, hub *Hub, log *logger.Logger) *Client { return &Client{ - ID: id, - conn: conn, - hub: hub, - send: make(chan []byte, 256), - subscriptions: make(map[string]bool), - userSubscriptions: make(map[string]bool), - logger: log.WithFields(zap.String("client_id", id)), + ID: id, + conn: conn, + hub: hub, + send: make(chan []byte, 256), + subscriptions: make(map[string]bool), + sessionSubscriptions: make(map[string]bool), + userSubscriptions: make(map[string]bool), + logger: log.WithFields(zap.String("client_id", id)), } } @@ -111,6 +113,12 @@ func (c *Client) handleMessage(ctx context.Context, msg *ws.Message) { case ws.ActionTaskUnsubscribe: c.handleUnsubscribe(msg) return + case ws.ActionSessionSubscribe: + c.handleSessionSubscribe(msg) + return + case ws.ActionSessionUnsubscribe: + c.handleSessionUnsubscribe(msg) + return case ws.ActionUserSubscribe: c.handleUserSubscribe(msg) return @@ -172,6 +180,10 @@ type UserSubscribeRequest struct { UserID string `json:"user_id,omitempty"` } +type SessionSubscribeRequest struct { + SessionID string `json:"session_id"` +} + func (c *Client) handleUserSubscribe(msg *ws.Message) { var req UserSubscribeRequest if err := msg.ParsePayload(&req); err != nil { @@ -196,6 +208,26 @@ func (c *Client) handleUserSubscribe(msg *ws.Message) { c.sendMessage(resp) } +func (c *Client) handleSessionSubscribe(msg *ws.Message) { + var req SessionSubscribeRequest + if err := msg.ParsePayload(&req); err != nil { + c.sendError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + return + } + + if req.SessionID == "" { + c.sendError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) + return + } + + c.hub.SubscribeToSession(c, req.SessionID) + resp, _ := ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "success": true, + "session_id": req.SessionID, + }) + c.sendMessage(resp) +} + func (c *Client) handleUserUnsubscribe(msg *ws.Message) { var req UserSubscribeRequest if err := msg.ParsePayload(&req); err != nil { @@ -290,6 +322,28 @@ func (c *Client) handleUnsubscribe(msg *ws.Message) { c.sendMessage(resp) } +// handleSessionUnsubscribe handles session.unsubscribe action +func (c *Client) handleSessionUnsubscribe(msg *ws.Message) { + var req SessionSubscribeRequest + if err := msg.ParsePayload(&req); err != nil { + c.sendError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + return + } + + if req.SessionID == "" { + c.sendError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) + return + } + + c.hub.UnsubscribeFromSession(c, req.SessionID) + + resp, _ := ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "success": true, + "session_id": req.SessionID, + }) + c.sendMessage(resp) +} + // sendMessage sends a message to the client func (c *Client) sendMessage(msg *ws.Message) { data, err := json.Marshal(msg) diff --git a/apps/backend/internal/gateway/websocket/hub.go b/apps/backend/internal/gateway/websocket/hub.go index 4cd8a0f870..4daad9e98a 100644 --- a/apps/backend/internal/gateway/websocket/hub.go +++ b/apps/backend/internal/gateway/websocket/hub.go @@ -24,6 +24,8 @@ type Hub struct { // Clients subscribed to specific tasks (for ACP notifications) taskSubscribers map[string]map[*Client]bool + // Clients subscribed to specific sessions + sessionSubscribers map[string]map[*Client]bool // Clients subscribed to specific users (for user settings notifications) userSubscribers map[string]map[*Client]bool @@ -49,14 +51,15 @@ type Hub struct { // NewHub creates a new WebSocket hub func NewHub(dispatcher *ws.Dispatcher, log *logger.Logger) *Hub { return &Hub{ - clients: make(map[*Client]bool), - taskSubscribers: make(map[string]map[*Client]bool), - userSubscribers: make(map[string]map[*Client]bool), - register: make(chan *Client), - unregister: make(chan *Client), - broadcast: make(chan *ws.Message, 256), - dispatcher: dispatcher, - logger: log.WithFields(zap.String("component", "ws_hub")), + clients: make(map[*Client]bool), + taskSubscribers: make(map[string]map[*Client]bool), + sessionSubscribers: make(map[string]map[*Client]bool), + userSubscribers: make(map[string]map[*Client]bool), + register: make(chan *Client), + unregister: make(chan *Client), + broadcast: make(chan *ws.Message, 256), + dispatcher: dispatcher, + logger: log.WithFields(zap.String("component", "ws_hub")), } } @@ -96,6 +99,7 @@ func (h *Hub) closeAllClients() { delete(h.clients, client) } h.taskSubscribers = make(map[string]map[*Client]bool) + h.sessionSubscribers = make(map[string]map[*Client]bool) } // removeClient removes a client from the hub @@ -116,6 +120,14 @@ func (h *Hub) removeClient(client *Client) { } } } + for sessionID := range client.sessionSubscriptions { + if clients, ok := h.sessionSubscribers[sessionID]; ok { + delete(clients, client) + if len(clients) == 0 { + delete(h.sessionSubscribers, sessionID) + } + } + } for userID := range client.userSubscriptions { if clients, ok := h.userSubscribers[userID]; ok { delete(clients, client) @@ -196,6 +208,40 @@ func (h *Hub) BroadcastToTask(taskID string, msg *ws.Message) { } } +// BroadcastToSession sends a notification to clients subscribed to a specific session +func (h *Hub) BroadcastToSession(sessionID string, msg *ws.Message) { + data, err := json.Marshal(msg) + if err != nil { + h.logger.Error("Failed to marshal message", zap.Error(err)) + return + } + + h.mu.RLock() + subscriberMap := h.sessionSubscribers[sessionID] + clients := make([]*Client, 0, len(subscriberMap)) + for client := range subscriberMap { + clients = append(clients, client) + } + h.mu.RUnlock() + + h.logger.Debug("BroadcastToSession", + zap.String("session_id", sessionID), + zap.String("action", msg.Action), + zap.Int("subscriber_count", len(clients))) + + for _, client := range clients { + if client.sendBytes(data) { + h.logger.Debug("Sent message to client", + zap.String("client_id", client.ID), + zap.String("action", msg.Action)) + } else { + h.logger.Warn("Client send buffer full, dropping message", + zap.String("client_id", client.ID), + zap.String("action", msg.Action)) + } + } +} + // BroadcastToUser sends a notification to clients subscribed to a specific user func (h *Hub) BroadcastToUser(userID string, msg *ws.Message) { data, err := json.Marshal(msg) @@ -247,6 +293,36 @@ func (h *Hub) SubscribeToTask(client *Client, taskID string) { zap.String("task_id", taskID)) } +// SubscribeToSession subscribes a client to session notifications +func (h *Hub) SubscribeToSession(client *Client, sessionID string) { + h.mu.Lock() + defer h.mu.Unlock() + + if _, ok := h.sessionSubscribers[sessionID]; !ok { + h.sessionSubscribers[sessionID] = make(map[*Client]bool) + } + h.sessionSubscribers[sessionID][client] = true + client.sessionSubscriptions[sessionID] = true + + h.logger.Debug("Client subscribed to session", + zap.String("client_id", client.ID), + zap.String("session_id", sessionID)) +} + +// UnsubscribeFromSession unsubscribes a client from session notifications +func (h *Hub) UnsubscribeFromSession(client *Client, sessionID string) { + h.mu.Lock() + defer h.mu.Unlock() + + delete(client.sessionSubscriptions, sessionID) + if clients, ok := h.sessionSubscribers[sessionID]; ok { + delete(clients, client) + if len(clients) == 0 { + delete(h.sessionSubscribers, sessionID) + } + } +} + // SubscribeToUser subscribes a client to user notifications func (h *Hub) SubscribeToUser(client *Client, userID string) { h.mu.Lock() diff --git a/apps/backend/internal/gateway/websocket/task_notifications.go b/apps/backend/internal/gateway/websocket/task_notifications.go index 495243ce9c..dbe16780fa 100644 --- a/apps/backend/internal/gateway/websocket/task_notifications.go +++ b/apps/backend/internal/gateway/websocket/task_notifications.go @@ -50,6 +50,11 @@ func RegisterTaskNotifications(ctx context.Context, eventBus bus.EventBus, hub * b.subscribe(eventBus, events.EnvironmentCreated, ws.ActionEnvironmentCreated) b.subscribe(eventBus, events.EnvironmentUpdated, ws.ActionEnvironmentUpdated) b.subscribe(eventBus, events.EnvironmentDeleted, ws.ActionEnvironmentDeleted) + b.subscribe(eventBus, events.MessageAdded, ws.ActionSessionMessageAdded) + b.subscribe(eventBus, events.MessageUpdated, ws.ActionSessionMessageUpdated) + b.subscribe(eventBus, events.AgentctlStarting, ws.ActionSessionAgentctlStarting) + b.subscribe(eventBus, events.AgentctlReady, ws.ActionSessionAgentctlReady) + b.subscribe(eventBus, events.AgentctlError, ws.ActionSessionAgentctlError) go func() { <-ctx.Done() @@ -75,6 +80,18 @@ func (b *TaskEventBroadcaster) subscribe(eventBus bus.EventBus, subject, action b.logger.Error("failed to build websocket notification", zap.String("action", action), zap.Error(err)) return nil } + switch action { + case ws.ActionSessionAgentctlStarting, ws.ActionSessionAgentctlReady, ws.ActionSessionAgentctlError: + if sessionID, ok := event.Data["session_id"].(string); ok && sessionID != "" { + b.hub.BroadcastToSession(sessionID, msg) + return nil + } + case ws.ActionSessionMessageAdded, ws.ActionSessionMessageUpdated: + if sessionID, ok := event.Data["session_id"].(string); ok && sessionID != "" { + b.hub.BroadcastToSession(sessionID, msg) + return nil + } + } b.hub.Broadcast(msg) return nil }) diff --git a/apps/backend/internal/integration/integration_test.go b/apps/backend/internal/integration/integration_test.go index fae659b523..b250053772 100644 --- a/apps/backend/internal/integration/integration_test.go +++ b/apps/backend/internal/integration/integration_test.go @@ -94,7 +94,7 @@ func NewTestServer(t *testing.T) *TestServer { repositoryController := taskcontroller.NewRepositoryController(taskSvc) taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log) taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log) - taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, log) + taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, nil, log) taskhandlers.RegisterRepositoryRoutes(router, gateway.Dispatcher, repositoryController, log) taskhandlers.RegisterExecutorRoutes(router, gateway.Dispatcher, executorController, log) taskhandlers.RegisterEnvironmentRoutes(router, gateway.Dispatcher, environmentController, log) diff --git a/apps/backend/internal/integration/orchestrator_test.go b/apps/backend/internal/integration/orchestrator_test.go index 17048a83c0..3051572e93 100644 --- a/apps/backend/internal/integration/orchestrator_test.go +++ b/apps/backend/internal/integration/orchestrator_test.go @@ -63,6 +63,7 @@ type SimulatedAgentManagerClient struct { type simulatedInstance struct { id string taskID string + sessionID string agentProfileID string status v1.AgentStatus statusMu sync.Mutex // Protects status field @@ -125,6 +126,7 @@ func (s *SimulatedAgentManagerClient) LaunchAgent(ctx context.Context, req *exec instance := &simulatedInstance{ id: executionID, taskID: req.TaskID, + sessionID: req.SessionID, agentProfileID: req.AgentProfileID, status: v1.AgentStatusStarting, stopCh: make(chan struct{}), @@ -137,8 +139,8 @@ func (s *SimulatedAgentManagerClient) LaunchAgent(ctx context.Context, req *exec return &executor.LaunchAgentResponse{ AgentExecutionID: executionID, - ContainerID: containerID, - Status: v1.AgentStatusStarting, + ContainerID: containerID, + Status: v1.AgentStatusStarting, }, nil } @@ -248,11 +250,16 @@ func (s *SimulatedAgentManagerClient) publishACPMessages(instance *simulatedInst // Publish each message with a small delay for _, msg := range messages { + sessionID := msg.SessionID + if sessionID == "" { + sessionID = instance.sessionID + } msgData := map[string]interface{}{ - "type": msg.Type, - "task_id": msg.TaskID, - "timestamp": msg.Timestamp, - "data": msg.Data, + "type": msg.Type, + "task_id": msg.TaskID, + "session_id": sessionID, + "timestamp": msg.Timestamp, + "data": msg.Data, } event := bus.NewEvent(events.ACPMessage, "simulated-agent", msgData) @@ -371,10 +378,10 @@ func (s *SimulatedAgentManagerClient) PromptAgent(ctx context.Context, agentExec }, nil } -// RespondToPermissionByTaskID responds to a permission request for a task -func (s *SimulatedAgentManagerClient) RespondToPermissionByTaskID(ctx context.Context, taskID, pendingID, optionID string, cancelled bool) error { +// RespondToPermissionBySessionID responds to a permission request for a session +func (s *SimulatedAgentManagerClient) RespondToPermissionBySessionID(ctx context.Context, sessionID, pendingID, optionID string, cancelled bool) error { s.logger.Info("simulated: responding to permission", - zap.String("task_id", taskID), + zap.String("session_id", sessionID), zap.String("pending_id", pendingID), zap.String("option_id", optionID), zap.Bool("cancelled", cancelled)) @@ -424,20 +431,20 @@ func (s *SimulatedAgentManagerClient) GetRecoveredExecutions() []executor.Recove return nil } -// IsAgentRunningForTask checks if a simulated agent is running for a task -func (s *SimulatedAgentManagerClient) IsAgentRunningForTask(ctx context.Context, taskID string) bool { +// IsAgentRunningForSession checks if a simulated agent is running for a session +func (s *SimulatedAgentManagerClient) IsAgentRunningForSession(ctx context.Context, sessionID string) bool { s.mu.Lock() defer s.mu.Unlock() for _, inst := range s.instances { - if inst.taskID == taskID && inst.status == v1.AgentStatusRunning { + if inst.sessionID == sessionID && inst.status == v1.AgentStatusRunning { return true } } return false } -func (s *SimulatedAgentManagerClient) CleanupStaleExecutionByTaskID(ctx context.Context, taskID string) error { +func (s *SimulatedAgentManagerClient) CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error { return nil } @@ -564,7 +571,7 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer { repositoryController := taskcontroller.NewRepositoryController(taskSvc) taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log) taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log) - taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, log) + taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, nil, log) taskhandlers.RegisterRepositoryRoutes(router, gateway.Dispatcher, repositoryController, log) taskhandlers.RegisterExecutorRoutes(router, gateway.Dispatcher, executorController, log) taskhandlers.RegisterEnvironmentRoutes(router, gateway.Dispatcher, environmentController, log) @@ -635,12 +642,12 @@ func (ts *OrchestratorTestServer) CreateTestTask(t *testing.T, agentProfileID st require.NoError(t, err) task, err := ts.TaskSvc.CreateTask(context.Background(), &taskservice.CreateTaskRequest{ - WorkspaceID: workspace.ID, - BoardID: board.ID, - ColumnID: col.ID, - Title: "Test Task", - Description: "This is a test task for the orchestrator", - Priority: priority, + WorkspaceID: workspace.ID, + BoardID: board.ID, + ColumnID: col.ID, + Title: "Test Task", + Description: "This is a test task for the orchestrator", + Priority: priority, Repositories: []taskservice.TaskRepositoryInput{ { RepositoryID: repository.ID, @@ -875,6 +882,11 @@ func TestOrchestratorStartTask(t *testing.T) { assert.True(t, payload["success"].(bool)) assert.Equal(t, taskID, payload["task_id"]) assert.NotEmpty(t, payload["agent_execution_id"]) + sessionID, _ := payload["session_id"].(string) + require.NotEmpty(t, sessionID) + + _, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) + require.NoError(t, err) // Wait for ACP notifications time.Sleep(500 * time.Millisecond) @@ -930,10 +942,14 @@ func TestOrchestratorStopTask(t *testing.T) { "agent_profile_id": "augment-agent", }) require.NoError(t, err) - var startPayload map[string]interface{} require.NoError(t, startResp.ParsePayload(&startPayload)) require.True(t, startPayload["success"].(bool)) + sessionID, _ := startPayload["session_id"].(string) + require.NotEmpty(t, sessionID) + + _, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) + require.NoError(t, err) // Stop task stopResp, err := client.SendRequest("stop-1", ws.ActionOrchestratorStop, map[string]interface{}{ @@ -971,14 +987,20 @@ func TestOrchestratorPromptTask(t *testing.T) { var startPayload map[string]interface{} require.NoError(t, startResp.ParsePayload(&startPayload)) require.True(t, startPayload["success"].(bool)) + sessionID, _ := startPayload["session_id"].(string) + require.NotEmpty(t, sessionID) + + _, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) + require.NoError(t, err) // Wait for agent to be ready time.Sleep(300 * time.Millisecond) // Send follow-up prompt promptResp, err := client.SendRequest("prompt-1", ws.ActionOrchestratorPrompt, map[string]interface{}{ - "task_id": taskID, - "prompt": "Please continue and add error handling", + "task_id": taskID, + "session_id": sessionID, + "prompt": "Please continue and add error handling", }) require.NoError(t, err) @@ -1194,11 +1216,20 @@ func TestOrchestratorMultipleClients(t *testing.T) { require.NoError(t, err) // Client 1 starts the task - _, err = client1.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + startResp, err := client1.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ "task_id": taskID, "agent_profile_id": "augment-agent", }) require.NoError(t, err) + var startPayload map[string]interface{} + require.NoError(t, startResp.ParsePayload(&startPayload)) + sessionID, _ := startPayload["session_id"].(string) + require.NotEmpty(t, sessionID) + + _, err = client1.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) + require.NoError(t, err) + _, err = client2.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) + require.NoError(t, err) // Both clients should receive notifications time.Sleep(500 * time.Millisecond) @@ -1256,7 +1287,8 @@ func TestOrchestratorErrorHandling(t *testing.T) { defer client.Close() resp, err := client.SendRequest("prompt-1", ws.ActionOrchestratorPrompt, map[string]interface{}{ - "task_id": taskID, + "task_id": taskID, + "session_id": "session-1", }) require.NoError(t, err) @@ -1420,14 +1452,14 @@ func TestOrchestratorEndToEndWorkflow(t *testing.T) { repositoryID := repoPayload["id"].(string) taskResp, err := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "board_id": boardID, - "column_id": colID, - "title": "Implement feature X", - "description": "Create a new feature with tests", - "priority": 2, - "repository_id": repositoryID, - "base_branch": "main", + "workspace_id": workspaceID, + "board_id": boardID, + "column_id": colID, + "title": "Implement feature X", + "description": "Create a new feature with tests", + "priority": 2, + "repository_id": repositoryID, + "base_branch": "main", }) require.NoError(t, err) @@ -1451,6 +1483,11 @@ func TestOrchestratorEndToEndWorkflow(t *testing.T) { assert.True(t, startPayload["success"].(bool)) agentExecutionID := startPayload["agent_execution_id"].(string) assert.NotEmpty(t, agentExecutionID) + sessionID, _ := startPayload["session_id"].(string) + require.NotEmpty(t, sessionID) + + _, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) + require.NoError(t, err) // 6. Collect notifications during execution time.Sleep(400 * time.Millisecond) diff --git a/apps/backend/internal/notifications/models/models.go b/apps/backend/internal/notifications/models/models.go index 5a7e86ab25..aaeb3c70c8 100644 --- a/apps/backend/internal/notifications/models/models.go +++ b/apps/backend/internal/notifications/models/models.go @@ -35,6 +35,6 @@ type Delivery struct { UserID string `json:"user_id"` ProviderID string `json:"provider_id"` EventType string `json:"event_type"` - TaskSessionID string `json:"task_session_id"` + TaskSessionID string `json:"session_id"` CreatedAt time.Time `json:"created_at"` } diff --git a/apps/backend/internal/notifications/providers/local.go b/apps/backend/internal/notifications/providers/local.go index 464407056e..f2b1686540 100644 --- a/apps/backend/internal/notifications/providers/local.go +++ b/apps/backend/internal/notifications/providers/local.go @@ -28,11 +28,11 @@ func (p *LocalProvider) Send(_ context.Context, message Message) error { if p.hub == nil { return fmt.Errorf("websocket hub not available") } - msg, err := ws.NewNotification(ws.ActionTaskSessionWaitingForInput, map[string]interface{}{ - "task_id": message.TaskID, - "task_session_id": message.TaskSessionID, - "title": message.Title, - "body": message.Body, + msg, err := ws.NewNotification(ws.ActionSessionWaitingForInput, map[string]interface{}{ + "task_id": message.TaskID, + "session_id": message.TaskSessionID, + "title": message.Title, + "body": message.Body, }) if err != nil { return err diff --git a/apps/backend/internal/notifications/service/service.go b/apps/backend/internal/notifications/service/service.go index 52d3153168..0750714dcd 100644 --- a/apps/backend/internal/notifications/service/service.go +++ b/apps/backend/internal/notifications/service/service.go @@ -19,7 +19,7 @@ import ( ) const ( - EventTaskSessionWaitingForInput = "task_session.waiting_for_input" + EventTaskSessionWaitingForInput = "session.waiting_for_input" ) var ErrProviderNotFound = errors.New("notification provider not found") diff --git a/apps/backend/internal/orchestrator/controller/controller.go b/apps/backend/internal/orchestrator/controller/controller.go index def3c7fda5..4ce768da12 100644 --- a/apps/backend/internal/orchestrator/controller/controller.go +++ b/apps/backend/internal/orchestrator/controller/controller.go @@ -140,7 +140,7 @@ func (c *Controller) StopTask(ctx context.Context, req dto.StopTaskRequest) (dto // PromptTask sends a prompt to a running task func (c *Controller) PromptTask(ctx context.Context, req dto.PromptTaskRequest) (dto.PromptTaskResponse, error) { - result, err := c.service.PromptTask(ctx, req.TaskID, req.Prompt) + result, err := c.service.PromptTask(ctx, req.TaskID, req.TaskSessionID, req.Prompt) if err != nil { return dto.PromptTaskResponse{}, err } @@ -165,13 +165,13 @@ func (c *Controller) CompleteTask(ctx context.Context, req dto.CompleteTaskReque // RespondToPermission responds to a permission request func (c *Controller) RespondToPermission(ctx context.Context, req dto.PermissionRespondRequest) (dto.PermissionRespondResponse, error) { - if err := c.service.RespondToPermission(ctx, req.TaskID, req.PendingID, req.OptionID, req.Cancelled); err != nil { + if err := c.service.RespondToPermission(ctx, req.SessionID, req.PendingID, req.OptionID, req.Cancelled); err != nil { return dto.PermissionRespondResponse{}, err } return dto.PermissionRespondResponse{ Success: true, - TaskID: req.TaskID, + SessionID: req.SessionID, PendingID: req.PendingID, }, nil } diff --git a/apps/backend/internal/orchestrator/dto/dto.go b/apps/backend/internal/orchestrator/dto/dto.go index e4d53b9432..4c9ee6acfa 100644 --- a/apps/backend/internal/orchestrator/dto/dto.go +++ b/apps/backend/internal/orchestrator/dto/dto.go @@ -52,7 +52,7 @@ type StartTaskResponse struct { Success bool `json:"success"` TaskID string `json:"task_id"` AgentExecutionID string `json:"agent_execution_id"` - TaskSessionID string `json:"task_session_id,omitempty"` + TaskSessionID string `json:"session_id,omitempty"` State string `json:"state"` WorktreePath *string `json:"worktree_path,omitempty"` WorktreeBranch *string `json:"worktree_branch,omitempty"` @@ -61,7 +61,7 @@ type StartTaskResponse struct { // ResumeTaskSessionRequest is the payload for task.session.resume type ResumeTaskSessionRequest struct { TaskID string `json:"task_id"` - TaskSessionID string `json:"task_session_id"` + TaskSessionID string `json:"session_id"` } // ResumeTaskSessionResponse is the response for task.session.resume @@ -69,7 +69,7 @@ type ResumeTaskSessionResponse struct { Success bool `json:"success"` TaskID string `json:"task_id"` AgentExecutionID string `json:"agent_execution_id"` - TaskSessionID string `json:"task_session_id,omitempty"` + TaskSessionID string `json:"session_id,omitempty"` State string `json:"state"` WorktreePath *string `json:"worktree_path,omitempty"` WorktreeBranch *string `json:"worktree_branch,omitempty"` @@ -78,7 +78,7 @@ type ResumeTaskSessionResponse struct { // TaskSessionStatusRequest is the payload for task.session.status type TaskSessionStatusRequest struct { TaskID string `json:"task_id"` - TaskSessionID string `json:"task_session_id"` + TaskSessionID string `json:"session_id"` } // TaskSessionStatusResponse is the response for task.session.status @@ -90,9 +90,9 @@ type TaskSessionStatusResponse struct { AgentProfileID string `json:"agent_profile_id,omitempty"` // Runtime status - IsAgentRunning bool `json:"is_agent_running"` // Agent process is currently running - IsResumable bool `json:"is_resumable"` // Session can be resumed - NeedsResume bool `json:"needs_resume"` // Session needs resumption (page reload scenario) + IsAgentRunning bool `json:"is_agent_running"` // Agent process is currently running + IsResumable bool `json:"is_resumable"` // Session can be resumed + NeedsResume bool `json:"needs_resume"` // Session needs resumption (page reload scenario) ResumeReason string `json:"resume_reason,omitempty"` // Why resume is needed (e.g., "agent_not_running") // ACP session info @@ -120,8 +120,9 @@ type SuccessResponse struct { // PromptTaskRequest is the payload for orchestrator.prompt type PromptTaskRequest struct { - TaskID string `json:"task_id"` - Prompt string `json:"prompt"` + TaskID string `json:"task_id"` + TaskSessionID string `json:"session_id"` + Prompt string `json:"prompt"` } // PromptTaskResponse is the response for orchestrator.prompt @@ -143,7 +144,7 @@ type CompleteTaskResponse struct { // PermissionRespondRequest is the payload for permission.respond type PermissionRespondRequest struct { - TaskID string `json:"task_id"` + SessionID string `json:"session_id"` PendingID string `json:"pending_id"` OptionID string `json:"option_id,omitempty"` Cancelled bool `json:"cancelled,omitempty"` @@ -152,7 +153,7 @@ type PermissionRespondRequest struct { // PermissionRespondResponse is the response for permission.respond type PermissionRespondResponse struct { Success bool `json:"success"` - TaskID string `json:"task_id"` + SessionID string `json:"session_id"` PendingID string `json:"pending_id"` } @@ -167,7 +168,7 @@ type TaskExecutionResponse struct { TaskID string `json:"task_id"` AgentExecutionID string `json:"agent_execution_id,omitempty"` AgentProfileID string `json:"agent_profile_id,omitempty"` - TaskSessionID string `json:"task_session_id,omitempty"` + TaskSessionID string `json:"session_id,omitempty"` State string `json:"state,omitempty"` Progress int `json:"progress,omitempty"` StartedAt string `json:"started_at,omitempty"` diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index a077b9ab53..302de8053c 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -54,18 +54,18 @@ type AgentManagerClient interface { PromptAgent(ctx context.Context, agentExecutionID string, prompt string) (*PromptResult, error) // RespondToPermission sends a response to a permission request - RespondToPermissionByTaskID(ctx context.Context, taskID, pendingID, optionID string, cancelled bool) error + RespondToPermissionBySessionID(ctx context.Context, sessionID, pendingID, optionID string, cancelled bool) error // GetRecoveredExecutions returns executions recovered from Docker during startup GetRecoveredExecutions() []RecoveredExecutionInfo - // IsAgentRunningForTask checks if an agent is actually running for a task + // IsAgentRunningForSession checks if an agent is actually running for a session // This probes the actual agent (Docker container or standalone process) rather than relying on cached state - IsAgentRunningForTask(ctx context.Context, taskID string) bool + IsAgentRunningForSession(ctx context.Context, sessionID string) bool - // CleanupStaleExecutionByTaskID removes a stale agent execution from tracking without trying to stop it. + // CleanupStaleExecutionBySessionID removes a stale agent execution from tracking without trying to stop it. // This is used when we detect the agent process has stopped but the execution is still tracked. - CleanupStaleExecutionByTaskID(ctx context.Context, taskID string) error + CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error } // LaunchAgentRequest contains parameters for launching an agent @@ -241,6 +241,7 @@ func (e *Executor) LoadActiveSessionsFromDB(ctx context.Context) error { type RecoveredExecutionInfo struct { ExecutionID string TaskID string + SessionID string ContainerID string AgentProfileID string } @@ -266,6 +267,7 @@ func (e *Executor) SyncWithRecoveredExecutions(ctx context.Context, executions [ SessionState: v1.TaskSessionStateRunning, Progress: 0, LastUpdate: time.Now(), + SessionID: exec.SessionID, } e.logger.Info("synced recovered execution to executor cache", @@ -412,13 +414,39 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP zap.Error(err)) } + resumable := true + if session.ExecutorID != "" { + if executor, err := e.repo.GetExecutor(ctx, session.ExecutorID); err == nil && executor != nil { + resumable = executor.Resumable + } + } + running := &models.ExecutorRunning{ + ID: session.ID, + SessionID: session.ID, + TaskID: task.ID, + ExecutorID: session.ExecutorID, + Status: "starting", + Resumable: resumable, + AgentExecutionID: resp.AgentExecutionID, + ContainerID: resp.ContainerID, + WorktreeID: resp.WorktreeID, + WorktreePath: resp.WorktreePath, + WorktreeBranch: resp.WorktreeBranch, + } + if err := e.repo.UpsertExecutorRunning(ctx, running); err != nil { + e.logger.Warn("failed to persist executor runtime after launch", + zap.String("task_id", task.ID), + zap.String("session_id", sessionID), + zap.Error(err)) + } + if resp.WorktreeID != "" { sessionWorktree := &models.TaskSessionWorktree{ - SessionID: session.ID, - WorktreeID: resp.WorktreeID, - RepositoryID: repositoryID, - Position: 0, - WorktreePath: resp.WorktreePath, + SessionID: session.ID, + WorktreeID: resp.WorktreeID, + RepositoryID: repositoryID, + Position: 0, + WorktreePath: resp.WorktreePath, WorktreeBranch: resp.WorktreeBranch, } if err := e.repo.CreateTaskSessionWorktree(ctx, sessionWorktree); err != nil { @@ -557,12 +585,12 @@ func (e *Executor) ResumeSession(ctx context.Context, task *v1.Task, session *mo } } - if session.Metadata != nil { - if acpSessionID, ok := session.Metadata["acp_session_id"].(string); ok && acpSessionID != "" { - req.ACPSessionID = acpSessionID - e.logger.Info("found acp_session_id in session metadata for resumption", + if running, err := e.repo.GetExecutorRunningBySessionID(ctx, session.ID); err == nil && running != nil { + if running.ResumeToken != "" { + req.ACPSessionID = running.ResumeToken + e.logger.Info("found resume token for session resumption", zap.String("task_id", task.ID), - zap.String("acp_session_id", acpSessionID)) + zap.String("session_id", session.ID)) } } @@ -570,7 +598,7 @@ func (e *Executor) ResumeSession(ctx context.Context, task *v1.Task, session *mo zap.String("task_id", task.ID), zap.String("session_id", session.ID), zap.String("agent_profile_id", session.AgentProfileID), - zap.String("acp_session_id", req.ACPSessionID), + zap.String("resume_token", req.ACPSessionID), zap.Bool("use_worktree", req.UseWorktree)) req.Env = e.applyPreferredShellEnv(ctx, req.Env) @@ -598,6 +626,32 @@ func (e *Executor) ResumeSession(ctx context.Context, task *v1.Task, session *mo zap.Error(err)) } + resumable := true + if session.ExecutorID != "" { + if executor, err := e.repo.GetExecutor(ctx, session.ExecutorID); err == nil && executor != nil { + resumable = executor.Resumable + } + } + running := &models.ExecutorRunning{ + ID: session.ID, + SessionID: session.ID, + TaskID: task.ID, + ExecutorID: session.ExecutorID, + Status: "starting", + Resumable: resumable, + AgentExecutionID: resp.AgentExecutionID, + ContainerID: resp.ContainerID, + WorktreeID: resp.WorktreeID, + WorktreePath: resp.WorktreePath, + WorktreeBranch: resp.WorktreeBranch, + } + if err := e.repo.UpsertExecutorRunning(ctx, running); err != nil { + e.logger.Warn("failed to persist executor runtime after resume", + zap.String("task_id", task.ID), + zap.String("session_id", session.ID), + zap.Error(err)) + } + if resp.WorktreeID != "" { hasWorktree := false for _, wt := range session.Worktrees { @@ -608,11 +662,11 @@ func (e *Executor) ResumeSession(ctx context.Context, task *v1.Task, session *mo } if !hasWorktree { sessionWorktree := &models.TaskSessionWorktree{ - SessionID: session.ID, - WorktreeID: resp.WorktreeID, - RepositoryID: repositoryID, - Position: 0, - WorktreePath: resp.WorktreePath, + SessionID: session.ID, + WorktreeID: resp.WorktreeID, + RepositoryID: repositoryID, + Position: 0, + WorktreePath: resp.WorktreePath, WorktreeBranch: resp.WorktreeBranch, } if err := e.repo.CreateTaskSessionWorktree(ctx, sessionWorktree); err != nil { @@ -713,21 +767,22 @@ func (e *Executor) Stop(ctx context.Context, taskID string, reason string, force // Prompt sends a follow-up prompt to a running agent for a task // Returns PromptResult indicating if the agent needs input -func (e *Executor) Prompt(ctx context.Context, taskID string, prompt string) (*PromptResult, error) { - execution, err := e.getOrLoadExecution(ctx, taskID) +func (e *Executor) Prompt(ctx context.Context, taskID, sessionID string, prompt string) (*PromptResult, error) { + execution, err := e.getOrLoadExecutionForSession(ctx, taskID, sessionID) if err != nil { return nil, err } e.logger.Info("sending prompt to agent", zap.String("task_id", taskID), + zap.String("session_id", sessionID), zap.String("agent_execution_id", execution.AgentExecutionID), zap.Int("prompt_length", len(prompt))) return e.agentManager.PromptAgent(ctx, execution.AgentExecutionID, prompt) } -// getOrLoadExecution gets execution from memory cache or loads from database +// getOrLoadExecution gets execution from memory cache or loads from database. func (e *Executor) getOrLoadExecution(ctx context.Context, taskID string) (*TaskExecution, error) { // First check in-memory cache e.mu.RLock() @@ -761,15 +816,47 @@ func (e *Executor) getOrLoadExecution(ctx context.Context, taskID string) (*Task return execution, nil } -// RespondToPermission sends a response to a permission request for a task -func (e *Executor) RespondToPermission(ctx context.Context, taskID, pendingID, optionID string, cancelled bool) error { +// getOrLoadExecutionForSession gets execution for a specific task session. +func (e *Executor) getOrLoadExecutionForSession(ctx context.Context, taskID, sessionID string) (*TaskExecution, error) { + if sessionID == "" { + return nil, ErrExecutionNotFound + } + + e.mu.RLock() + execution, exists := e.executions[taskID] + e.mu.RUnlock() + if exists && execution.SessionID == sessionID { + return execution, nil + } + + session, err := e.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return nil, ErrExecutionNotFound + } + if session.TaskID != taskID { + return nil, ErrExecutionNotFound + } + if session.AgentExecutionID == "" { + return nil, ErrExecutionNotFound + } + + execution = FromTaskSession(session) + e.mu.Lock() + e.executions[taskID] = execution + e.mu.Unlock() + + return execution, nil +} + +// RespondToPermission sends a response to a permission request for a session +func (e *Executor) RespondToPermission(ctx context.Context, sessionID, pendingID, optionID string, cancelled bool) error { e.logger.Info("responding to permission request", - zap.String("task_id", taskID), + zap.String("session_id", sessionID), zap.String("pending_id", pendingID), zap.String("option_id", optionID), zap.Bool("cancelled", cancelled)) - return e.agentManager.RespondToPermissionByTaskID(ctx, taskID, pendingID, optionID, cancelled) + return e.agentManager.RespondToPermissionBySessionID(ctx, sessionID, pendingID, optionID, cancelled) } // GetExecution returns the current execution state for a task @@ -782,7 +869,7 @@ func (e *Executor) GetExecution(taskID string) (*TaskExecution, bool) { if exists { // Verify the agent is actually running by probing agentctl - if !e.agentManager.IsAgentRunningForTask(ctx, taskID) { + if execution.SessionID == "" || !e.agentManager.IsAgentRunningForSession(ctx, execution.SessionID) { // Agent stopped - clean up in-memory state e.mu.Lock() delete(e.executions, taskID) @@ -794,10 +881,12 @@ func (e *Executor) GetExecution(taskID string) (*TaskExecution, bool) { } // Clean up the stale execution from the agent manager - if err := e.agentManager.CleanupStaleExecutionByTaskID(ctx, taskID); err != nil { - e.logger.Warn("failed to cleanup stale agent execution", - zap.String("task_id", taskID), - zap.Error(err)) + if execution.SessionID != "" { + if err := e.agentManager.CleanupStaleExecutionBySessionID(ctx, execution.SessionID); err != nil { + e.logger.Warn("failed to cleanup stale agent execution", + zap.String("session_id", execution.SessionID), + zap.Error(err)) + } } return nil, false } @@ -815,7 +904,7 @@ func (e *Executor) GetExecution(taskID string) (*TaskExecution, bool) { // Verify the agent is actually running by probing the lifecycle manager // This handles the case where backend was restarted and DB has stale "running" sessions - if !e.agentManager.IsAgentRunningForTask(ctx, taskID) { + if session.ID == "" || !e.agentManager.IsAgentRunningForSession(ctx, session.ID) { // Agent is not running - mark the session as stopped in DB and return false e.logger.Info("stale agent session detected - agent not running", zap.String("task_id", taskID), @@ -823,10 +912,12 @@ func (e *Executor) GetExecution(taskID string) (*TaskExecution, bool) { _ = e.repo.UpdateTaskSessionState(ctx, session.ID, models.TaskSessionStateCancelled, "agent not running after backend restart") // Clean up the stale execution from the agent manager - if err := e.agentManager.CleanupStaleExecutionByTaskID(ctx, taskID); err != nil { - e.logger.Warn("failed to cleanup stale agent execution", - zap.String("task_id", taskID), - zap.Error(err)) + if session.ID != "" { + if err := e.agentManager.CleanupStaleExecutionBySessionID(ctx, session.ID); err != nil { + e.logger.Warn("failed to cleanup stale agent execution", + zap.String("session_id", session.ID), + zap.Error(err)) + } } return nil, false } @@ -849,7 +940,7 @@ func (e *Executor) GetExecutionWithContext(ctx context.Context, taskID string) ( if exists { // Verify the agent is actually running by probing agentctl - if !e.agentManager.IsAgentRunningForTask(ctx, taskID) { + if execution.SessionID == "" || !e.agentManager.IsAgentRunningForSession(ctx, execution.SessionID) { // Agent stopped - clean up in-memory state e.mu.Lock() delete(e.executions, taskID) @@ -861,10 +952,12 @@ func (e *Executor) GetExecutionWithContext(ctx context.Context, taskID string) ( } // Clean up the stale execution from the agent manager - if err := e.agentManager.CleanupStaleExecutionByTaskID(ctx, taskID); err != nil { - e.logger.Warn("failed to cleanup stale agent execution", - zap.String("task_id", taskID), - zap.Error(err)) + if execution.SessionID != "" { + if err := e.agentManager.CleanupStaleExecutionBySessionID(ctx, execution.SessionID); err != nil { + e.logger.Warn("failed to cleanup stale agent execution", + zap.String("session_id", execution.SessionID), + zap.Error(err)) + } } return nil, false } @@ -880,17 +973,19 @@ func (e *Executor) GetExecutionWithContext(ctx context.Context, taskID string) ( } // Verify the agent is actually running by probing the lifecycle manager - if !e.agentManager.IsAgentRunningForTask(ctx, taskID) { + if session.ID == "" || !e.agentManager.IsAgentRunningForSession(ctx, session.ID) { e.logger.Info("stale agent session detected - agent not running", zap.String("task_id", taskID), zap.String("session_id", session.ID)) _ = e.repo.UpdateTaskSessionState(ctx, session.ID, models.TaskSessionStateCancelled, "agent not running after backend restart") // Clean up the stale execution from the agent manager - if err := e.agentManager.CleanupStaleExecutionByTaskID(ctx, taskID); err != nil { - e.logger.Warn("failed to cleanup stale agent execution", - zap.String("task_id", taskID), - zap.Error(err)) + if session.ID != "" { + if err := e.agentManager.CleanupStaleExecutionBySessionID(ctx, session.ID); err != nil { + e.logger.Warn("failed to cleanup stale agent execution", + zap.String("session_id", session.ID), + zap.Error(err)) + } } return nil, false } @@ -1015,26 +1110,6 @@ func (e *Executor) RemoveExecution(taskID string) { } } -// GetActiveSessionID returns the active session ID for a task, if any -func (e *Executor) GetActiveSessionID(ctx context.Context, taskID string) (string, error) { - // First check in-memory cache - e.mu.RLock() - execution, exists := e.executions[taskID] - e.mu.RUnlock() - - if exists && execution.SessionID != "" { - return execution.SessionID, nil - } - - // Try to load from database - session, err := e.repo.GetActiveTaskSessionByTaskID(ctx, taskID) - if err != nil { - return "", err - } - - return session.ID, nil -} - // MockAgentManagerClient is a placeholder implementation type MockAgentManagerClient struct { logger *logger.Logger @@ -1113,10 +1188,10 @@ func (m *MockAgentManagerClient) PromptAgent(ctx context.Context, agentExecution return &PromptResult{StopReason: "end_turn"}, nil } -// RespondToPermissionByTaskID mocks responding to a permission request -func (m *MockAgentManagerClient) RespondToPermissionByTaskID(ctx context.Context, taskID, pendingID, optionID string, cancelled bool) error { +// RespondToPermissionBySessionID mocks responding to a permission request +func (m *MockAgentManagerClient) RespondToPermissionBySessionID(ctx context.Context, sessionID, pendingID, optionID string, cancelled bool) error { m.logger.Info("mock: responding to permission", - zap.String("task_id", taskID), + zap.String("session_id", sessionID), zap.String("pending_id", pendingID), zap.String("option_id", optionID), zap.Bool("cancelled", cancelled)) @@ -1128,12 +1203,12 @@ func (m *MockAgentManagerClient) GetRecoveredExecutions() []RecoveredExecutionIn return nil } -// IsAgentRunningForTask mocks checking if an agent is running (always returns false for mock) -func (m *MockAgentManagerClient) IsAgentRunningForTask(ctx context.Context, taskID string) bool { +// IsAgentRunningForSession mocks checking if an agent is running (always returns false for mock) +func (m *MockAgentManagerClient) IsAgentRunningForSession(ctx context.Context, sessionID string) bool { return false } -// CleanupStaleExecutionByTaskID mocks cleaning up a stale execution (no-op for mock) -func (m *MockAgentManagerClient) CleanupStaleExecutionByTaskID(ctx context.Context, taskID string) error { +// CleanupStaleExecutionBySessionID mocks cleaning up a stale execution (no-op for mock) +func (m *MockAgentManagerClient) CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error { return nil } diff --git a/apps/backend/internal/orchestrator/handlers/handlers.go b/apps/backend/internal/orchestrator/handlers/handlers.go index 6b5b50969a..4549662b26 100644 --- a/apps/backend/internal/orchestrator/handlers/handlers.go +++ b/apps/backend/internal/orchestrator/handlers/handlers.go @@ -113,7 +113,7 @@ func (h *Handlers) wsStartTask(ctx context.Context, msg *ws.Message) (*ws.Messag type wsResumeTaskSessionRequest struct { TaskID string `json:"task_id"` - TaskSessionID string `json:"task_session_id"` + TaskSessionID string `json:"session_id"` } func (h *Handlers) wsResumeTaskSession(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -125,7 +125,7 @@ func (h *Handlers) wsResumeTaskSession(ctx context.Context, msg *ws.Message) (*w return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) } if req.TaskSessionID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_session_id is required", nil) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) } resp, err := h.controller.ResumeTaskSession(ctx, dto.ResumeTaskSessionRequest{ @@ -135,7 +135,7 @@ func (h *Handlers) wsResumeTaskSession(ctx context.Context, msg *ws.Message) (*w if err != nil { h.logger.Error("failed to resume task session", zap.String("task_id", req.TaskID), - zap.String("task_session_id", req.TaskSessionID), + zap.String("session_id", req.TaskSessionID), zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to resume task session: "+err.Error(), nil) } @@ -170,8 +170,9 @@ func (h *Handlers) wsStopTask(ctx context.Context, msg *ws.Message) (*ws.Message } type wsPromptTaskRequest struct { - TaskID string `json:"task_id"` - Prompt string `json:"prompt"` + TaskID string `json:"task_id"` + TaskSessionID string `json:"session_id"` + Prompt string `json:"prompt"` } func (h *Handlers) wsPromptTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -182,13 +183,17 @@ func (h *Handlers) wsPromptTask(ctx context.Context, msg *ws.Message) (*ws.Messa if req.TaskID == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) } + if req.TaskSessionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) + } if req.Prompt == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "prompt is required", nil) } resp, err := h.controller.PromptTask(ctx, dto.PromptTaskRequest{ - TaskID: req.TaskID, - Prompt: req.Prompt, + TaskID: req.TaskID, + TaskSessionID: req.TaskSessionID, + Prompt: req.Prompt, }) if err != nil { h.logger.Error("failed to send prompt", zap.String("task_id", req.TaskID), zap.Error(err)) @@ -219,7 +224,7 @@ func (h *Handlers) wsCompleteTask(ctx context.Context, msg *ws.Message) (*ws.Mes } type wsPermissionRespondRequest struct { - TaskID string `json:"task_id"` + SessionID string `json:"session_id"` PendingID string `json:"pending_id"` OptionID string `json:"option_id,omitempty"` Cancelled bool `json:"cancelled,omitempty"` @@ -230,8 +235,8 @@ func (h *Handlers) wsRespondToPermission(ctx context.Context, msg *ws.Message) ( if err := msg.ParsePayload(&req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } - if req.TaskID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + if req.SessionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) } if req.PendingID == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "pending_id is required", nil) @@ -241,19 +246,19 @@ func (h *Handlers) wsRespondToPermission(ctx context.Context, msg *ws.Message) ( } h.logger.Info("responding to permission request", - zap.String("task_id", req.TaskID), + zap.String("session_id", req.SessionID), zap.String("pending_id", req.PendingID), zap.String("option_id", req.OptionID), zap.Bool("cancelled", req.Cancelled)) resp, err := h.controller.RespondToPermission(ctx, dto.PermissionRespondRequest{ - TaskID: req.TaskID, + SessionID: req.SessionID, PendingID: req.PendingID, OptionID: req.OptionID, Cancelled: req.Cancelled, }) if err != nil { - h.logger.Error("failed to respond to permission", zap.String("task_id", req.TaskID), zap.Error(err)) + h.logger.Error("failed to respond to permission", zap.String("session_id", req.SessionID), zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to respond to permission: "+err.Error(), nil) } return ws.NewResponse(msg.ID, msg.Action, resp) @@ -285,7 +290,7 @@ func (h *Handlers) wsGetTaskExecution(ctx context.Context, msg *ws.Message) (*ws type wsGetTaskSessionStatusRequest struct { TaskID string `json:"task_id"` - TaskSessionID string `json:"task_session_id"` + TaskSessionID string `json:"session_id"` } func (h *Handlers) wsGetTaskSessionStatus(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -298,7 +303,7 @@ func (h *Handlers) wsGetTaskSessionStatus(ctx context.Context, msg *ws.Message) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "task_id is required", nil) } if req.TaskSessionID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "task_session_id is required", nil) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "session_id is required", nil) } resp, err := h.controller.GetTaskSessionStatus(ctx, dto.TaskSessionStatusRequest{ @@ -308,7 +313,7 @@ func (h *Handlers) wsGetTaskSessionStatus(ctx context.Context, msg *ws.Message) if err != nil { h.logger.Error("failed to get task session status", zap.String("task_id", req.TaskID), - zap.String("task_session_id", req.TaskSessionID), + zap.String("session_id", req.TaskSessionID), zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get task session status: "+err.Error(), nil) } diff --git a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go index ccdbb29655..890e5c601f 100644 --- a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go +++ b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go @@ -64,7 +64,7 @@ func (m *mockAgentManager) PromptAgent(ctx context.Context, agentExecutionID str return &executor.PromptResult{StopReason: "end_turn"}, nil } -func (m *mockAgentManager) RespondToPermissionByTaskID(ctx context.Context, taskID, pendingID, optionID string, cancelled bool) error { +func (m *mockAgentManager) RespondToPermissionBySessionID(ctx context.Context, sessionID, pendingID, optionID string, cancelled bool) error { return nil } @@ -72,11 +72,11 @@ func (m *mockAgentManager) GetRecoveredExecutions() []executor.RecoveredExecutio return nil } -func (m *mockAgentManager) IsAgentRunningForTask(ctx context.Context, taskID string) bool { +func (m *mockAgentManager) IsAgentRunningForSession(ctx context.Context, sessionID string) bool { return false } -func (m *mockAgentManager) CleanupStaleExecutionByTaskID(ctx context.Context, taskID string) error { +func (m *mockAgentManager) CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error { return nil } diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index dbcf47237f..b2c14b9c5d 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -47,8 +47,8 @@ func DefaultServiceConfig() ServiceConfig { } } -// InputRequestHandler is called when an agent requests user input -type InputRequestHandler func(ctx context.Context, taskID, agentID, message string) error +// InputRequestHandler is called when an agent requests user input. +type InputRequestHandler func(ctx context.Context, taskID, sessionID, agentID, message string) error // MessageCreator is an interface for creating messages on tasks type MessageCreator interface { @@ -351,7 +351,7 @@ func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID s func (s *Service) ResumeTaskSession(ctx context.Context, taskID, sessionID string) (*executor.TaskExecution, error) { s.logger.Info("resuming task session", zap.String("task_id", taskID), - zap.String("task_session_id", sessionID)) + zap.String("session_id", sessionID)) if exec, ok := s.executor.GetExecutionWithContext(ctx, taskID); ok && exec != nil { return nil, executor.ErrExecutionAlreadyRunning @@ -378,6 +378,10 @@ func (s *Service) ResumeTaskSession(ctx context.Context, taskID, sessionID strin if session.TaskID != taskID { return nil, fmt.Errorf("task session does not belong to task") } + running, err := s.repo.GetExecutorRunningBySessionID(ctx, sessionID) + if err != nil || running == nil || running.ResumeToken == "" || !running.Resumable { + return nil, fmt.Errorf("session is not resumable") + } if len(session.Worktrees) == 0 || session.Worktrees[0].WorktreePath == "" { return nil, fmt.Errorf("task session has no worktree path") } @@ -423,11 +427,6 @@ func (s *Service) ResumeTaskSession(ctx context.Context, taskID, sessionID strin return execution, nil } -// GetActiveSessionID returns the active (running) session ID for a task -func (s *Service) GetActiveSessionID(ctx context.Context, taskID string) (string, error) { - return s.executor.GetActiveSessionID(ctx, taskID) -} - // GetTaskSessionStatus returns the status of a task session including whether it's resumable func (s *Service) GetTaskSessionStatus(ctx context.Context, taskID, sessionID string) (dto.TaskSessionStatusResponse, error) { s.logger.Info("checking task session status", @@ -454,12 +453,13 @@ func (s *Service) GetTaskSessionStatus(ctx context.Context, taskID, sessionID st resp.State = string(session.State) resp.AgentProfileID = session.AgentProfileID - // Extract ACP session ID from metadata - this is the key requirement for resumption - var acpSessionID string - if session.Metadata != nil { - if id, ok := session.Metadata["acp_session_id"].(string); ok { - acpSessionID = id - resp.ACPSessionID = acpSessionID + // Extract resume token from executor runtime state. + var resumeToken string + if running, err := s.repo.GetExecutorRunningBySessionID(ctx, sessionID); err == nil && running != nil { + resumeToken = running.ResumeToken + resp.ACPSessionID = resumeToken + if running.Resumable { + resp.IsResumable = true } } @@ -477,18 +477,16 @@ func (s *Service) GetTaskSessionStatus(ctx context.Context, taskID, sessionID st // 2. Check if we have an active execution in memory and agent is running if exec, ok := s.executor.GetExecutionWithContext(ctx, taskID); ok && exec != nil { resp.IsAgentRunning = true - resp.IsResumable = true resp.NeedsResume = false return resp, nil } - // 3. Session can be resumed if it has an ACP session ID - // Any session type with an acp_session_id can be resumed - if acpSessionID == "" { + // 3. Session can be resumed if it has a resume token + if resumeToken == "" { resp.IsAgentRunning = false resp.IsResumable = false resp.NeedsResume = false - resp.Error = "session has no acp_session_id" + resp.Error = "session has no resume token" return resp, nil } @@ -509,7 +507,6 @@ func (s *Service) GetTaskSessionStatus(ctx context.Context, taskID, sessionID st } resp.IsAgentRunning = false - resp.IsResumable = true resp.NeedsResume = true resp.ResumeReason = "agent_not_running" @@ -531,15 +528,17 @@ type PromptResult struct { AgentMessage string // The agent's accumulated response message } -// PromptTask sends a follow-up prompt to a running agent for a task -func (s *Service) PromptTask(ctx context.Context, taskID string, prompt string) (*PromptResult, error) { +// PromptTask sends a follow-up prompt to a running agent for a task session. +func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prompt string) (*PromptResult, error) { s.logger.Info("sending prompt to task agent", zap.String("task_id", taskID), + zap.String("session_id", sessionID), zap.Int("prompt_length", len(prompt))) - if sessionID, err := s.executor.GetActiveSessionID(ctx, taskID); err == nil && sessionID != "" { - s.updateTaskSessionState(ctx, taskID, sessionID, models.TaskSessionStateRunning, "", true) + if sessionID == "" { + return nil, fmt.Errorf("session_id is required") } - result, err := s.executor.Prompt(ctx, taskID, prompt) + s.updateTaskSessionState(ctx, taskID, sessionID, models.TaskSessionStateRunning, "", true) + result, err := s.executor.Prompt(ctx, taskID, sessionID, prompt) if err != nil { return nil, err } @@ -549,14 +548,14 @@ func (s *Service) PromptTask(ctx context.Context, taskID string, prompt string) }, nil } -// RespondToPermission sends a response to a permission request for a task -func (s *Service) RespondToPermission(ctx context.Context, taskID, pendingID, optionID string, cancelled bool) error { +// RespondToPermission sends a response to a permission request for a session +func (s *Service) RespondToPermission(ctx context.Context, sessionID, pendingID, optionID string, cancelled bool) error { s.logger.Info("responding to permission request", - zap.String("task_id", taskID), + zap.String("session_id", sessionID), zap.String("pending_id", pendingID), zap.String("option_id", optionID), zap.Bool("cancelled", cancelled)) - return s.executor.RespondToPermission(ctx, taskID, pendingID, optionID, cancelled) + return s.executor.RespondToPermission(ctx, sessionID, pendingID, optionID, cancelled) } // CompleteTask explicitly completes a task and stops its agent @@ -686,26 +685,42 @@ func (s *Service) handleACPSessionCreated(ctx context.Context, data watcher.ACPS return } - if session.Metadata == nil { - session.Metadata = make(map[string]interface{}) + resumable := true + if session.ExecutorID != "" { + if executor, err := s.repo.GetExecutor(ctx, session.ExecutorID); err == nil && executor != nil { + resumable = executor.Resumable + } } - if existing, ok := session.Metadata["acp_session_id"].(string); ok && existing == data.ACPSessionID { - return - } - session.Metadata["acp_session_id"] = data.ACPSessionID - if err := s.repo.UpdateTaskSession(ctx, session); err != nil { - s.logger.Warn("failed to persist ACP session id", + running := &models.ExecutorRunning{ + ID: session.ID, + SessionID: session.ID, + TaskID: session.TaskID, + ExecutorID: session.ExecutorID, + Status: "ready", + Resumable: resumable, + ResumeToken: data.ACPSessionID, + AgentExecutionID: session.AgentExecutionID, + ContainerID: session.ContainerID, + } + if len(session.Worktrees) > 0 { + running.WorktreeID = session.Worktrees[0].WorktreeID + running.WorktreePath = session.Worktrees[0].WorktreePath + running.WorktreeBranch = session.Worktrees[0].WorktreeBranch + } + + if err := s.repo.UpsertExecutorRunning(ctx, running); err != nil { + s.logger.Warn("failed to persist resume token for session", zap.String("task_id", data.TaskID), zap.String("session_id", sessionID), zap.Error(err)) return } - s.logger.Info("stored ACP session id for task session", + s.logger.Info("stored resume token for task session", zap.String("task_id", data.TaskID), - zap.String("task_session_id", sessionID), - zap.String("acp_session_id", data.ACPSessionID)) + zap.String("session_id", sessionID), + zap.String("resume_token", data.ACPSessionID)) } // handleAgentCompleted handles agent completion events @@ -764,32 +779,33 @@ func (s *Service) handleACPMessage(ctx context.Context, taskID string, msg *prot // Handle input_required messages specially if msg.Type == protocol.MessageTypeInputRequired { - s.handleInputRequired(ctx, taskID, msg) + s.handleInputRequired(ctx, taskID, msg.SessionID, msg) } // Normalize ACP messages into session messages for persistence. normalized := s.normalizeACPMessage(msg) if normalized != nil && s.messageCreator != nil { - sessionID, err := s.executor.GetActiveSessionID(ctx, taskID) - if err == nil && sessionID != "" { - if err := s.messageCreator.CreateSessionMessage( - ctx, - taskID, - normalized.content, - sessionID, - normalized.messageType, - normalized.metadata, - normalized.requestsInput, - ); err != nil { - s.logger.Error("failed to create normalized session message", - zap.String("task_id", taskID), - zap.String("message_type", normalized.messageType), - zap.Error(err)) - } + if msg.SessionID == "" { + s.logger.Warn("ACP message missing session_id", + zap.String("task_id", taskID), + zap.String("message_type", normalized.messageType)) + } else if err := s.messageCreator.CreateSessionMessage( + ctx, + taskID, + normalized.content, + msg.SessionID, + normalized.messageType, + normalized.metadata, + normalized.requestsInput, + ); err != nil { + s.logger.Error("failed to create normalized session message", + zap.String("task_id", taskID), + zap.String("message_type", normalized.messageType), + zap.Error(err)) + } - if normalized.bumpToRunning { - s.updateTaskSessionState(ctx, taskID, sessionID, models.TaskSessionStateRunning, "", false) - } + if normalized.bumpToRunning && msg.SessionID != "" { + s.updateTaskSessionState(ctx, taskID, msg.SessionID, models.TaskSessionStateRunning, "", false) } } @@ -902,10 +918,10 @@ func (s *Service) updateTaskSessionState(ctx context.Context, taskID, sessionID zap.String("new_state", string(nextState))) if s.eventBus != nil { _ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent(events.TaskSessionStateChanged, "task-session", map[string]interface{}{ - "task_id": taskID, - "task_session_id": sessionID, - "old_state": string(oldState), - "new_state": string(nextState), + "task_id": taskID, + "session_id": sessionID, + "old_state": string(oldState), + "new_state": string(nextState), })) } if taskID != "" { @@ -954,12 +970,12 @@ func (s *Service) markPromptComplete(taskID string) bool { func (s *Service) finalizeAgentReady(taskID, sessionID string) { ctx := context.Background() if sessionID == "" { - sessionID, _ = s.executor.GetActiveSessionID(ctx, taskID) + s.logger.Warn("missing session_id for finalizeAgentReady", + zap.String("task_id", taskID)) + return } - if sessionID != "" { - s.updateTaskSessionState(ctx, taskID, sessionID, models.TaskSessionStateWaitingForInput, "", false) - } + s.updateTaskSessionState(ctx, taskID, sessionID, models.TaskSessionStateWaitingForInput, "", false) task, err := s.taskRepo.GetTask(ctx, taskID) if err != nil { @@ -986,9 +1002,10 @@ func (s *Service) finalizeAgentReady(taskID, sessionID string) { } // handleInputRequired handles agent input request messages -func (s *Service) handleInputRequired(ctx context.Context, taskID string, msg *protocol.Message) { +func (s *Service) handleInputRequired(ctx context.Context, taskID, sessionID string, msg *protocol.Message) { s.logger.Info("agent requesting user input", zap.String("task_id", taskID), + zap.String("session_id", sessionID), zap.String("agent_id", msg.AgentID)) // Extract message from data @@ -999,6 +1016,12 @@ func (s *Service) handleInputRequired(ctx context.Context, taskID string, msg *p } } + if sessionID == "" { + s.logger.Warn("missing session_id for input_required", + zap.String("task_id", taskID)) + return + } + // Update task state to WAITING_FOR_INPUT if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateWaitingForInput); err != nil { s.logger.Error("failed to update task state to WAITING_FOR_INPUT", @@ -1007,13 +1030,11 @@ func (s *Service) handleInputRequired(ctx context.Context, taskID string, msg *p } // Update session state to WAITING_FOR_INPUT - if sessionID, err := s.executor.GetActiveSessionID(ctx, taskID); err == nil && sessionID != "" { - s.updateTaskSessionState(ctx, taskID, sessionID, models.TaskSessionStateWaitingForInput, "", false) - } + s.updateTaskSessionState(ctx, taskID, sessionID, models.TaskSessionStateWaitingForInput, "", false) // Call the input request handler if set if s.inputRequestHandler != nil { - if err := s.inputRequestHandler(ctx, taskID, msg.AgentID, message); err != nil { + if err := s.inputRequestHandler(ctx, taskID, sessionID, msg.AgentID, message); err != nil { s.logger.Error("input request handler failed", zap.String("task_id", taskID), zap.Error(err)) @@ -1027,11 +1048,16 @@ func (s *Service) handleGitStatusUpdated(ctx context.Context, data watcher.GitSt zap.String("task_id", data.TaskID), zap.String("branch", data.Branch)) - // Get the active agent session for this task - session, err := s.repo.GetActiveTaskSessionByTaskID(ctx, data.TaskID) + if data.TaskSessionID == "" { + s.logger.Debug("missing session_id for git status update", + zap.String("task_id", data.TaskID)) + return + } + + session, err := s.repo.GetTaskSession(ctx, data.TaskSessionID) if err != nil { - s.logger.Debug("no active agent session for git status update", - zap.String("task_id", data.TaskID), + s.logger.Debug("no task session for git status update", + zap.String("session_id", data.TaskSessionID), zap.Error(err)) return } @@ -1078,12 +1104,15 @@ func (s *Service) handlePromptComplete(ctx context.Context, data watcher.PromptC zap.String("task_id", data.TaskID), zap.Int("message_length", len(data.AgentMessage))) + if data.TaskSessionID == "" { + s.logger.Warn("missing session_id for prompt_complete", + zap.String("task_id", data.TaskID)) + return + } + // Save agent response as a message if we have a message creator if s.messageCreator != nil && data.AgentMessage != "" { - // Get the active session ID for this task - sessionID, _ := s.executor.GetActiveSessionID(ctx, data.TaskID) - - if err := s.messageCreator.CreateAgentMessage(ctx, data.TaskID, data.AgentMessage, sessionID); err != nil { + if err := s.messageCreator.CreateAgentMessage(ctx, data.TaskID, data.AgentMessage, data.TaskSessionID); err != nil { s.logger.Error("failed to create agent message", zap.String("task_id", data.TaskID), zap.Error(err)) @@ -1094,7 +1123,7 @@ func (s *Service) handlePromptComplete(ctx context.Context, data watcher.PromptC } if s.markPromptComplete(data.TaskID) { - s.finalizeAgentReady(data.TaskID, sessionID) + s.finalizeAgentReady(data.TaskID, data.TaskSessionID) } } } @@ -1106,12 +1135,16 @@ func (s *Service) handleToolCallStarted(ctx context.Context, data watcher.ToolCa zap.String("tool_call_id", data.ToolCallID), zap.String("title", data.Title)) + if data.TaskSessionID == "" { + s.logger.Warn("missing session_id for tool_call", + zap.String("task_id", data.TaskID), + zap.String("tool_call_id", data.ToolCallID)) + return + } + // Save tool call as a message if we have a message creator if s.messageCreator != nil { - // Get the active session ID for this task - sessionID, _ := s.executor.GetActiveSessionID(ctx, data.TaskID) - - if err := s.messageCreator.CreateToolCallMessage(ctx, data.TaskID, data.ToolCallID, data.Title, data.Status, sessionID, data.Args); err != nil { + if err := s.messageCreator.CreateToolCallMessage(ctx, data.TaskID, data.ToolCallID, data.Title, data.Status, data.TaskSessionID, data.Args); err != nil { s.logger.Error("failed to create tool call message", zap.String("task_id", data.TaskID), zap.String("tool_call_id", data.ToolCallID), @@ -1122,9 +1155,7 @@ func (s *Service) handleToolCallStarted(ctx context.Context, data watcher.ToolCa zap.String("tool_call_id", data.ToolCallID)) } - if sessionID != "" { - s.updateTaskSessionState(ctx, data.TaskID, sessionID, models.TaskSessionStateRunning, "", false) - } + s.updateTaskSessionState(ctx, data.TaskID, data.TaskSessionID, models.TaskSessionStateRunning, "", false) } } @@ -1132,17 +1163,14 @@ func (s *Service) handleToolCallStarted(ctx context.Context, data watcher.ToolCa func (s *Service) handleToolCallComplete(ctx context.Context, data watcher.ToolCallCompleteData) { // Update tool call message status if we have a message creator if s.messageCreator != nil { - sessionID, _ := s.executor.GetActiveSessionID(ctx, data.TaskID) - if sessionID == "" { - // No active session - can't update tool call message - // This can happen during startup or if session was cleared - s.logger.Debug("skipping tool call update - no active session", + if data.TaskSessionID == "" { + s.logger.Warn("missing session_id for tool_call_complete", zap.String("task_id", data.TaskID), zap.String("tool_call_id", data.ToolCallID)) return } - if err := s.messageCreator.UpdateToolCallMessage(ctx, data.TaskID, data.ToolCallID, data.Status, data.Result, sessionID); err != nil { + if err := s.messageCreator.UpdateToolCallMessage(ctx, data.TaskID, data.ToolCallID, data.Status, data.Result, data.TaskSessionID); err != nil { // Log as warning since this is often a race condition (complete arrives before start) s.logger.Warn("failed to update tool call message", zap.String("task_id", data.TaskID), @@ -1150,6 +1178,6 @@ func (s *Service) handleToolCallComplete(ctx context.Context, data watcher.ToolC zap.Error(err)) } - s.updateTaskSessionState(ctx, data.TaskID, sessionID, models.TaskSessionStateRunning, "", false) + s.updateTaskSessionState(ctx, data.TaskID, data.TaskSessionID, models.TaskSessionStateRunning, "", false) } } diff --git a/apps/backend/internal/orchestrator/watcher/watcher.go b/apps/backend/internal/orchestrator/watcher/watcher.go index 8341fd2aa1..af1c6257c3 100644 --- a/apps/backend/internal/orchestrator/watcher/watcher.go +++ b/apps/backend/internal/orchestrator/watcher/watcher.go @@ -42,45 +42,49 @@ type ACPSessionEventData struct { // PromptCompleteData contains data from prompt_complete events type PromptCompleteData struct { - TaskID string `json:"task_id"` - AgentID string `json:"agent_id"` - AgentMessage string `json:"agent_message"` + TaskID string `json:"task_id"` + TaskSessionID string `json:"session_id"` + AgentID string `json:"agent_id"` + AgentMessage string `json:"agent_message"` } // ToolCallData contains data from tool_call events type ToolCallData struct { - TaskID string `json:"task_id"` - AgentID string `json:"agent_id"` - ToolCallID string `json:"tool_call_id"` - Title string `json:"title"` - Status string `json:"status"` - Args map[string]interface{} `json:"args,omitempty"` // Tool call arguments (kind, path, locations, raw_input) + TaskID string `json:"task_id"` + TaskSessionID string `json:"session_id"` + AgentID string `json:"agent_id"` + ToolCallID string `json:"tool_call_id"` + Title string `json:"title"` + Status string `json:"status"` + Args map[string]interface{} `json:"args,omitempty"` // Tool call arguments (kind, path, locations, raw_input) } // ToolCallCompleteData contains data from tool_call_complete events type ToolCallCompleteData struct { - TaskID string `json:"task_id"` - AgentID string `json:"agent_id"` - ToolCallID string `json:"tool_call_id"` - Status string `json:"status"` // "complete" or "error" - Result string `json:"result,omitempty"` + TaskID string `json:"task_id"` + TaskSessionID string `json:"session_id"` + AgentID string `json:"agent_id"` + ToolCallID string `json:"tool_call_id"` + Status string `json:"status"` // "complete" or "error" + Result string `json:"result,omitempty"` } // GitStatusData contains data from git status events type GitStatusData struct { - TaskID string `json:"task_id"` - AgentID string `json:"agent_id"` - Branch string `json:"branch"` - RemoteBranch string `json:"remote_branch"` - Modified []string `json:"modified"` - Added []string `json:"added"` - Deleted []string `json:"deleted"` - Untracked []string `json:"untracked"` - Renamed []string `json:"renamed"` - Ahead int `json:"ahead"` - Behind int `json:"behind"` - Files map[string]interface{} `json:"files"` - Timestamp string `json:"timestamp"` + TaskID string `json:"task_id"` + TaskSessionID string `json:"session_id"` + AgentID string `json:"agent_id"` + Branch string `json:"branch"` + RemoteBranch string `json:"remote_branch"` + Modified []string `json:"modified"` + Added []string `json:"added"` + Deleted []string `json:"deleted"` + Untracked []string `json:"untracked"` + Renamed []string `json:"renamed"` + Ahead int `json:"ahead"` + Behind int `json:"behind"` + Files map[string]interface{} `json:"files"` + Timestamp string `json:"timestamp"` } // EventHandlers contains callbacks for different event types diff --git a/apps/backend/internal/task/controller/executor.go b/apps/backend/internal/task/controller/executor.go index a509d87521..bbaf555afd 100644 --- a/apps/backend/internal/task/controller/executor.go +++ b/apps/backend/internal/task/controller/executor.go @@ -41,11 +41,12 @@ func (c *ExecutorController) GetExecutor(ctx context.Context, req dto.GetExecuto func (c *ExecutorController) CreateExecutor(ctx context.Context, req dto.CreateExecutorRequest) (dto.ExecutorDTO, error) { executor, err := c.service.CreateExecutor(ctx, &service.CreateExecutorRequest{ - Name: req.Name, - Type: req.Type, - Status: req.Status, - IsSystem: req.IsSystem, - Config: req.Config, + Name: req.Name, + Type: req.Type, + Status: req.Status, + IsSystem: req.IsSystem, + Resumable: req.Resumable, + Config: req.Config, }) if err != nil { return dto.ExecutorDTO{}, err @@ -55,10 +56,11 @@ func (c *ExecutorController) CreateExecutor(ctx context.Context, req dto.CreateE func (c *ExecutorController) UpdateExecutor(ctx context.Context, req dto.UpdateExecutorRequest) (dto.ExecutorDTO, error) { executor, err := c.service.UpdateExecutor(ctx, req.ID, &service.UpdateExecutorRequest{ - Name: req.Name, - Type: req.Type, - Status: req.Status, - Config: req.Config, + Name: req.Name, + Type: req.Type, + Status: req.Status, + Resumable: req.Resumable, + Config: req.Config, }) if err != nil { return dto.ExecutorDTO{}, err diff --git a/apps/backend/internal/task/controller/task.go b/apps/backend/internal/task/controller/task.go index 9950bb7cf7..11c914d22f 100644 --- a/apps/backend/internal/task/controller/task.go +++ b/apps/backend/internal/task/controller/task.go @@ -69,6 +69,16 @@ func (c *TaskController) ListTaskSessions(ctx context.Context, req dto.ListTaskS return resp, nil } +func (c *TaskController) GetTaskSession(ctx context.Context, req dto.GetTaskSessionRequest) (dto.GetTaskSessionResponse, error) { + session, err := c.service.GetTaskSession(ctx, req.TaskSessionID) + if err != nil { + return dto.GetTaskSessionResponse{}, err + } + return dto.GetTaskSessionResponse{ + Session: dto.FromTaskSession(session), + }, nil +} + func (c *TaskController) GetTask(ctx context.Context, req dto.GetTaskRequest) (dto.TaskDTO, error) { task, err := c.service.GetTask(ctx, req.ID) if err != nil { @@ -101,7 +111,6 @@ func (c *TaskController) CreateTask(ctx context.Context, req dto.CreateTaskReque Priority: req.Priority, State: req.State, Repositories: repos, - AssignedTo: req.AssignedTo, Position: req.Position, Metadata: req.Metadata, }) @@ -130,7 +139,6 @@ func (c *TaskController) UpdateTask(ctx context.Context, req dto.UpdateTaskReque Priority: req.Priority, State: req.State, Repositories: repos, - AssignedTo: req.AssignedTo, Position: req.Position, Metadata: req.Metadata, }) diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 3b5784233d..77992704ef 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -73,6 +73,7 @@ type ExecutorDTO struct { Type models.ExecutorType `json:"type"` Status models.ExecutorStatus `json:"status"` IsSystem bool `json:"is_system"` + Resumable bool `json:"resumable"` Config map[string]string `json:"config,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -92,20 +93,19 @@ type EnvironmentDTO struct { } type TaskDTO struct { - ID string `json:"id"` - WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` - ColumnID string `json:"column_id"` - Title string `json:"title"` - Description string `json:"description"` - State v1.TaskState `json:"state"` - Priority int `json:"priority"` - Repositories []TaskRepositoryDTO `json:"repositories,omitempty"` - AssignedAgentID *string `json:"assigned_agent_id,omitempty"` - Position int `json:"position"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + BoardID string `json:"board_id"` + ColumnID string `json:"column_id"` + Title string `json:"title"` + Description string `json:"description"` + State v1.TaskState `json:"state"` + Priority int `json:"priority"` + Repositories []TaskRepositoryDTO `json:"repositories,omitempty"` + Position int `json:"position"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Metadata map[string]interface{} `json:"metadata,omitempty"` // Worktree information (populated if worktree exists for this task) WorktreePath *string `json:"worktree_path,omitempty"` WorktreeBranch *string `json:"worktree_branch,omitempty"` @@ -123,29 +123,33 @@ type TaskRepositoryDTO struct { } type TaskSessionDTO struct { - ID string `json:"id"` - TaskID string `json:"task_id"` - AgentExecutionID string `json:"agent_execution_id,omitempty"` - ContainerID string `json:"container_id,omitempty"` - AgentProfileID string `json:"agent_profile_id,omitempty"` - ExecutorID string `json:"executor_id,omitempty"` - EnvironmentID string `json:"environment_id,omitempty"` - RepositoryID string `json:"repository_id,omitempty"` - BaseBranch string `json:"base_branch,omitempty"` - WorktreeID string `json:"worktree_id,omitempty"` - WorktreePath string `json:"worktree_path,omitempty"` - WorktreeBranch string `json:"worktree_branch,omitempty"` + ID string `json:"id"` + TaskID string `json:"task_id"` + AgentExecutionID string `json:"agent_execution_id,omitempty"` + ContainerID string `json:"container_id,omitempty"` + AgentProfileID string `json:"agent_profile_id,omitempty"` + ExecutorID string `json:"executor_id,omitempty"` + EnvironmentID string `json:"environment_id,omitempty"` + RepositoryID string `json:"repository_id,omitempty"` + BaseBranch string `json:"base_branch,omitempty"` + WorktreeID string `json:"worktree_id,omitempty"` + WorktreePath string `json:"worktree_path,omitempty"` + WorktreeBranch string `json:"worktree_branch,omitempty"` State models.TaskSessionState `json:"state"` - Progress int `json:"progress"` - ErrorMessage string `json:"error_message,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` - AgentProfileSnapshot map[string]interface{} `json:"agent_profile_snapshot,omitempty"` - ExecutorSnapshot map[string]interface{} `json:"executor_snapshot,omitempty"` - EnvironmentSnapshot map[string]interface{} `json:"environment_snapshot,omitempty"` - RepositorySnapshot map[string]interface{} `json:"repository_snapshot,omitempty"` - StartedAt time.Time `json:"started_at"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - UpdatedAt time.Time `json:"updated_at"` + Progress int `json:"progress"` + ErrorMessage string `json:"error_message,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + AgentProfileSnapshot map[string]interface{} `json:"agent_profile_snapshot,omitempty"` + ExecutorSnapshot map[string]interface{} `json:"executor_snapshot,omitempty"` + EnvironmentSnapshot map[string]interface{} `json:"environment_snapshot,omitempty"` + RepositorySnapshot map[string]interface{} `json:"repository_snapshot,omitempty"` + StartedAt time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +type GetTaskSessionResponse struct { + Session TaskSessionDTO `json:"session"` } type ListTaskSessionsResponse struct { @@ -328,6 +332,7 @@ func FromExecutor(executor *models.Executor) ExecutorDTO { Type: executor.Type, Status: executor.Status, IsSystem: executor.IsSystem, + Resumable: executor.Resumable, Config: executor.Config, CreatedAt: executor.CreatedAt, UpdatedAt: executor.UpdatedAt, @@ -358,11 +363,6 @@ func FromLocalRepository(repo service.LocalRepository) LocalRepositoryDTO { } func FromTask(task *models.Task) TaskDTO { - var assignedAgentID *string - if task.AssignedTo != "" { - assignedAgentID = &task.AssignedTo - } - // Convert repositories var repositories []TaskRepositoryDTO for _, repo := range task.Repositories { @@ -379,20 +379,19 @@ func FromTask(task *models.Task) TaskDTO { } return TaskDTO{ - ID: task.ID, - WorkspaceID: task.WorkspaceID, - BoardID: task.BoardID, - ColumnID: task.ColumnID, - Title: task.Title, - Description: task.Description, - State: task.State, - Priority: task.Priority, - Repositories: repositories, - AssignedAgentID: assignedAgentID, - Position: task.Position, - CreatedAt: task.CreatedAt, - UpdatedAt: task.UpdatedAt, - Metadata: task.Metadata, + ID: task.ID, + WorkspaceID: task.WorkspaceID, + BoardID: task.BoardID, + ColumnID: task.ColumnID, + Title: task.Title, + Description: task.Description, + State: task.State, + Priority: task.Priority, + Repositories: repositories, + Position: task.Position, + CreatedAt: task.CreatedAt, + UpdatedAt: task.UpdatedAt, + Metadata: task.Metadata, } } diff --git a/apps/backend/internal/task/dto/requests.go b/apps/backend/internal/task/dto/requests.go index 19260ec4e4..cb565ec5a4 100644 --- a/apps/backend/internal/task/dto/requests.go +++ b/apps/backend/internal/task/dto/requests.go @@ -136,19 +136,21 @@ type GetExecutorRequest struct { } type CreateExecutorRequest struct { - Name string - Type models.ExecutorType - Status models.ExecutorStatus - IsSystem bool - Config map[string]string + Name string + Type models.ExecutorType + Status models.ExecutorStatus + IsSystem bool + Resumable bool + Config map[string]string } type UpdateExecutorRequest struct { - ID string - Name *string - Type *models.ExecutorType - Status *models.ExecutorStatus - Config map[string]string + ID string + Name *string + Type *models.ExecutorType + Status *models.ExecutorStatus + Resumable *bool + Config map[string]string } type DeleteExecutorRequest struct { @@ -238,6 +240,10 @@ type ListTaskSessionsRequest struct { TaskID string } +type GetTaskSessionRequest struct { + TaskSessionID string +} + type GetTaskRequest struct { ID string } @@ -259,7 +265,6 @@ type CreateTaskRequest struct { Priority int State *v1.TaskState Repositories []TaskRepositoryInput - AssignedTo string Position int Metadata map[string]interface{} } @@ -271,7 +276,6 @@ type UpdateTaskRequest struct { Priority *int State *v1.TaskState Repositories []TaskRepositoryInput - AssignedTo *string Position *int Metadata map[string]interface{} } diff --git a/apps/backend/internal/task/handlers/executor_handlers.go b/apps/backend/internal/task/handlers/executor_handlers.go index f0259a85af..947d71c7c8 100644 --- a/apps/backend/internal/task/handlers/executor_handlers.go +++ b/apps/backend/internal/task/handlers/executor_handlers.go @@ -58,11 +58,12 @@ func (h *ExecutorHandlers) httpListExecutors(c *gin.Context) { } type httpCreateExecutorRequest struct { - Name string `json:"name"` - Type string `json:"type"` - Status string `json:"status"` - IsSystem bool `json:"is_system"` - Config map[string]string `json:"config,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + IsSystem bool `json:"is_system"` + Resumable *bool `json:"resumable,omitempty"` + Config map[string]string `json:"config,omitempty"` } func (h *ExecutorHandlers) httpCreateExecutor(c *gin.Context) { @@ -79,12 +80,17 @@ func (h *ExecutorHandlers) httpCreateExecutor(c *gin.Context) { if status == "" { status = string(models.ExecutorStatusActive) } + resumable := true + if body.Resumable != nil { + resumable = *body.Resumable + } resp, err := h.controller.CreateExecutor(c.Request.Context(), dto.CreateExecutorRequest{ - Name: body.Name, - Type: models.ExecutorType(body.Type), - Status: models.ExecutorStatus(status), - IsSystem: body.IsSystem, - Config: body.Config, + Name: body.Name, + Type: models.ExecutorType(body.Type), + Status: models.ExecutorStatus(status), + IsSystem: body.IsSystem, + Resumable: resumable, + Config: body.Config, }) if err != nil { h.logger.Error("failed to create executor", zap.Error(err)) @@ -105,10 +111,11 @@ func (h *ExecutorHandlers) httpGetExecutor(c *gin.Context) { } type httpUpdateExecutorRequest struct { - Name *string `json:"name,omitempty"` - Type *string `json:"type,omitempty"` - Status *string `json:"status,omitempty"` - Config map[string]string `json:"config,omitempty"` + Name *string `json:"name,omitempty"` + Type *string `json:"type,omitempty"` + Status *string `json:"status,omitempty"` + Resumable *bool `json:"resumable,omitempty"` + Config map[string]string `json:"config,omitempty"` } func (h *ExecutorHandlers) httpUpdateExecutor(c *gin.Context) { @@ -128,11 +135,12 @@ func (h *ExecutorHandlers) httpUpdateExecutor(c *gin.Context) { execStatus = &value } resp, err := h.controller.UpdateExecutor(c.Request.Context(), dto.UpdateExecutorRequest{ - ID: c.Param("id"), - Name: body.Name, - Type: execType, - Status: execStatus, - Config: body.Config, + ID: c.Param("id"), + Name: body.Name, + Type: execType, + Status: execStatus, + Resumable: body.Resumable, + Config: body.Config, }) if err != nil { h.logger.Error("failed to update executor", zap.Error(err)) @@ -166,11 +174,12 @@ func (h *ExecutorHandlers) wsListExecutors(ctx context.Context, msg *ws.Message) } type wsCreateExecutorRequest struct { - Name string `json:"name"` - Type string `json:"type"` - Status string `json:"status"` - IsSystem bool `json:"is_system"` - Config map[string]string `json:"config,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + IsSystem bool `json:"is_system"` + Resumable *bool `json:"resumable,omitempty"` + Config map[string]string `json:"config,omitempty"` } func (h *ExecutorHandlers) wsCreateExecutor(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -185,12 +194,17 @@ func (h *ExecutorHandlers) wsCreateExecutor(ctx context.Context, msg *ws.Message if status == "" { status = string(models.ExecutorStatusActive) } + resumable := true + if req.Resumable != nil { + resumable = *req.Resumable + } resp, err := h.controller.CreateExecutor(ctx, dto.CreateExecutorRequest{ - Name: req.Name, - Type: models.ExecutorType(req.Type), - Status: models.ExecutorStatus(status), - IsSystem: req.IsSystem, - Config: req.Config, + Name: req.Name, + Type: models.ExecutorType(req.Type), + Status: models.ExecutorStatus(status), + IsSystem: req.IsSystem, + Resumable: resumable, + Config: req.Config, }) if err != nil { h.logger.Error("failed to create executor", zap.Error(err)) @@ -219,11 +233,12 @@ func (h *ExecutorHandlers) wsGetExecutor(ctx context.Context, msg *ws.Message) ( } type wsUpdateExecutorRequest struct { - ID string `json:"id"` - Name *string `json:"name,omitempty"` - Type *string `json:"type,omitempty"` - Status *string `json:"status,omitempty"` - Config map[string]string `json:"config,omitempty"` + ID string `json:"id"` + Name *string `json:"name,omitempty"` + Type *string `json:"type,omitempty"` + Status *string `json:"status,omitempty"` + Resumable *bool `json:"resumable,omitempty"` + Config map[string]string `json:"config,omitempty"` } func (h *ExecutorHandlers) wsUpdateExecutor(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -245,11 +260,12 @@ func (h *ExecutorHandlers) wsUpdateExecutor(ctx context.Context, msg *ws.Message execStatus = &value } resp, err := h.controller.UpdateExecutor(ctx, dto.UpdateExecutorRequest{ - ID: req.ID, - Name: req.Name, - Type: execType, - Status: execStatus, - Config: req.Config, + ID: req.ID, + Name: req.Name, + Type: execType, + Status: execStatus, + Resumable: req.Resumable, + Config: req.Config, }) if err != nil { h.logger.Error("failed to update executor", zap.Error(err)) diff --git a/apps/backend/internal/task/handlers/message_handlers.go b/apps/backend/internal/task/handlers/message_handlers.go index 5a7b7f08fa..5920f417e0 100644 --- a/apps/backend/internal/task/handlers/message_handlers.go +++ b/apps/backend/internal/task/handlers/message_handlers.go @@ -12,9 +12,9 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/task/controller" "github.com/kandev/kandev/internal/task/dto" - "github.com/kandev/kandev/internal/orchestrator/executor" v1 "github.com/kandev/kandev/pkg/api/v1" ws "github.com/kandev/kandev/pkg/websocket" ) @@ -27,7 +27,7 @@ type PromptResult struct { // OrchestratorService defines the interface for orchestrator operations type OrchestratorService interface { - PromptTask(ctx context.Context, taskID string, prompt string) (*PromptResult, error) + PromptTask(ctx context.Context, taskID, sessionID, prompt string) (*PromptResult, error) ResumeTaskSession(ctx context.Context, taskID, taskSessionID string) error } @@ -100,10 +100,10 @@ func (h *MessageHandlers) httpListMessages(c *gin.Context) { if paginated { resp, err = h.messageController.ListMessages(c.Request.Context(), dto.ListMessagesRequest{ TaskSessionID: sessionID, - Limit: limit, - Before: before, - After: after, - Sort: sort, + Limit: limit, + Before: before, + After: after, + Sort: sort, }) } else { resp, err = h.messageController.ListAllMessages(c.Request.Context(), sessionID) @@ -119,10 +119,10 @@ func (h *MessageHandlers) httpListMessages(c *gin.Context) { // WS handlers type wsAddMessageRequest struct { - TaskID string `json:"task_id"` - TaskSessionID string `json:"task_session_id"` - Content string `json:"content"` - AuthorID string `json:"author_id,omitempty"` + TaskID string `json:"task_id"` + TaskSessionID string `json:"session_id"` + Content string `json:"content"` + AuthorID string `json:"author_id,omitempty"` } func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -131,7 +131,7 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } if req.TaskSessionID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_session_id is required", nil) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) } if req.TaskID == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) @@ -162,10 +162,10 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w message, err := h.messageController.CreateMessage(ctx, dto.CreateMessageRequest{ TaskSessionID: req.TaskSessionID, - TaskID: req.TaskID, - Content: req.Content, - AuthorType: "user", - AuthorID: req.AuthorID, + TaskID: req.TaskID, + Content: req.Content, + AuthorType: "user", + AuthorID: req.AuthorID, }) if err != nil { h.logger.Error("failed to create message", zap.Error(err)) @@ -174,18 +174,18 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w // Auto-forward message as prompt to running agent if orchestrator is available if h.orchestrator != nil { - _, err := h.orchestrator.PromptTask(ctx, req.TaskID, req.Content) + _, err := h.orchestrator.PromptTask(ctx, req.TaskID, req.TaskSessionID, req.Content) if err != nil { if errors.Is(err, executor.ErrExecutionNotFound) { if resumeErr := h.orchestrator.ResumeTaskSession(ctx, req.TaskID, req.TaskSessionID); resumeErr != nil { h.logger.Warn("failed to resume task session for prompt", zap.String("task_id", req.TaskID), - zap.String("task_session_id", req.TaskSessionID), + zap.String("session_id", req.TaskSessionID), zap.Error(resumeErr)) } else { for attempt := 0; attempt < 3; attempt++ { time.Sleep(500 * time.Millisecond) - _, err = h.orchestrator.PromptTask(ctx, req.TaskID, req.Content) + _, err = h.orchestrator.PromptTask(ctx, req.TaskID, req.TaskSessionID, req.Content) if err == nil { break } @@ -202,11 +202,11 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w } type wsListMessagesRequest struct { - TaskSessionID string `json:"task_session_id"` - Limit int `json:"limit"` - Before string `json:"before"` - After string `json:"after"` - Sort string `json:"sort"` + TaskSessionID string `json:"session_id"` + Limit int `json:"limit"` + Before string `json:"before"` + After string `json:"after"` + Sort string `json:"sort"` } func (h *MessageHandlers) wsListMessages(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -215,7 +215,7 @@ func (h *MessageHandlers) wsListMessages(ctx context.Context, msg *ws.Message) ( return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } if req.TaskSessionID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_session_id is required", nil) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) } if req.Before != "" && req.After != "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "only one of before or after can be set", nil) @@ -226,10 +226,10 @@ func (h *MessageHandlers) wsListMessages(ctx context.Context, msg *ws.Message) ( resp, err := h.messageController.ListMessages(ctx, dto.ListMessagesRequest{ TaskSessionID: req.TaskSessionID, - Limit: req.Limit, - Before: req.Before, - After: req.After, - Sort: req.Sort, + Limit: req.Limit, + Before: req.Before, + After: req.After, + Sort: req.Sort, }) if err != nil { h.logger.Error("failed to list messages", zap.Error(err)) diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index 4e8fad4b85..7d73cac8c6 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -7,6 +7,7 @@ import ( "github.com/gin-gonic/gin" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/task/controller" "github.com/kandev/kandev/internal/task/dto" v1 "github.com/kandev/kandev/pkg/api/v1" @@ -15,19 +16,25 @@ import ( ) type TaskHandlers struct { - controller *controller.TaskController - logger *logger.Logger + controller *controller.TaskController + orchestrator OrchestratorStarter + logger *logger.Logger } -func NewTaskHandlers(ctrl *controller.TaskController, log *logger.Logger) *TaskHandlers { +type OrchestratorStarter interface { + StartTask(ctx context.Context, taskID string, agentProfileID string, priority int) (*executor.TaskExecution, error) +} + +func NewTaskHandlers(ctrl *controller.TaskController, orchestrator OrchestratorStarter, log *logger.Logger) *TaskHandlers { return &TaskHandlers{ - controller: ctrl, - logger: log.WithFields(zap.String("component", "task-task-handlers")), + controller: ctrl, + orchestrator: orchestrator, + logger: log.WithFields(zap.String("component", "task-task-handlers")), } } -func RegisterTaskRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, ctrl *controller.TaskController, log *logger.Logger) { - handlers := NewTaskHandlers(ctrl, log) +func RegisterTaskRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, ctrl *controller.TaskController, orchestrator OrchestratorStarter, log *logger.Logger) { + handlers := NewTaskHandlers(ctrl, orchestrator, log) handlers.registerHTTP(router) handlers.registerWS(dispatcher) } @@ -36,6 +43,7 @@ func (h *TaskHandlers) registerHTTP(router *gin.Engine) { api := router.Group("/api/v1") api.GET("/boards/:id/tasks", h.httpListTasks) api.GET("/tasks/:id", h.httpGetTask) + api.GET("/task-sessions/:id", h.httpGetTaskSession) api.GET("/tasks/:id/sessions", h.httpListTaskSessions) api.POST("/tasks", h.httpCreateTask) api.PATCH("/tasks/:id", h.httpUpdateTask) @@ -83,6 +91,18 @@ func (h *TaskHandlers) httpListTaskSessions(c *gin.Context) { c.JSON(http.StatusOK, resp) } +func (h *TaskHandlers) httpGetTaskSession(c *gin.Context) { + resp, err := h.controller.GetTaskSession( + c.Request.Context(), + dto.GetTaskSessionRequest{TaskSessionID: c.Param("id")}, + ) + if err != nil { + handleNotFound(c, h.logger, err, "task session not found") + return + } + c.JSON(http.StatusOK, resp) +} + type httpTaskRepositoryInput struct { RepositoryID string `json:"repository_id"` BaseBranch string `json:"base_branch"` @@ -92,17 +112,24 @@ type httpTaskRepositoryInput struct { } type httpCreateTaskRequest struct { - WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` - ColumnID string `json:"column_id"` - Title string `json:"title"` - Description string `json:"description,omitempty"` - Priority int `json:"priority,omitempty"` - State *v1.TaskState `json:"state,omitempty"` - Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"` - AssignedTo string `json:"assigned_to,omitempty"` - Position int `json:"position,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + WorkspaceID string `json:"workspace_id"` + BoardID string `json:"board_id"` + ColumnID string `json:"column_id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Priority int `json:"priority,omitempty"` + State *v1.TaskState `json:"state,omitempty"` + Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"` + Position int `json:"position,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + StartAgent bool `json:"start_agent,omitempty"` + AgentProfileID string `json:"agent_profile_id,omitempty"` +} + +type createTaskResponse struct { + dto.TaskDTO + TaskSessionID string `json:"session_id,omitempty"` + AgentExecutionID string `json:"agent_execution_id,omitempty"` } func (h *TaskHandlers) httpCreateTask(c *gin.Context) { @@ -115,6 +142,10 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id is required"}) return } + if body.StartAgent && body.AgentProfileID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "agent_profile_id is required to start agent"}) + return + } // Convert repositories var repos []dto.TaskRepositoryInput @@ -141,7 +172,6 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { Priority: body.Priority, State: body.State, Repositories: repos, - AssignedTo: body.AssignedTo, Position: body.Position, Metadata: body.Metadata, }) @@ -149,7 +179,20 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { handleNotFound(c, h.logger, err, "task not created") return } - c.JSON(http.StatusOK, resp) + + response := createTaskResponse{TaskDTO: resp} + if body.StartAgent && body.AgentProfileID != "" && h.orchestrator != nil { + execution, err := h.orchestrator.StartTask(c.Request.Context(), resp.ID, body.AgentProfileID, body.Priority) + if err != nil { + h.logger.Error("failed to start agent for task", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start agent for task"}) + return + } + response.TaskSessionID = execution.SessionID + response.AgentExecutionID = execution.AgentExecutionID + } + + c.JSON(http.StatusOK, response) } type httpUpdateTaskRequest struct { @@ -158,7 +201,6 @@ type httpUpdateTaskRequest struct { Priority *int `json:"priority,omitempty"` State *v1.TaskState `json:"state,omitempty"` Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"` - AssignedTo *string `json:"assigned_to,omitempty"` Position *int `json:"position,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` } @@ -191,7 +233,6 @@ func (h *TaskHandlers) httpUpdateTask(c *gin.Context) { Priority: body.Priority, State: body.State, Repositories: repos, - AssignedTo: body.AssignedTo, Position: body.Position, Metadata: body.Metadata, }) @@ -285,17 +326,18 @@ func (h *TaskHandlers) wsListTasks(ctx context.Context, msg *ws.Message) (*ws.Me } type wsCreateTaskRequest struct { - WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` - ColumnID string `json:"column_id"` - Title string `json:"title"` - Description string `json:"description,omitempty"` - Priority int `json:"priority,omitempty"` - State *v1.TaskState `json:"state,omitempty"` - Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"` - AssignedTo string `json:"assigned_to,omitempty"` - Position int `json:"position,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + WorkspaceID string `json:"workspace_id"` + BoardID string `json:"board_id"` + ColumnID string `json:"column_id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Priority int `json:"priority,omitempty"` + State *v1.TaskState `json:"state,omitempty"` + Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"` + Position int `json:"position,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + StartAgent bool `json:"start_agent,omitempty"` + AgentProfileID string `json:"agent_profile_id,omitempty"` } func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -315,6 +357,9 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M if req.Title == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "title is required", nil) } + if req.StartAgent && req.AgentProfileID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "agent_profile_id is required to start agent", nil) + } // Convert repositories var repos []dto.TaskRepositoryInput @@ -340,7 +385,6 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M Priority: req.Priority, State: req.State, Repositories: repos, - AssignedTo: req.AssignedTo, Position: req.Position, Metadata: req.Metadata, }) @@ -348,7 +392,18 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M h.logger.Error("failed to create task", zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create task", nil) } - return ws.NewResponse(msg.ID, msg.Action, resp) + + response := createTaskResponse{TaskDTO: resp} + if req.StartAgent && req.AgentProfileID != "" && h.orchestrator != nil { + execution, err := h.orchestrator.StartTask(ctx, resp.ID, req.AgentProfileID, req.Priority) + if err != nil { + h.logger.Error("failed to start agent for task", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to start agent for task", nil) + } + response.TaskSessionID = execution.SessionID + response.AgentExecutionID = execution.AgentExecutionID + } + return ws.NewResponse(msg.ID, msg.Action, response) } type wsGetTaskRequest struct { @@ -378,7 +433,6 @@ type wsUpdateTaskRequest struct { Priority *int `json:"priority,omitempty"` State *v1.TaskState `json:"state,omitempty"` Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"` - AssignedTo *string `json:"assigned_to,omitempty"` Position *int `json:"position,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` } @@ -413,7 +467,6 @@ func (h *TaskHandlers) wsUpdateTask(ctx context.Context, msg *ws.Message) (*ws.M Priority: req.Priority, State: req.State, Repositories: repos, - AssignedTo: req.AssignedTo, Position: req.Position, Metadata: req.Metadata, }) diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index cd25aa3bea..5aae350170 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -16,7 +16,6 @@ type Task struct { Description string `json:"description"` State v1.TaskState `json:"state"` Priority int `json:"priority"` - AssignedTo string `json:"assigned_to,omitempty"` Position int `json:"position"` // Order within column Metadata map[string]interface{} `json:"metadata,omitempty"` Repositories []*TaskRepository `json:"repositories,omitempty"` @@ -101,16 +100,16 @@ const ( // Message represents a message in a task session type Message struct { - ID string `json:"id"` - TaskSessionID string `json:"task_session_id"` - TaskID string `json:"task_id,omitempty"` - AuthorType MessageAuthorType `json:"author_type"` - AuthorID string `json:"author_id,omitempty"` // User ID or Agent Execution ID - Content string `json:"content"` - Type MessageType `json:"type,omitempty"` // Defaults to "message" - Metadata map[string]interface{} `json:"metadata,omitempty"` - RequestsInput bool `json:"requests_input"` // True if agent is requesting user input - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + TaskSessionID string `json:"session_id"` + TaskID string `json:"task_id,omitempty"` + AuthorType MessageAuthorType `json:"author_type"` + AuthorID string `json:"author_id,omitempty"` // User ID or Agent Execution ID + Content string `json:"content"` + Type MessageType `json:"type,omitempty"` // Defaults to "message" + Metadata map[string]interface{} `json:"metadata,omitempty"` + RequestsInput bool `json:"requests_input"` // True if agent is requesting user input + CreatedAt time.Time `json:"created_at"` } // ToAPI converts internal Message to API type @@ -120,16 +119,16 @@ func (m *Message) ToAPI() *v1.Message { messageType = string(MessageTypeMessage) } result := &v1.Message{ - ID: m.ID, + ID: m.ID, TaskSessionID: m.TaskSessionID, - TaskID: m.TaskID, - AuthorType: string(m.AuthorType), - AuthorID: m.AuthorID, - Content: m.Content, - Type: messageType, - Metadata: m.Metadata, - RequestsInput: m.RequestsInput, - CreatedAt: m.CreatedAt, + TaskID: m.TaskID, + AuthorType: string(m.AuthorType), + AuthorID: m.AuthorID, + Content: m.Content, + Type: messageType, + Metadata: m.Metadata, + RequestsInput: m.RequestsInput, + CreatedAt: m.CreatedAt, } return result } @@ -175,17 +174,17 @@ type TaskSession struct { TaskID string `json:"task_id"` AgentExecutionID string `json:"agent_execution_id"` // Docker container/agent execution ContainerID string `json:"container_id"` // Docker container ID for cleanup - AgentProfileID string `json:"agent_profile_id"` // ID of the agent profile used + AgentProfileID string `json:"agent_profile_id"` // ID of the agent profile used ExecutorID string `json:"executor_id"` EnvironmentID string `json:"environment_id"` - RepositoryID string `json:"repository_id"` // Primary repository (for backward compatibility) - BaseBranch string `json:"base_branch"` // Primary base branch (for backward compatibility) + RepositoryID string `json:"repository_id"` // Primary repository (for backward compatibility) + BaseBranch string `json:"base_branch"` // Primary base branch (for backward compatibility) Worktrees []*TaskSessionWorktree `json:"worktrees,omitempty"` // Associated worktrees AgentProfileSnapshot map[string]interface{} `json:"agent_profile_snapshot,omitempty"` ExecutorSnapshot map[string]interface{} `json:"executor_snapshot,omitempty"` EnvironmentSnapshot map[string]interface{} `json:"environment_snapshot,omitempty"` RepositorySnapshot map[string]interface{} `json:"repository_snapshot,omitempty"` - State TaskSessionState `json:"state"` + State TaskSessionState `json:"state"` Progress int `json:"progress"` // 0-100 ErrorMessage string `json:"error_message,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` @@ -202,16 +201,16 @@ func (s *TaskSession) ToAPI() map[string]interface{} { "task_id": s.TaskID, "agent_execution_id": s.AgentExecutionID, "container_id": s.ContainerID, - "agent_profile_id": s.AgentProfileID, - "executor_id": s.ExecutorID, - "environment_id": s.EnvironmentID, - "repository_id": s.RepositoryID, - "base_branch": s.BaseBranch, - "worktrees": s.Worktrees, - "state": string(s.State), - "progress": s.Progress, - "started_at": s.StartedAt, - "updated_at": s.UpdatedAt, + "agent_profile_id": s.AgentProfileID, + "executor_id": s.ExecutorID, + "environment_id": s.EnvironmentID, + "repository_id": s.RepositoryID, + "base_branch": s.BaseBranch, + "worktrees": s.Worktrees, + "state": string(s.State), + "progress": s.Progress, + "started_at": s.StartedAt, + "updated_at": s.UpdatedAt, } // For backward compatibility, populate worktree_path and worktree_branch from first worktree if len(s.Worktrees) > 0 { @@ -307,12 +306,37 @@ type Executor struct { Type ExecutorType `json:"type"` Status ExecutorStatus `json:"status"` IsSystem bool `json:"is_system"` + Resumable bool `json:"resumable"` Config map[string]string `json:"config,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt *time.Time `json:"deleted_at,omitempty"` } +// ExecutorRunning tracks an active executor instance for a session. +type ExecutorRunning struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + TaskID string `json:"task_id"` + ExecutorID string `json:"executor_id"` + Runtime string `json:"runtime,omitempty"` + Status string `json:"status"` + Resumable bool `json:"resumable"` + ResumeToken string `json:"resume_token,omitempty"` + AgentExecutionID string `json:"agent_execution_id,omitempty"` + ContainerID string `json:"container_id,omitempty"` + AgentctlURL string `json:"agentctl_url,omitempty"` + AgentctlPort int `json:"agentctl_port,omitempty"` + PID int `json:"pid,omitempty"` + WorktreeID string `json:"worktree_id,omitempty"` + WorktreePath string `json:"worktree_path,omitempty"` + WorktreeBranch string `json:"worktree_branch,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // EnvironmentKind represents the runtime type for environments. type EnvironmentKind string @@ -342,11 +366,6 @@ type Environment struct { // ToAPI converts internal Task to API type func (t *Task) ToAPI() *v1.Task { - var assignedAgentID *string - if t.AssignedTo != "" { - assignedAgentID = &t.AssignedTo - } - // Convert TaskRepository models to API types var repositories []v1.TaskRepository for _, repo := range t.Repositories { @@ -363,17 +382,16 @@ func (t *Task) ToAPI() *v1.Task { } return &v1.Task{ - ID: t.ID, - WorkspaceID: t.WorkspaceID, - BoardID: t.BoardID, - Title: t.Title, - Description: t.Description, - State: t.State, - Priority: t.Priority, - AssignedAgentID: assignedAgentID, - Repositories: repositories, - CreatedAt: t.CreatedAt, - UpdatedAt: t.UpdatedAt, - Metadata: t.Metadata, + ID: t.ID, + WorkspaceID: t.WorkspaceID, + BoardID: t.BoardID, + Title: t.Title, + Description: t.Description, + State: t.State, + Priority: t.Priority, + Repositories: repositories, + CreatedAt: t.CreatedAt, + UpdatedAt: t.UpdatedAt, + Metadata: t.Metadata, } } diff --git a/apps/backend/internal/task/models/models_test.go b/apps/backend/internal/task/models/models_test.go index 5b218c8793..f681ed9b7c 100644 --- a/apps/backend/internal/task/models/models_test.go +++ b/apps/backend/internal/task/models/models_test.go @@ -49,20 +49,19 @@ func TestTaskStructInitialization(t *testing.T) { }, } task := Task{ - ID: "task-123", - WorkspaceID: "workspace-001", - BoardID: "board-456", - ColumnID: "column-789", - Title: "Test Task", - Description: "A test task description", - State: v1.TaskStateTODO, - Priority: 5, - AssignedTo: "agent-001", - Position: 1, - Metadata: map[string]interface{}{"key": "value"}, - Repositories: repos, - CreatedAt: now, - UpdatedAt: now, + ID: "task-123", + WorkspaceID: "workspace-001", + BoardID: "board-456", + ColumnID: "column-789", + Title: "Test Task", + Description: "A test task description", + State: v1.TaskStateTODO, + Priority: 5, + Position: 1, + Metadata: map[string]interface{}{"key": "value"}, + Repositories: repos, + CreatedAt: now, + UpdatedAt: now, } if task.ID != "task-123" { @@ -98,9 +97,6 @@ func TestTaskStructInitialization(t *testing.T) { if task.Repositories[0].BaseBranch != "main" { t.Errorf("expected BaseBranch 'main', got %s", task.Repositories[0].BaseBranch) } - if task.AssignedTo != "agent-001" { - t.Errorf("expected AssignedTo 'agent-001', got %s", task.AssignedTo) - } if task.Position != 1 { t.Errorf("expected Position 1, got %d", task.Position) } @@ -169,11 +165,11 @@ func TestTaskToAPI(t *testing.T) { ID: "task-123", WorkspaceID: "workspace-001", BoardID: "board-456", - ColumnID: "column-789", - Title: "Test Task", - Description: "A test task description", - State: v1.TaskStateInProgress, - Priority: 3, + ColumnID: "column-789", + Title: "Test Task", + Description: "A test task description", + State: v1.TaskStateInProgress, + Priority: 3, Repositories: []*TaskRepository{ { ID: "task-repo-1", @@ -186,11 +182,10 @@ func TestTaskToAPI(t *testing.T) { UpdatedAt: now, }, }, - AssignedTo: "agent-001", - Position: 2, - Metadata: map[string]interface{}{"key": "value"}, - CreatedAt: now, - UpdatedAt: now, + Position: 2, + Metadata: map[string]interface{}{"key": "value"}, + CreatedAt: now, + UpdatedAt: now, } apiTask := task.ToAPI() @@ -225,9 +220,6 @@ func TestTaskToAPI(t *testing.T) { if apiTask.Repositories[0].BaseBranch != "main" { t.Errorf("expected BaseBranch main, got %s", apiTask.Repositories[0].BaseBranch) } - if apiTask.AssignedAgentID == nil || *apiTask.AssignedAgentID != task.AssignedTo { - t.Errorf("expected AssignedAgentID %s, got %v", task.AssignedTo, apiTask.AssignedAgentID) - } if apiTask.Metadata["key"] != "value" { t.Errorf("expected Metadata key 'value', got %v", apiTask.Metadata["key"]) } @@ -237,18 +229,17 @@ func TestTaskToAPIWithEmptyOptionalFields(t *testing.T) { now := time.Now().UTC() task := &Task{ ID: "task-123", - WorkspaceID: "workspace-001", - BoardID: "board-456", - ColumnID: "column-789", - Title: "Test Task", - Description: "A test task description", - State: v1.TaskStateTODO, - Priority: 0, - AssignedTo: "", - Position: 0, - Metadata: nil, - CreatedAt: now, - UpdatedAt: now, + WorkspaceID: "workspace-001", + BoardID: "board-456", + ColumnID: "column-789", + Title: "Test Task", + Description: "A test task description", + State: v1.TaskStateTODO, + Priority: 0, + Position: 0, + Metadata: nil, + CreatedAt: now, + UpdatedAt: now, } apiTask := task.ToAPI() @@ -256,7 +247,4 @@ func TestTaskToAPIWithEmptyOptionalFields(t *testing.T) { if len(apiTask.Repositories) != 0 { t.Errorf("expected no repositories, got %d", len(apiTask.Repositories)) } - if apiTask.AssignedAgentID != nil { - t.Errorf("expected AssignedAgentID nil, got %v", apiTask.AssignedAgentID) - } } diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index b44ce361a9..0dfaa4c8e5 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -109,6 +109,11 @@ type Repository interface { DeleteExecutor(ctx context.Context, id string) error ListExecutors(ctx context.Context) ([]*models.Executor, error) + // Executor running operations + UpsertExecutorRunning(ctx context.Context, running *models.ExecutorRunning) error + GetExecutorRunningBySessionID(ctx context.Context, sessionID string) (*models.ExecutorRunning, error) + DeleteExecutorRunningBySessionID(ctx context.Context, sessionID string) error + // Environment operations CreateEnvironment(ctx context.Context, environment *models.Environment) error GetEnvironment(ctx context.Context, id string) (*models.Environment, error) diff --git a/apps/backend/internal/task/repository/sqlite.go b/apps/backend/internal/task/repository/sqlite.go index e78aa74931..8309b073fe 100644 --- a/apps/backend/internal/task/repository/sqlite.go +++ b/apps/backend/internal/task/repository/sqlite.go @@ -105,12 +105,36 @@ func (r *SQLiteRepository) initSchema() error { type TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', is_system INTEGER NOT NULL DEFAULT 0, + resumable INTEGER NOT NULL DEFAULT 1, config TEXT DEFAULT '{}', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, deleted_at DATETIME ); + CREATE TABLE IF NOT EXISTS executors_running ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL UNIQUE, + task_id TEXT NOT NULL, + executor_id TEXT NOT NULL, + runtime TEXT DEFAULT '', + status TEXT NOT NULL DEFAULT 'starting', + resumable INTEGER NOT NULL DEFAULT 0, + resume_token TEXT DEFAULT '', + agent_execution_id TEXT DEFAULT '', + container_id TEXT DEFAULT '', + agentctl_url TEXT DEFAULT '', + agentctl_port INTEGER DEFAULT 0, + pid INTEGER DEFAULT 0, + worktree_id TEXT DEFAULT '', + worktree_path TEXT DEFAULT '', + worktree_branch TEXT DEFAULT '', + last_seen_at DATETIME, + error_message TEXT DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + CREATE TABLE IF NOT EXISTS environments ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -155,7 +179,6 @@ func (r *SQLiteRepository) initSchema() error { description TEXT DEFAULT '', state TEXT DEFAULT 'TODO', priority INTEGER DEFAULT 0, - assigned_to TEXT DEFAULT '', position INTEGER DEFAULT 0, metadata TEXT DEFAULT '{}', created_at DATETIME NOT NULL, @@ -216,7 +239,7 @@ func (r *SQLiteRepository) initSchema() error { CREATE INDEX IF NOT EXISTS idx_repositories_workspace_id ON repositories(workspace_id); CREATE INDEX IF NOT EXISTS idx_repository_scripts_repo_id ON repository_scripts(repository_id); - CREATE TABLE IF NOT EXISTS task_messages ( + CREATE TABLE IF NOT EXISTS task_session_messages ( id TEXT PRIMARY KEY, task_session_id TEXT NOT NULL, task_id TEXT DEFAULT '', @@ -230,9 +253,9 @@ func (r *SQLiteRepository) initSchema() error { FOREIGN KEY (task_session_id) REFERENCES task_sessions(id) ON DELETE CASCADE ); - CREATE INDEX IF NOT EXISTS idx_messages_session_id ON task_messages(task_session_id); - CREATE INDEX IF NOT EXISTS idx_messages_created_at ON task_messages(created_at); - CREATE INDEX IF NOT EXISTS idx_messages_session_created ON task_messages(task_session_id, created_at); + CREATE INDEX IF NOT EXISTS idx_messages_session_id ON task_session_messages(task_session_id); + CREATE INDEX IF NOT EXISTS idx_messages_created_at ON task_session_messages(created_at); + CREATE INDEX IF NOT EXISTS idx_messages_session_created ON task_session_messages(task_session_id, created_at); CREATE TABLE IF NOT EXISTS task_sessions ( id TEXT PRIMARY KEY, @@ -316,18 +339,21 @@ func (r *SQLiteRepository) initSchema() error { if err := r.ensureColumn("executors", "deleted_at", "DATETIME"); err != nil { return err } + if err := r.ensureColumn("executors", "resumable", "INTEGER NOT NULL DEFAULT 1"); err != nil { + return err + } if err := r.ensureColumn("environments", "deleted_at", "DATETIME"); err != nil { return err } // Ensure new message columns exist for existing databases - if err := r.ensureColumn("task_messages", "type", "TEXT NOT NULL DEFAULT 'message'"); err != nil { + if err := r.ensureColumn("task_session_messages", "type", "TEXT NOT NULL DEFAULT 'message'"); err != nil { return err } - if err := r.ensureColumn("task_messages", "metadata", "TEXT DEFAULT '{}'"); err != nil { + if err := r.ensureColumn("task_session_messages", "metadata", "TEXT DEFAULT '{}'"); err != nil { return err } - if err := r.ensureColumn("task_messages", "task_session_id", "TEXT NOT NULL DEFAULT ''"); err != nil { + if err := r.ensureColumn("task_session_messages", "task_session_id", "TEXT NOT NULL DEFAULT ''"); err != nil { return err } @@ -504,38 +530,42 @@ func (r *SQLiteRepository) ensureDefaultExecutorsAndEnvironments() error { if executorCount == 0 { now := time.Now().UTC() executors := []struct { - id string - name string - execType models.ExecutorType - status models.ExecutorStatus - isSystem bool - config map[string]string + id string + name string + execType models.ExecutorType + status models.ExecutorStatus + isSystem bool + resumable bool + config map[string]string }{ { - id: models.ExecutorIDLocalPC, - name: "Local PC", - execType: models.ExecutorTypeLocalPC, - status: models.ExecutorStatusActive, - isSystem: true, - config: map[string]string{}, + id: models.ExecutorIDLocalPC, + name: "Local PC", + execType: models.ExecutorTypeLocalPC, + status: models.ExecutorStatusActive, + isSystem: true, + resumable: true, + config: map[string]string{}, }, { - id: models.ExecutorIDLocalDocker, - name: "Local Docker", - execType: models.ExecutorTypeLocalDocker, - status: models.ExecutorStatusActive, - isSystem: false, + id: models.ExecutorIDLocalDocker, + name: "Local Docker", + execType: models.ExecutorTypeLocalDocker, + status: models.ExecutorStatusActive, + isSystem: false, + resumable: true, config: map[string]string{ "docker_host": "unix:///var/run/docker.sock", }, }, { - id: models.ExecutorIDRemoteDocker, - name: "Remote Docker", - execType: models.ExecutorTypeRemoteDocker, - status: models.ExecutorStatusDisabled, - isSystem: false, - config: map[string]string{}, + id: models.ExecutorIDRemoteDocker, + name: "Remote Docker", + execType: models.ExecutorTypeRemoteDocker, + status: models.ExecutorStatusDisabled, + isSystem: false, + resumable: true, + config: map[string]string{}, }, } @@ -545,9 +575,9 @@ func (r *SQLiteRepository) ensureDefaultExecutorsAndEnvironments() error { return fmt.Errorf("failed to serialize executor config: %w", err) } if _, err := r.db.ExecContext(ctx, ` - INSERT INTO executors (id, name, type, status, is_system, config, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, executor.id, executor.name, executor.execType, executor.status, boolToInt(executor.isSystem), string(configJSON), now, now); err != nil { + INSERT INTO executors (id, name, type, status, is_system, resumable, config, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, executor.id, executor.name, executor.execType, executor.status, boolToInt(executor.isSystem), boolToInt(executor.resumable), string(configJSON), now, now); err != nil { return err } } @@ -805,9 +835,9 @@ func (r *SQLiteRepository) CreateTask(ctx context.Context, task *models.Task) er } _, err = tx.ExecContext(ctx, ` - INSERT INTO tasks (id, workspace_id, board_id, column_id, title, description, state, priority, assigned_to, position, metadata, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, task.ID, task.WorkspaceID, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.AssignedTo, task.Position, string(metadata), task.CreatedAt, task.UpdatedAt) + INSERT INTO tasks (id, workspace_id, board_id, column_id, title, description, state, priority, position, metadata, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, task.ID, task.WorkspaceID, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.CreatedAt, task.UpdatedAt) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { return fmt.Errorf("failed to rollback task insert: %w", rollbackErr) @@ -822,12 +852,10 @@ func (r *SQLiteRepository) CreateTask(ctx context.Context, task *models.Task) er func (r *SQLiteRepository) GetTask(ctx context.Context, id string) (*models.Task, error) { task := &models.Task{} var metadata string - var assignedTo sql.NullString - err := r.db.QueryRowContext(ctx, ` - SELECT id, workspace_id, board_id, column_id, title, description, state, priority, assigned_to, position, metadata, created_at, updated_at + SELECT id, workspace_id, board_id, column_id, title, description, state, priority, position, metadata, created_at, updated_at FROM tasks WHERE id = ? - `, id).Scan(&task.ID, &task.WorkspaceID, &task.BoardID, &task.ColumnID, &task.Title, &task.Description, &task.State, &task.Priority, &assignedTo, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt) + `, id).Scan(&task.ID, &task.WorkspaceID, &task.BoardID, &task.ColumnID, &task.Title, &task.Description, &task.State, &task.Priority, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("task not found: %s", id) @@ -836,7 +864,6 @@ func (r *SQLiteRepository) GetTask(ctx context.Context, id string) (*models.Task return nil, err } - task.AssignedTo = assignedTo.String _ = json.Unmarshal([]byte(metadata), &task.Metadata) return task, nil } @@ -851,9 +878,9 @@ func (r *SQLiteRepository) UpdateTask(ctx context.Context, task *models.Task) er } result, err := r.db.ExecContext(ctx, ` - UPDATE tasks SET workspace_id = ?, board_id = ?, column_id = ?, title = ?, description = ?, state = ?, priority = ?, assigned_to = ?, position = ?, metadata = ?, updated_at = ? + UPDATE tasks SET workspace_id = ?, board_id = ?, column_id = ?, title = ?, description = ?, state = ?, priority = ?, position = ?, metadata = ?, updated_at = ? WHERE id = ? - `, task.WorkspaceID, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.AssignedTo, task.Position, string(metadata), task.UpdatedAt, task.ID) + `, task.WorkspaceID, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.UpdatedAt, task.ID) if err != nil { return err } @@ -882,7 +909,7 @@ func (r *SQLiteRepository) DeleteTask(ctx context.Context, id string) error { // ListTasks returns all tasks for a board func (r *SQLiteRepository) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT id, workspace_id, board_id, column_id, title, description, state, priority, assigned_to, position, metadata, created_at, updated_at + SELECT id, workspace_id, board_id, column_id, title, description, state, priority, position, metadata, created_at, updated_at FROM tasks WHERE board_id = ? ORDER BY position @@ -898,7 +925,7 @@ func (r *SQLiteRepository) ListTasks(ctx context.Context, boardID string) ([]*mo // ListTasksByColumn returns all tasks in a column func (r *SQLiteRepository) ListTasksByColumn(ctx context.Context, columnID string) ([]*models.Task, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT id, workspace_id, board_id, column_id, title, description, state, priority, assigned_to, position, metadata, created_at, updated_at + SELECT id, workspace_id, board_id, column_id, title, description, state, priority, position, metadata, created_at, updated_at FROM tasks WHERE column_id = ? ORDER BY position `, columnID) @@ -915,7 +942,6 @@ func (r *SQLiteRepository) scanTasks(rows *sql.Rows) ([]*models.Task, error) { for rows.Next() { task := &models.Task{} var metadata string - var assignedTo sql.NullString err := rows.Scan( &task.ID, &task.WorkspaceID, @@ -925,7 +951,6 @@ func (r *SQLiteRepository) scanTasks(rows *sql.Rows) ([]*models.Task, error) { &task.Description, &task.State, &task.Priority, - &assignedTo, &task.Position, &metadata, &task.CreatedAt, @@ -934,7 +959,6 @@ func (r *SQLiteRepository) scanTasks(rows *sql.Rows) ([]*models.Task, error) { if err != nil { return nil, err } - task.AssignedTo = assignedTo.String _ = json.Unmarshal([]byte(metadata), &task.Metadata) result = append(result, task) } @@ -1348,7 +1372,7 @@ func (r *SQLiteRepository) CreateMessage(ctx context.Context, message *models.Me } _, err := r.db.ExecContext(ctx, ` - INSERT INTO task_messages (id, task_session_id, task_id, author_type, author_id, content, requests_input, type, metadata, created_at) + INSERT INTO task_session_messages (id, task_session_id, task_id, author_type, author_id, content, requests_input, type, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, message.ID, message.TaskSessionID, message.TaskID, message.AuthorType, message.AuthorID, message.Content, requestsInput, messageType, metadataJSON, message.CreatedAt) @@ -1363,7 +1387,7 @@ func (r *SQLiteRepository) GetMessage(ctx context.Context, id string) (*models.M var metadataJSON string err := r.db.QueryRowContext(ctx, ` SELECT id, task_session_id, task_id, author_type, author_id, content, requests_input, type, metadata, created_at - FROM task_messages WHERE id = ? + FROM task_session_messages WHERE id = ? `, id).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.AuthorType, &message.AuthorID, &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt) if err != nil { return nil, err @@ -1384,7 +1408,7 @@ func (r *SQLiteRepository) GetMessage(ctx context.Context, id string) (*models.M func (r *SQLiteRepository) ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error) { rows, err := r.db.QueryContext(ctx, ` SELECT id, task_session_id, task_id, author_type, author_id, content, requests_input, type, metadata, created_at - FROM task_messages WHERE task_session_id = ? ORDER BY created_at ASC + FROM task_session_messages WHERE task_session_id = ? ORDER BY created_at ASC `, sessionID) if err != nil { return nil, err @@ -1454,7 +1478,7 @@ func (r *SQLiteRepository) ListMessagesPaginated(ctx context.Context, sessionID query := ` SELECT id, task_session_id, task_id, author_type, author_id, content, requests_input, type, metadata, created_at - FROM task_messages WHERE task_session_id = ?` + FROM task_session_messages WHERE task_session_id = ?` args := []interface{}{sessionID} if cursor != nil { if opts.Before != "" { @@ -1510,7 +1534,7 @@ func (r *SQLiteRepository) ListMessagesPaginated(ctx context.Context, sessionID // DeleteMessage deletes a message by ID func (r *SQLiteRepository) DeleteMessage(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM task_messages WHERE id = ?`, id) + result, err := r.db.ExecContext(ctx, `DELETE FROM task_session_messages WHERE id = ?`, id) if err != nil { return err } @@ -1530,7 +1554,7 @@ func (r *SQLiteRepository) GetMessageByToolCallID(ctx context.Context, sessionID var metadataJSON string err := r.db.QueryRowContext(ctx, ` SELECT id, task_session_id, task_id, author_type, author_id, content, requests_input, type, metadata, created_at - FROM task_messages WHERE task_session_id = ? AND json_extract(metadata, '$.tool_call_id') = ? + FROM task_session_messages WHERE task_session_id = ? AND json_extract(metadata, '$.tool_call_id') = ? `, sessionID, toolCallID).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.AuthorType, &message.AuthorID, &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt) if err != nil { @@ -1557,7 +1581,7 @@ func (r *SQLiteRepository) UpdateMessage(ctx context.Context, message *models.Me } result, err := r.db.ExecContext(ctx, ` - UPDATE task_messages SET content = ?, requests_input = ?, type = ?, metadata = ? + UPDATE task_session_messages SET content = ?, requests_input = ?, type = ?, metadata = ? WHERE id = ? `, message.Content, requestsInput, string(message.Type), string(metadataJSON), message.ID) if err != nil { @@ -2390,9 +2414,9 @@ func (r *SQLiteRepository) CreateExecutor(ctx context.Context, executor *models. } _, err = r.db.ExecContext(ctx, ` - INSERT INTO executors (id, name, type, status, is_system, config, created_at, updated_at, deleted_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, executor.ID, executor.Name, executor.Type, executor.Status, boolToInt(executor.IsSystem), string(configJSON), executor.CreatedAt, executor.UpdatedAt, executor.DeletedAt) + INSERT INTO executors (id, name, type, status, is_system, resumable, config, created_at, updated_at, deleted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, executor.ID, executor.Name, executor.Type, executor.Status, boolToInt(executor.IsSystem), boolToInt(executor.Resumable), string(configJSON), executor.CreatedAt, executor.UpdatedAt, executor.DeletedAt) return err } @@ -2400,13 +2424,14 @@ func (r *SQLiteRepository) GetExecutor(ctx context.Context, id string) (*models. executor := &models.Executor{} var configJSON string var isSystem int + var resumable int err := r.db.QueryRowContext(ctx, ` - SELECT id, name, type, status, is_system, config, created_at, updated_at, deleted_at + SELECT id, name, type, status, is_system, resumable, config, created_at, updated_at, deleted_at FROM executors WHERE id = ? AND deleted_at IS NULL `, id).Scan( &executor.ID, &executor.Name, &executor.Type, &executor.Status, - &isSystem, &configJSON, &executor.CreatedAt, &executor.UpdatedAt, &executor.DeletedAt, + &isSystem, &resumable, &configJSON, &executor.CreatedAt, &executor.UpdatedAt, &executor.DeletedAt, ) if err == sql.ErrNoRows { return nil, fmt.Errorf("executor not found: %s", id) @@ -2416,6 +2441,7 @@ func (r *SQLiteRepository) GetExecutor(ctx context.Context, id string) (*models. } executor.IsSystem = isSystem == 1 + executor.Resumable = resumable == 1 if configJSON != "" && configJSON != "{}" { if err := json.Unmarshal([]byte(configJSON), &executor.Config); err != nil { return nil, fmt.Errorf("failed to deserialize executor config: %w", err) @@ -2433,9 +2459,9 @@ func (r *SQLiteRepository) UpdateExecutor(ctx context.Context, executor *models. } result, err := r.db.ExecContext(ctx, ` - UPDATE executors SET name = ?, type = ?, status = ?, is_system = ?, config = ?, updated_at = ? + UPDATE executors SET name = ?, type = ?, status = ?, is_system = ?, resumable = ?, config = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL - `, executor.Name, executor.Type, executor.Status, boolToInt(executor.IsSystem), string(configJSON), executor.UpdatedAt, executor.ID) + `, executor.Name, executor.Type, executor.Status, boolToInt(executor.IsSystem), boolToInt(executor.Resumable), string(configJSON), executor.UpdatedAt, executor.ID) if err != nil { return err } @@ -2463,7 +2489,7 @@ func (r *SQLiteRepository) DeleteExecutor(ctx context.Context, id string) error func (r *SQLiteRepository) ListExecutors(ctx context.Context) ([]*models.Executor, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT id, name, type, status, is_system, config, created_at, updated_at, deleted_at + SELECT id, name, type, status, is_system, resumable, config, created_at, updated_at, deleted_at FROM executors WHERE deleted_at IS NULL ORDER BY created_at ASC `) if err != nil { @@ -2476,13 +2502,15 @@ func (r *SQLiteRepository) ListExecutors(ctx context.Context) ([]*models.Executo executor := &models.Executor{} var configJSON string var isSystem int + var resumable int if err := rows.Scan( &executor.ID, &executor.Name, &executor.Type, &executor.Status, - &isSystem, &configJSON, &executor.CreatedAt, &executor.UpdatedAt, &executor.DeletedAt, + &isSystem, &resumable, &configJSON, &executor.CreatedAt, &executor.UpdatedAt, &executor.DeletedAt, ); err != nil { return nil, err } executor.IsSystem = isSystem == 1 + executor.Resumable = resumable == 1 if configJSON != "" && configJSON != "{}" { if err := json.Unmarshal([]byte(configJSON), &executor.Config); err != nil { return nil, fmt.Errorf("failed to deserialize executor config: %w", err) @@ -2493,6 +2521,138 @@ func (r *SQLiteRepository) ListExecutors(ctx context.Context) ([]*models.Executo return result, rows.Err() } +func (r *SQLiteRepository) UpsertExecutorRunning(ctx context.Context, running *models.ExecutorRunning) error { + if running == nil { + return fmt.Errorf("executor running is nil") + } + if running.SessionID == "" { + return fmt.Errorf("session_id is required") + } + if running.ID == "" { + running.ID = running.SessionID + } + now := time.Now().UTC() + if running.CreatedAt.IsZero() { + running.CreatedAt = now + } + running.UpdatedAt = now + + _, err := r.db.ExecContext(ctx, ` + INSERT INTO executors_running ( + id, session_id, task_id, executor_id, runtime, status, resumable, resume_token, + agent_execution_id, container_id, agentctl_url, agentctl_port, pid, + worktree_id, worktree_path, worktree_branch, last_seen_at, error_message, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET + id = excluded.id, + task_id = excluded.task_id, + executor_id = excluded.executor_id, + runtime = excluded.runtime, + status = excluded.status, + resumable = excluded.resumable, + resume_token = excluded.resume_token, + agent_execution_id = excluded.agent_execution_id, + container_id = excluded.container_id, + agentctl_url = excluded.agentctl_url, + agentctl_port = excluded.agentctl_port, + pid = excluded.pid, + worktree_id = excluded.worktree_id, + worktree_path = excluded.worktree_path, + worktree_branch = excluded.worktree_branch, + last_seen_at = excluded.last_seen_at, + error_message = excluded.error_message, + updated_at = excluded.updated_at + `, + running.ID, + running.SessionID, + running.TaskID, + running.ExecutorID, + running.Runtime, + running.Status, + boolToInt(running.Resumable), + running.ResumeToken, + running.AgentExecutionID, + running.ContainerID, + running.AgentctlURL, + running.AgentctlPort, + running.PID, + running.WorktreeID, + running.WorktreePath, + running.WorktreeBranch, + running.LastSeenAt, + running.ErrorMessage, + running.CreatedAt, + running.UpdatedAt, + ) + return err +} + +func (r *SQLiteRepository) GetExecutorRunningBySessionID(ctx context.Context, sessionID string) (*models.ExecutorRunning, error) { + if sessionID == "" { + return nil, fmt.Errorf("session_id is required") + } + running := &models.ExecutorRunning{} + var resumable int + var lastSeen sql.NullTime + + err := r.db.QueryRowContext(ctx, ` + SELECT id, session_id, task_id, executor_id, runtime, status, resumable, resume_token, + agent_execution_id, container_id, agentctl_url, agentctl_port, pid, + worktree_id, worktree_path, worktree_branch, last_seen_at, error_message, + created_at, updated_at + FROM executors_running + WHERE session_id = ? + `, sessionID).Scan( + &running.ID, + &running.SessionID, + &running.TaskID, + &running.ExecutorID, + &running.Runtime, + &running.Status, + &resumable, + &running.ResumeToken, + &running.AgentExecutionID, + &running.ContainerID, + &running.AgentctlURL, + &running.AgentctlPort, + &running.PID, + &running.WorktreeID, + &running.WorktreePath, + &running.WorktreeBranch, + &lastSeen, + &running.ErrorMessage, + &running.CreatedAt, + &running.UpdatedAt, + ) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("executor running not found for session: %s", sessionID) + } + if err != nil { + return nil, err + } + running.Resumable = resumable == 1 + if lastSeen.Valid { + running.LastSeenAt = &lastSeen.Time + } + return running, nil +} + +func (r *SQLiteRepository) DeleteExecutorRunningBySessionID(ctx context.Context, sessionID string) error { + if sessionID == "" { + return fmt.Errorf("session_id is required") + } + result, err := r.db.ExecContext(ctx, `DELETE FROM executors_running WHERE session_id = ?`, sessionID) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("executor running not found for session: %s", sessionID) + } + return nil +} + // Environment operations func (r *SQLiteRepository) CreateEnvironment(ctx context.Context, environment *models.Environment) error { diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 942cded36f..91d0690617 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -83,7 +83,6 @@ type CreateTaskRequest struct { Priority int `json:"priority"` State *v1.TaskState `json:"state,omitempty"` Repositories []TaskRepositoryInput `json:"repositories,omitempty"` - AssignedTo string `json:"assigned_to,omitempty"` Position int `json:"position"` Metadata map[string]interface{} `json:"metadata,omitempty"` } @@ -95,7 +94,6 @@ type UpdateTaskRequest struct { Priority *int `json:"priority,omitempty"` State *v1.TaskState `json:"state,omitempty"` Repositories []TaskRepositoryInput `json:"repositories,omitempty"` - AssignedTo *string `json:"assigned_to,omitempty"` Position *int `json:"position,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` } @@ -180,19 +178,21 @@ type UpdateRepositoryRequest struct { // CreateExecutorRequest contains the data for creating an executor type CreateExecutorRequest struct { - Name string `json:"name"` - Type models.ExecutorType `json:"type"` - Status models.ExecutorStatus `json:"status"` - IsSystem bool `json:"is_system"` - Config map[string]string `json:"config,omitempty"` + Name string `json:"name"` + Type models.ExecutorType `json:"type"` + Status models.ExecutorStatus `json:"status"` + IsSystem bool `json:"is_system"` + Resumable bool `json:"resumable"` + Config map[string]string `json:"config,omitempty"` } // UpdateExecutorRequest contains the data for updating an executor type UpdateExecutorRequest struct { - Name *string `json:"name,omitempty"` - Type *models.ExecutorType `json:"type,omitempty"` - Status *models.ExecutorStatus `json:"status,omitempty"` - Config map[string]string `json:"config,omitempty"` + Name *string `json:"name,omitempty"` + Type *models.ExecutorType `json:"type,omitempty"` + Status *models.ExecutorStatus `json:"status,omitempty"` + Resumable *bool `json:"resumable,omitempty"` + Config map[string]string `json:"config,omitempty"` } // CreateEnvironmentRequest contains the data for creating an environment @@ -255,7 +255,6 @@ func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*mode Description: req.Description, State: state, Priority: req.Priority, - AssignedTo: req.AssignedTo, Position: req.Position, Metadata: req.Metadata, } @@ -389,9 +388,6 @@ func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequ task.State = *req.State stateChanged = true } - if req.AssignedTo != nil { - task.AssignedTo = *req.AssignedTo - } if req.Position != nil { task.Position = *req.Position } @@ -471,6 +467,37 @@ func (s *Service) DeleteTask(ctx context.Context, id string) error { } } + sessions, err := s.repo.ListTaskSessions(ctx, id) + if err != nil { + s.logger.Warn("failed to list task sessions for delete", + zap.String("task_id", id), + zap.Error(err)) + } else { + for _, session := range sessions { + if session == nil || session.ID == "" { + continue + } + if err := s.repo.DeleteExecutorRunningBySessionID(ctx, session.ID); err != nil { + s.logger.Debug("failed to delete executor runtime for session", + zap.String("task_id", id), + zap.String("session_id", session.ID), + zap.Error(err)) + } + if err := s.repo.DeleteTaskSessionWorktreesBySession(ctx, session.ID); err != nil { + s.logger.Warn("failed to delete session worktrees", + zap.String("task_id", id), + zap.String("session_id", session.ID), + zap.Error(err)) + } + if err := s.repo.DeleteTaskSession(ctx, session.ID); err != nil { + s.logger.Warn("failed to delete task session", + zap.String("task_id", id), + zap.String("session_id", session.ID), + zap.Error(err)) + } + } + } + if err := s.repo.DeleteTask(ctx, id); err != nil { s.logger.Error("failed to delete task", zap.String("task_id", id), zap.Error(err)) return err @@ -507,6 +534,11 @@ func (s *Service) ListTaskSessions(ctx context.Context, taskID string) ([]*model return s.repo.ListTaskSessions(ctx, taskID) } +// GetTaskSession returns a single session by ID. +func (s *Service) GetTaskSession(ctx context.Context, sessionID string) (*models.TaskSession, error) { + return s.repo.GetTaskSession(ctx, sessionID) +} + // UpdateTaskState updates the state of a task, moves it to the matching column, // and publishes a task.state_changed event func (s *Service) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) (*models.Task, error) { @@ -1068,12 +1100,13 @@ func (s *Service) ListRepositoryScripts(ctx context.Context, repositoryID string func (s *Service) CreateExecutor(ctx context.Context, req *CreateExecutorRequest) (*models.Executor, error) { executor := &models.Executor{ - ID: uuid.New().String(), - Name: req.Name, - Type: req.Type, - Status: req.Status, - IsSystem: req.IsSystem, - Config: req.Config, + ID: uuid.New().String(), + Name: req.Name, + Type: req.Type, + Status: req.Status, + IsSystem: req.IsSystem, + Resumable: req.Resumable, + Config: req.Config, } if err := s.repo.CreateExecutor(ctx, executor); err != nil { @@ -1104,6 +1137,9 @@ func (s *Service) UpdateExecutor(ctx context.Context, id string, req *UpdateExec if req.Status != nil { executor.Status = *req.Status } + if req.Resumable != nil { + executor.Resumable = *req.Resumable + } if req.Config != nil { executor.Config = req.Config } @@ -1248,9 +1284,6 @@ func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task * "updated_at": task.UpdatedAt.Format(time.RFC3339), } - if task.AssignedTo != "" { - data["assigned_to"] = task.AssignedTo - } if task.Metadata != nil { data["metadata"] = task.Metadata } @@ -1363,6 +1396,7 @@ func (s *Service) publishExecutorEvent(ctx context.Context, eventType string, ex "type": executor.Type, "status": executor.Status, "is_system": executor.IsSystem, + "resumable": executor.Resumable, "config": executor.Config, "created_at": executor.CreatedAt.Format(time.RFC3339), "updated_at": executor.UpdatedAt.Format(time.RFC3339), @@ -1408,7 +1442,7 @@ func (s *Service) publishEnvironmentEvent(ctx context.Context, eventType string, // CreateMessageRequest contains the data for creating a new message type CreateMessageRequest struct { - TaskSessionID string `json:"task_session_id"` + TaskSessionID string `json:"session_id"` TaskID string `json:"task_id,omitempty"` Content string `json:"content"` AuthorType string `json:"author_type,omitempty"` // "user" or "agent", defaults to "user" @@ -1463,7 +1497,7 @@ func (s *Service) CreateMessage(ctx context.Context, req *CreateMessageRequest) s.logger.Info("message created", zap.String("message_id", message.ID), - zap.String("task_session_id", message.TaskSessionID), + zap.String("session_id", message.TaskSessionID), zap.String("author_type", string(message.AuthorType))) return message, nil @@ -1533,7 +1567,7 @@ func (s *Service) UpdateToolCallMessage(ctx context.Context, sessionID, toolCall // Log retry attempt (only warn on final failure) if attempt < maxRetries-1 { s.logger.Debug("tool call message not found, retrying", - zap.String("task_session_id", sessionID), + zap.String("session_id", sessionID), zap.String("tool_call_id", toolCallID), zap.Int("attempt", attempt+1), zap.Int("max_retries", maxRetries)) @@ -1543,7 +1577,7 @@ func (s *Service) UpdateToolCallMessage(ctx context.Context, sessionID, toolCall if err != nil { s.logger.Warn("tool call message not found for update after retries", - zap.String("task_session_id", sessionID), + zap.String("session_id", sessionID), zap.String("tool_call_id", toolCallID), zap.Int("retries", maxRetries), zap.Error(err)) @@ -1590,15 +1624,15 @@ func (s *Service) publishMessageEvent(ctx context.Context, eventType string, mes } data := map[string]interface{}{ - "message_id": message.ID, - "task_session_id": message.TaskSessionID, - "task_id": message.TaskID, - "author_type": string(message.AuthorType), - "author_id": message.AuthorID, - "content": message.Content, - "type": messageType, - "requests_input": message.RequestsInput, - "created_at": message.CreatedAt.Format(time.RFC3339), + "message_id": message.ID, + "session_id": message.TaskSessionID, + "task_id": message.TaskID, + "author_type": string(message.AuthorType), + "author_id": message.AuthorID, + "content": message.Content, + "type": messageType, + "requests_input": message.RequestsInput, + "created_at": message.CreatedAt.Format(time.RFC3339), } if message.Metadata != nil { diff --git a/apps/backend/pkg/acp/protocol/message.go b/apps/backend/pkg/acp/protocol/message.go index 006961a72e..7e225e5eeb 100644 --- a/apps/backend/pkg/acp/protocol/message.go +++ b/apps/backend/pkg/acp/protocol/message.go @@ -29,6 +29,7 @@ type Message struct { Timestamp time.Time `json:"timestamp"` AgentID string `json:"agent_id"` TaskID string `json:"task_id"` + SessionID string `json:"session_id"` Data map[string]interface{} `json:"data"` } diff --git a/apps/backend/pkg/api/v1/task.go b/apps/backend/pkg/api/v1/task.go index 00b477233b..55a4c71853 100644 --- a/apps/backend/pkg/api/v1/task.go +++ b/apps/backend/pkg/api/v1/task.go @@ -59,21 +59,20 @@ type TaskRepository struct { // Task represents a Kanban task type Task struct { - ID string `json:"id"` - WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` - Title string `json:"title"` - Description string `json:"description"` - State TaskState `json:"state"` - Priority int `json:"priority"` - AssignedAgentID *string `json:"assigned_agent_id,omitempty"` - Repositories []TaskRepository `json:"repositories,omitempty"` - CreatedBy string `json:"created_by"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - StartedAt *time.Time `json:"started_at,omitempty"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + BoardID string `json:"board_id"` + Title string `json:"title"` + Description string `json:"description"` + State TaskState `json:"state"` + Priority int `json:"priority"` + Repositories []TaskRepository `json:"repositories,omitempty"` + CreatedBy string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } // TaskRepositoryInput for creating/updating task repositories @@ -84,11 +83,13 @@ type TaskRepositoryInput struct { // CreateTaskRequest for creating a new task type CreateTaskRequest struct { - Title string `json:"title" binding:"required,max=500"` - Description string `json:"description" binding:"required"` - Priority int `json:"priority" binding:"min=0,max=10"` - Repositories []TaskRepositoryInput `json:"repositories,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + Title string `json:"title" binding:"required,max=500"` + Description string `json:"description" binding:"required"` + Priority int `json:"priority" binding:"min=0,max=10"` + Repositories []TaskRepositoryInput `json:"repositories,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + StartAgent bool `json:"start_agent,omitempty"` + AgentProfileID string `json:"agent_profile_id,omitempty"` } // UpdateTaskRequest for updating an existing task @@ -119,26 +120,26 @@ type TaskEvent struct { // Message represents a message in a task session (user or agent) type Message struct { - ID string `json:"id"` - TaskSessionID string `json:"task_session_id"` - TaskID string `json:"task_id,omitempty"` - AuthorType string `json:"author_type"` // "user" or "agent" - Type string `json:"type,omitempty"` - AuthorID string `json:"author_id,omitempty"` - Content string `json:"content"` - RequestsInput bool `json:"requests_input"` // True if agent is requesting user input - Metadata map[string]interface{} `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + TaskSessionID string `json:"session_id"` + TaskID string `json:"task_id,omitempty"` + AuthorType string `json:"author_type"` // "user" or "agent" + Type string `json:"type,omitempty"` + AuthorID string `json:"author_id,omitempty"` + Content string `json:"content"` + RequestsInput bool `json:"requests_input"` // True if agent is requesting user input + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` } // CreateMessageRequest for adding a message to a task session type CreateMessageRequest struct { - TaskSessionID string `json:"task_session_id" binding:"required"` - Content string `json:"content" binding:"required"` - AuthorType string `json:"author_type,omitempty"` // Defaults to "user" if not specified - Type string `json:"type,omitempty"` - RequestsInput bool `json:"requests_input,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + TaskSessionID string `json:"session_id" binding:"required"` + Content string `json:"content" binding:"required"` + AuthorType string `json:"author_type,omitempty"` // Defaults to "user" if not specified + Type string `json:"type,omitempty"` + RequestsInput bool `json:"requests_input,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } // PermissionOption represents a permission choice presented to the user diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 57227c4c81..1ea873b281 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -55,28 +55,28 @@ const ( ActionEnvironmentDelete = "environment.delete" // Task actions - ActionTaskList = "task.list" - ActionTaskCreate = "task.create" - ActionTaskGet = "task.get" - ActionTaskUpdate = "task.update" - ActionTaskDelete = "task.delete" - ActionTaskMove = "task.move" - ActionTaskState = "task.state" - ActionTaskExecution = "task.execution" + ActionTaskList = "task.list" + ActionTaskCreate = "task.create" + ActionTaskGet = "task.get" + ActionTaskUpdate = "task.update" + ActionTaskDelete = "task.delete" + ActionTaskMove = "task.move" + ActionTaskState = "task.state" + ActionTaskExecution = "task.execution" ActionTaskSessionList = "task.session.list" ActionTaskSessionResume = "task.session.resume" ActionTaskSessionStatus = "task.session.status" // Agent actions - ActionAgentList = "agent.list" - ActionAgentLaunch = "agent.launch" - ActionAgentStatus = "agent.status" - ActionAgentLogs = "agent.logs" - ActionAgentStop = "agent.stop" - ActionAgentPrompt = "agent.prompt" - ActionAgentCancel = "agent.cancel" + ActionAgentList = "agent.list" + ActionAgentLaunch = "agent.launch" + ActionAgentStatus = "agent.status" + ActionAgentLogs = "agent.logs" + ActionAgentStop = "agent.stop" + ActionAgentPrompt = "agent.prompt" + ActionAgentCancel = "agent.cancel" ActionTaskSession = "task.session" - ActionAgentTypes = "agent.types" + ActionAgentTypes = "agent.types" // Orchestrator actions ActionOrchestratorStatus = "orchestrator.status" @@ -90,10 +90,12 @@ const ( ActionOrchestratorComplete = "orchestrator.complete" // Subscription actions - ActionTaskSubscribe = "task.subscribe" - ActionTaskUnsubscribe = "task.unsubscribe" - ActionUserSubscribe = "user.subscribe" - ActionUserUnsubscribe = "user.unsubscribe" + ActionTaskSubscribe = "task.subscribe" + ActionTaskUnsubscribe = "task.unsubscribe" + ActionSessionSubscribe = "session.subscribe" + ActionSessionUnsubscribe = "session.unsubscribe" + ActionUserSubscribe = "user.subscribe" + ActionUserUnsubscribe = "user.unsubscribe" // Message actions ActionMessageAdd = "message.add" @@ -101,43 +103,46 @@ const ( ActionMessageList = "message.list" // Notification actions (server -> client) - ActionACPProgress = "acp.progress" - ActionACPLog = "acp.log" - ActionACPResult = "acp.result" - ActionACPError = "acp.error" - ActionACPStatus = "acp.status" - ActionACPHeartbeat = "acp.heartbeat" - ActionTaskCreated = "task.created" - ActionTaskUpdated = "task.updated" - ActionTaskDeleted = "task.deleted" - ActionTaskStateChanged = "task.state_changed" - ActionAgentUpdated = "agent.updated" - ActionWorkspaceCreated = "workspace.created" - ActionWorkspaceUpdated = "workspace.updated" - ActionWorkspaceDeleted = "workspace.deleted" - ActionBoardCreated = "board.created" - ActionBoardUpdated = "board.updated" - ActionBoardDeleted = "board.deleted" - ActionColumnCreated = "column.created" - ActionColumnUpdated = "column.updated" - ActionColumnDeleted = "column.deleted" - ActionMessageAdded = "message.added" - ActionMessageUpdated = "message.updated" - ActionTaskSessionStateChanged = "task_session.state_changed" - ActionTaskSessionWaitingForInput = "task_session.waiting_for_input" - ActionInputRequested = "input.requested" - ActionRepositoryCreated = "repository.created" - ActionRepositoryUpdated = "repository.updated" - ActionRepositoryDeleted = "repository.deleted" - ActionRepositoryScriptCreated = "repository.script.created" - ActionRepositoryScriptUpdated = "repository.script.updated" - ActionRepositoryScriptDeleted = "repository.script.deleted" - ActionExecutorCreated = "executor.created" - ActionExecutorUpdated = "executor.updated" - ActionExecutorDeleted = "executor.deleted" - ActionEnvironmentCreated = "environment.created" - ActionEnvironmentUpdated = "environment.updated" - ActionEnvironmentDeleted = "environment.deleted" + ActionACPProgress = "acp.progress" + ActionACPLog = "acp.log" + ActionACPResult = "acp.result" + ActionACPError = "acp.error" + ActionACPStatus = "acp.status" + ActionACPHeartbeat = "acp.heartbeat" + ActionTaskCreated = "task.created" + ActionTaskUpdated = "task.updated" + ActionTaskDeleted = "task.deleted" + ActionTaskStateChanged = "task.state_changed" + ActionAgentUpdated = "agent.updated" + ActionWorkspaceCreated = "workspace.created" + ActionWorkspaceUpdated = "workspace.updated" + ActionWorkspaceDeleted = "workspace.deleted" + ActionBoardCreated = "board.created" + ActionBoardUpdated = "board.updated" + ActionBoardDeleted = "board.deleted" + ActionColumnCreated = "column.created" + ActionColumnUpdated = "column.updated" + ActionColumnDeleted = "column.deleted" + ActionSessionMessageAdded = "session.message.added" + ActionSessionMessageUpdated = "session.message.updated" + ActionSessionStateChanged = "session.state_changed" + ActionSessionWaitingForInput = "session.waiting_for_input" + ActionSessionAgentctlStarting = "session.agentctl_starting" + ActionSessionAgentctlReady = "session.agentctl_ready" + ActionSessionAgentctlError = "session.agentctl_error" + ActionInputRequested = "input.requested" + ActionRepositoryCreated = "repository.created" + ActionRepositoryUpdated = "repository.updated" + ActionRepositoryDeleted = "repository.deleted" + ActionRepositoryScriptCreated = "repository.script.created" + ActionRepositoryScriptUpdated = "repository.script.updated" + ActionRepositoryScriptDeleted = "repository.script.deleted" + ActionExecutorCreated = "executor.created" + ActionExecutorUpdated = "executor.updated" + ActionExecutorDeleted = "executor.deleted" + ActionEnvironmentCreated = "environment.created" + ActionEnvironmentUpdated = "environment.updated" + ActionEnvironmentDeleted = "environment.deleted" ActionAgentProfileDeleted = "agent.profile.deleted" ActionAgentProfileCreated = "agent.profile.created" @@ -150,13 +155,13 @@ const ( // Workspace file operations ActionWorkspaceFileTreeGet = "workspace.tree.get" ActionWorkspaceFileContentGet = "workspace.file.get" - ActionWorkspaceFileChanges = "workspace.file.changes" // Notification + ActionWorkspaceFileChanges = "session.workspace.file.changes" // Notification // Shell actions - ActionShellStatus = "shell.status" // Get shell status - ActionShellSubscribe = "shell.subscribe" // Subscribe to shell output - ActionShellInput = "shell.input" // Send input to shell - ActionShellOutput = "shell.output" // Shell output notification + ActionShellStatus = "session.shell.status" // Get shell status + ActionShellSubscribe = "shell.subscribe" // Subscribe to shell output + ActionShellInput = "shell.input" // Send input to shell + ActionShellOutput = "session.shell.output" // Shell output notification // User actions ActionUserGet = "user.get" diff --git a/apps/packages/types/src/http.ts b/apps/packages/types/src/http.ts index 7ad162ff1f..f1ba43afb9 100644 --- a/apps/packages/types/src/http.ts +++ b/apps/packages/types/src/http.ts @@ -79,7 +79,6 @@ export type Task = { agent_type?: string | null; repository_url?: string | null; branch?: string | null; - assigned_agent_id?: string | null; created_at: string; updated_at: string; metadata?: Record | null; diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 7eb98a5f20..f8bbfcf3e8 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -114,11 +114,16 @@ export default async function Page({ searchParams }: PageProps) { initialState = { ...initialState, messages: { - sessionId, - items: messagesResponse.messages ?? [], - isLoading: false, - hasMore: messagesResponse.has_more ?? false, - oldestCursor: messagesResponse.cursor ?? (messagesResponse.messages?.[0]?.id ?? null), + bySession: { + [sessionId]: messagesResponse.messages ?? [], + }, + metaBySession: { + [sessionId]: { + isLoading: false, + hasMore: messagesResponse.has_more ?? false, + oldestCursor: messagesResponse.cursor ?? (messagesResponse.messages?.[0]?.id ?? null), + }, + }, }, }; } catch (error) { diff --git a/apps/web/app/task/[id]/[sessionId]/page.tsx b/apps/web/app/s/[sessionId]/page.tsx similarity index 50% rename from apps/web/app/task/[id]/[sessionId]/page.tsx rename to apps/web/app/s/[sessionId]/page.tsx index 1766b44e73..98921134f2 100644 --- a/apps/web/app/task/[id]/[sessionId]/page.tsx +++ b/apps/web/app/s/[sessionId]/page.tsx @@ -1,63 +1,62 @@ import { StateHydrator } from '@/components/state-hydrator'; import { fetchBoardSnapshot, + fetchTaskSession, fetchTask, listAgents, listRepositories, listTaskSessionMessages, - listTaskSessions, } from '@/lib/http'; -import type { Task } from '@/lib/types/http'; +import type { ListMessagesResponse, Task } from '@/lib/types/http'; import { snapshotToState, taskToState } from '@/lib/ssr/mapper'; -import TaskPageClient from '../page-client'; +import TaskPageClient from '@/app/task/[id]/page-client'; -export default async function TaskSessionPage({ +export default async function SessionPage({ params, }: { - params: Promise<{ id: string; sessionId: string }>; + params: Promise<{ sessionId: string }>; }) { let initialState: ReturnType | null = null; let task: Task | null = null; let sessionId: string | null = null; - let sessionsByTask: Record>['sessions']> = {}; try { - const { id, sessionId: paramSessionId } = await params; + const { sessionId: paramSessionId } = await params; sessionId = paramSessionId; - task = await fetchTask(id, { cache: 'no-store' }); + const sessionResponse = await fetchTaskSession(sessionId, { cache: 'no-store' }); + const session = sessionResponse.session; + if (!session?.task_id) { + throw new Error('No task_id found for session'); + } + + task = await fetchTask(session.task_id, { cache: 'no-store' }); const [snapshot, agents, repositories] = await Promise.all([ fetchBoardSnapshot(task.board_id, { cache: 'no-store' }), listAgents({ cache: 'no-store' }), listRepositories(task.workspace_id, { cache: 'no-store' }), ]); - - const sessionResults = await Promise.all( - snapshot.tasks.map((taskItem) => - listTaskSessions(taskItem.id, { cache: 'no-store' }).then((result) => [taskItem.id, result.sessions] as const) - ) - ); - sessionsByTask = Object.fromEntries(sessionResults); - - let taskState = taskToState(task); + let messagesResponse: ListMessagesResponse | null = null; try { - const messagesResponse = await listTaskSessionMessages( + messagesResponse = await listTaskSessionMessages( sessionId, { limit: 50, sort: 'asc' }, { cache: 'no-store' } ); - taskState = taskToState(task, { - items: messagesResponse.messages ?? [], - hasMore: messagesResponse.has_more ?? false, - oldestCursor: messagesResponse.cursor ?? (messagesResponse.messages?.[0]?.id ?? null), - }); } catch (error) { console.warn( - 'Could not SSR messages (client will load via WebSocket):', + 'Could not load session messages for SSR:', error instanceof Error ? error.message : String(error) ); } + const messages = messagesResponse?.messages ?? []; + const taskState = taskToState(task, sessionId, { + items: messages, + hasMore: messagesResponse?.has_more ?? false, + oldestCursor: messagesResponse?.cursor ?? (messages[0]?.id ?? null), + }); + initialState = { ...snapshotToState(snapshot), ...taskState, @@ -82,6 +81,42 @@ export default async function TaskSessionPage({ ), version: 0, }, + taskSessions: { + items: { + [session.id]: session, + }, + }, + taskSessionsByTask: { + itemsByTaskId: { + [task.id]: [session], + }, + loadingByTaskId: { + [task.id]: false, + }, + loadedByTaskId: { + [task.id]: true, + }, + }, + worktrees: session.worktree_id + ? { + items: { + [session.worktree_id]: { + id: session.worktree_id, + sessionId: session.id, + repositoryId: session.repository_id ?? undefined, + path: session.worktree_path ?? undefined, + branch: session.worktree_branch ?? undefined, + }, + }, + } + : undefined, + sessionWorktreesBySessionId: session.worktree_id + ? { + itemsBySessionId: { + [session.id]: [session.worktree_id], + }, + } + : undefined, settingsAgents: { items: agents.agents, }, @@ -91,11 +126,14 @@ export default async function TaskSessionPage({ environmentsLoaded: false, }, }; - } catch { + } catch (error) { + console.warn( + 'Could not SSR session (client will load via WebSocket):', + error instanceof Error ? error.message : String(error) + ); initialState = null; task = null; sessionId = null; - sessionsByTask = {}; } return ( @@ -104,9 +142,7 @@ export default async function TaskSessionPage({ ); diff --git a/apps/web/app/task/[id]/page-client.tsx b/apps/web/app/task/[id]/page-client.tsx index 51033de4e9..d76b570eb4 100644 --- a/apps/web/app/task/[id]/page-client.tsx +++ b/apps/web/app/task/[id]/page-client.tsx @@ -1,51 +1,45 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import '@git-diff-view/react/styles/diff-view.css'; import { TooltipProvider } from '@kandev/ui/tooltip'; import { TaskTopBar } from '@/components/task/task-top-bar'; import { TaskLayout } from '@/components/task/task-layout'; import { CommandApprovalDialog } from '@/components/task/command-approval-dialog'; import { DebugOverlay } from '@/components/debug-overlay'; -import type { Repository, Task, TaskSession } from '@/lib/types/http'; +import type { Repository, Task } from '@/lib/types/http'; import { DEBUG_UI } from '@/lib/config'; -import { getWebSocketClient } from '@/lib/ws/connection'; import { useRepositories } from '@/hooks/use-repositories'; import { useTaskAgent } from '@/hooks/use-task-agent'; -import { useTaskMessages } from '@/hooks/use-task-messages'; import { useSessionResumption } from '@/hooks/use-session-resumption'; +import { useSessionAgentctl } from '@/hooks/use-session-agentctl'; import { useAppStore } from '@/components/state-provider'; -import { fetchTask, listTaskSessions } from '@/lib/http'; -import { linkToTaskSession } from '@/lib/links'; +import { fetchTask } from '@/lib/http'; +import { useTasks } from '@/hooks/use-tasks'; type TaskPageClientProps = { task: Task | null; sessionId?: string | null; - initialSessionsByTask?: Record; initialRepositories?: Repository[]; - initialAgentProfiles?: Array<{ id: string; label: string; agent_id: string }>; }; export default function TaskPage({ task: initialTask, sessionId = null, - initialSessionsByTask = {}, initialRepositories = [], - initialAgentProfiles = [], }: TaskPageClientProps) { - const [activeTaskOverride, setActiveTaskOverride] = useState(null); - const [activeSessionOverride, setActiveSessionOverride] = useState(null); + const [taskDetails, setTaskDetails] = useState(initialTask); const [isMounted, setIsMounted] = useState(false); - const effectiveTaskId = activeTaskOverride?.id ?? initialTask?.id ?? null; + const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); + const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); + const setActiveSession = useAppStore((state) => state.setActiveSession); + const setActiveTask = useAppStore((state) => state.setActiveTask); + const effectiveTaskId = activeTaskId ?? initialTask?.id ?? null; const kanbanTask = useAppStore((state) => effectiveTaskId ? state.kanban.tasks.find((item) => item.id === effectiveTaskId) ?? null : null ); - const boardTasks = useAppStore((state) => state.kanban.tasks); - const boardColumns = useAppStore((state) => state.kanban.columns); - const workspaces = useAppStore((state) => state.workspaces.items); - const agentProfiles = useAppStore((state) => state.agentProfiles.items); const task = useMemo(() => { - const baseTask = activeTaskOverride ?? initialTask; + const baseTask = taskDetails ?? initialTask; if (!baseTask) return null; if (!kanbanTask) return baseTask; return { @@ -57,18 +51,10 @@ export default function TaskPage({ state: (kanbanTask.state as Task['state'] | undefined) ?? baseTask.state, repositories: baseTask.repositories, }; - }, [activeTaskOverride, initialTask, kanbanTask]); + }, [taskDetails, initialTask, kanbanTask]); + useTasks(task?.board_id ?? null); const connectionStatus = useAppStore((state) => state.connection.status); - // Session resumption hook - handles auto-resume on page reload - const { - resumptionState, - error: resumptionError, - taskSessionState: resumedSessionState, - worktreePath: resumedWorktreePath, - worktreeBranch: resumedWorktreeBranch, - } = useSessionResumption(task?.id ?? null, sessionId); - // Regular task agent hook for non-resumed sessions const { isAgentRunning, @@ -81,227 +67,64 @@ export default function TaskPage({ handleStopAgent, } = useTaskAgent(task); + const initialSessionId = sessionId ?? taskSessionId ?? null; + const effectiveSessionId = activeSessionId ?? initialSessionId; + const agentctlStatus = useSessionAgentctl(effectiveSessionId); + + // Session resumption hook - handles auto-resume on page reload + const { + resumptionState, + error: resumptionError, + taskSessionState: resumedSessionState, + worktreePath: resumedWorktreePath, + worktreeBranch: resumedWorktreeBranch, + } = useSessionResumption(task?.id ?? null, effectiveSessionId); + // Merge state from resumption and regular agent hooks - const activeSessionId = activeSessionOverride ?? sessionId ?? taskSessionId; const isResuming = resumptionState === 'checking' || resumptionState === 'resuming'; const isResumed = resumptionState === 'resumed' || resumptionState === 'running'; // Both hooks read taskSessionState from the store, so prefer resumedSessionState as it's always // updated when we have a sessionId from URL. For worktree, use resumed values when resuming/resumed. const taskSessionState = resumedSessionState ?? agentSessionState; - const worktreePath = sessionId ? (resumedWorktreePath ?? agentWorktreePath) : agentWorktreePath; - const worktreeBranch = sessionId ? (resumedWorktreeBranch ?? agentWorktreeBranch) : agentWorktreeBranch; + const worktreePath = effectiveSessionId ? (resumedWorktreePath ?? agentWorktreePath) : agentWorktreePath; + const worktreeBranch = effectiveSessionId ? (resumedWorktreeBranch ?? agentWorktreeBranch) : agentWorktreeBranch; // Derive working state for debug overlay const isAgentWorking = taskSessionState !== null ? taskSessionState === 'STARTING' || taskSessionState === 'RUNNING' : isAgentRunning && (task?.state === 'IN_PROGRESS' || task?.state === 'SCHEDULING'); - useTaskMessages(task?.id ?? null, activeSessionId); - const [sessionsByTask, setSessionsByTask] = useState>( - initialSessionsByTask - ); + // Messages are loaded inside TaskChatPanel for the active session. useEffect(() => { queueMicrotask(() => setIsMounted(true)); }, []); + + useEffect(() => { + if (!initialTask?.id) return; + if (activeTaskId && activeTaskId !== initialTask.id) return; + if (initialSessionId) { + setActiveSession(initialTask.id, initialSessionId); + } else { + setActiveTask(initialTask.id); + } + }, [activeTaskId, initialSessionId, initialTask?.id, setActiveSession, setActiveTask]); + + useEffect(() => { + if (!activeTaskId) return; + if (taskDetails?.id === activeTaskId) return; + fetchTask(activeTaskId, { cache: 'no-store' }) + .then((response) => setTaskDetails(response)) + .catch((error) => console.error('Failed to load task details:', error)); + }, [activeTaskId, taskDetails?.id]); const { repositories } = useRepositories(task?.workspace_id ?? null, Boolean(task?.workspace_id)); const effectiveRepositories = repositories.length ? repositories : initialRepositories; - const repositoryPathsById = useMemo( - () => Object.fromEntries(effectiveRepositories.map((repo) => [repo.id, repo.local_path])), - [effectiveRepositories] - ); const repository = useMemo( () => effectiveRepositories.find((item) => item.id === task?.repositories?.[0]?.repository_id) ?? null, [effectiveRepositories, task?.repositories] ); - const handleSendMessage = useCallback(async (content: string) => { - if (!task?.id) return; - - const client = getWebSocketClient(); - if (!client) return; - - if (!activeSessionId) { - console.error('No active agent session. Start an agent before sending a message.'); - return; - } - - try { - await client.request( - 'message.add', - { task_id: task.id, task_session_id: activeSessionId, content }, - 10000 - ); - } catch (error) { - console.error('Failed to send message:', error); - } - }, [activeSessionId, task]); - - const updateUrl = useCallback((taskId: string, sessionIdToOpen: string) => { - if (typeof window === 'undefined') return; - window.history.replaceState({}, '', linkToTaskSession(taskId, sessionIdToOpen)); - }, []); - - const handleSelectSession = useCallback( - (taskId: string, sessionIdToOpen: string) => { - setActiveSessionOverride(sessionIdToOpen); - updateUrl(taskId, sessionIdToOpen); - }, - [updateUrl] - ); - - const handleLoadTaskSessions = useCallback(async (taskId: string) => { - try { - const response = await listTaskSessions(taskId, { cache: 'no-store' }); - setSessionsByTask((prev) => ({ ...prev, [taskId]: response.sessions })); - } catch (error) { - console.error('Failed to load task sessions:', error); - } - }, []); - - const handleSelectTask = useCallback( - async (taskId: string, sessionId: string | null) => { - try { - const nextTask = await fetchTask(taskId, { cache: 'no-store' }); - setActiveTaskOverride(nextTask); - } catch (error) { - console.error('Failed to load task details:', error); - } - - let targetSessionId = sessionId; - if (!targetSessionId) { - try { - const response = await listTaskSessions(taskId, { cache: 'no-store' }); - targetSessionId = response.sessions[0]?.id ?? null; - } catch (error) { - console.error('Failed to load task sessions for navigation:', error); - } - } - - if (!targetSessionId) { - setActiveSessionOverride(null); - return; - } - - setActiveSessionOverride(targetSessionId); - updateUrl(taskId, targetSessionId); - }, - [updateUrl] - ); - - const handleCreateSessionForTask = useCallback( - async ( - taskId: string, - data: { prompt: string; agentProfileId: string; executorId: string; environmentId: string } - ) => { - const client = getWebSocketClient(); - if (!client) return; - - try { - const response = await client.request<{ - success: boolean; - task_id: string; - agent_instance_id: string; - task_session_id?: string; - state: string; - }>( - 'orchestrator.start', - { task_id: taskId, agent_profile_id: data.agentProfileId }, - 15000 - ); - - if (response?.task_session_id && data.prompt.trim()) { - await client.request( - 'message.add', - { task_id: taskId, task_session_id: response.task_session_id, content: data.prompt.trim() }, - 10000 - ); - } - - await handleLoadTaskSessions(taskId); - - if (response?.task_session_id) { - setActiveSessionOverride(response.task_session_id); - updateUrl(taskId, response.task_session_id); - } - } catch (error) { - console.error('Failed to create task session:', error); - } - }, - [handleLoadTaskSessions, updateUrl] - ); - - const workspaceName = useMemo(() => { - if (!task?.workspace_id) return 'Workspace'; - return workspaces.find((workspace) => workspace.id === task.workspace_id)?.name ?? 'Workspace'; - }, [task, workspaces]); - - const effectiveAgentProfiles = agentProfiles.length ? agentProfiles : initialAgentProfiles; - const agentLabelsById = useMemo(() => { - return Object.fromEntries(effectiveAgentProfiles.map((profile) => [profile.id, profile.label])); - }, [effectiveAgentProfiles]); - - const tasksWithRepositories = useMemo( - () => - boardTasks.map((taskItem) => ({ - ...taskItem, - repositoryPath: taskItem.repositoryId ? repositoryPathsById[taskItem.repositoryId] : undefined, - })), - [boardTasks, repositoryPathsById] - ); - - useEffect(() => { - if (!task?.id) return; - if (sessionsByTask[task.id]) return; - // eslint-disable-next-line react-hooks/set-state-in-effect - handleLoadTaskSessions(task.id); - }, [handleLoadTaskSessions, sessionsByTask, task?.id]); - - useEffect(() => { - const client = getWebSocketClient(); - if (!client) return; - - const taskIds = boardTasks.map((item) => item.id); - taskIds.forEach((taskId) => client.subscribe(taskId)); - - return () => { - taskIds.forEach((taskId) => client.unsubscribe(taskId)); - }; - }, [boardTasks]); - - useEffect(() => { - const client = getWebSocketClient(); - if (!client) return; - - const pending = new Set(); - const unsubscribe = client.on('task_session.state_changed', (message) => { - const taskId = message.payload?.task_id; - const sessionId = message.payload?.task_session_id; - const payload = message.payload as Record | undefined; - const nextState = (payload?.new_state ?? payload?.state) as TaskSession['state'] | undefined; - if (taskId && sessionId && nextState) { - setSessionsByTask((prev) => { - const sessions = prev[taskId]; - if (!sessions) return prev; - const nextSessions = sessions.map((session) => - session.id === sessionId ? { ...session, state: nextState } : session - ); - return { ...prev, [taskId]: nextSessions }; - }); - } - if (!taskId || pending.has(taskId)) return; - pending.add(taskId); - setTimeout(() => { - pending.delete(taskId); - handleLoadTaskSessions(taskId); - }, 250); - }); - - return () => { - unsubscribe(); - }; - }, [handleLoadTaskSessions]); if (!isMounted) { return
; @@ -316,18 +139,22 @@ export default function TaskPage({ entries={{ ws_status: connectionStatus, task_id: task?.id ?? null, - task_session_id: activeSessionId ?? null, + session_id: effectiveSessionId ?? null, task_state: task?.state ?? null, task_session_state: taskSessionState ?? null, is_agent_working: isAgentWorking, resumption_state: resumptionState, resumption_error: resumptionError, + agentctl_status: agentctlStatus.status, + agentctl_ready: agentctlStatus.isReady, + agentctl_error: agentctlStatus.errorMessage ?? null, + agentctl_execution_id: agentctlStatus.agentExecutionId ?? null, }} /> )} ({ id: column.id, title: column.title }))} workspaceId={task?.workspace_id ?? null} boardId={task?.board_id ?? null} - workspaceName={workspaceName} - agentLabelsById={agentLabelsById} - activeSessionId={activeSessionId ?? null} - sessionsByTask={sessionsByTask} - onSelectSession={handleSelectSession} - onLoadTaskSessions={handleLoadTaskSessions} - onSelectTask={handleSelectTask} - onCreateSession={handleCreateSessionForTask} /> {/* Fallback dialog for permissions without matching tool call (e.g., workspace indexing) */} diff --git a/apps/web/app/task/[id]/page.tsx b/apps/web/app/task/[id]/page.tsx deleted file mode 100644 index e8a3acd800..0000000000 --- a/apps/web/app/task/[id]/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { fetchTask, listTaskSessions } from '@/lib/http'; -import { redirect } from 'next/navigation'; -import { linkToTaskSession } from '@/lib/links'; - -export default async function TaskPage({ params }: { params: Promise<{ id: string }> }) { - try { - const { id } = await params; - const task = await fetchTask(id, { cache: 'no-store' }); - const sessions = await listTaskSessions(id, { cache: 'no-store' }); - if (sessions.sessions.length > 0) { - redirect(linkToTaskSession(id, sessions.sessions[0].id)); - } - redirect(`/?boardId=${task.board_id}`); - } catch { - redirect('/'); - } - return null; -} diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index dfadf14abb..1ba59e57eb 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -16,7 +16,7 @@ import { useTaskActions } from '@/hooks/use-task-actions'; import { KanbanBoardHeader } from './kanban-board-header'; import { KanbanBoardGrid } from './kanban-board-grid'; import { getWebSocketClient } from '@/lib/ws/connection'; -import { linkToTaskSession } from '@/lib/links'; +import { linkToSession } from '@/lib/links'; interface KanbanBoardProps { onPreviewTask?: (task: Task) => void; @@ -236,7 +236,7 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {} if (onOpenTask) { onOpenTask(task, latestSessionId); } else { - router.push(linkToTaskSession(task.id, latestSessionId)); + router.push(linkToSession(latestSessionId)); } }; diff --git a/apps/web/components/kanban-with-preview.tsx b/apps/web/components/kanban-with-preview.tsx index d1c5530b5c..3b352bbed3 100644 --- a/apps/web/components/kanban-with-preview.tsx +++ b/apps/web/components/kanban-with-preview.tsx @@ -9,7 +9,7 @@ import { useAppStore } from "@/components/state-provider"; import { Task } from "./kanban-card"; import { PREVIEW_PANEL } from "@/lib/settings/constants"; import { getWebSocketClient } from "@/lib/ws/connection"; -import { linkToTaskSession } from "@/lib/links"; +import { linkToSession } from "@/lib/links"; type KanbanWithPreviewProps = { initialTaskId?: string; @@ -139,7 +139,7 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit const handleNavigateToTask = useCallback( (task: Task, sessionId: string) => { close(); - router.push(linkToTaskSession(task.id, sessionId)); + router.push(linkToSession(sessionId)); }, [close, router] ); diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index 75c21f0083..0e9f15a9e4 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -41,7 +41,6 @@ import { useSettingsData } from '@/hooks/use-settings-data'; import { SHORTCUTS } from '@/lib/keyboard/constants'; import { useKeyboardShortcutHandler } from '@/hooks/use-keyboard-shortcut'; import { KeyboardShortcutTooltip } from '@/components/keyboard-shortcut-tooltip'; -import { getWebSocketClient } from '@/lib/ws/connection'; import { useToast } from '@/components/toast-provider'; import { discoverRepositoriesAction, listLocalRepositoryBranchesAction } from '@/app/actions/workspaces'; @@ -54,7 +53,7 @@ interface TaskCreateDialogProps { defaultColumnId: string | null; columns: Array<{ id: string; title: string }>; editingTask?: { id: string; title: string; description?: string; columnId: string; state?: Task['state'] } | null; - onSuccess?: (task: Task, mode: 'create' | 'edit') => void; + onSuccess?: (task: Task, mode: 'create' | 'edit', meta?: { taskSessionId?: string | null }) => void; onCreateSession?: (data: { prompt: string; agentProfileId: string; @@ -511,7 +510,7 @@ export function TaskCreateDialog({ targetState = 'IN_PROGRESS'; } try { - const task = await createTask({ + const taskResponse = await createTask({ workspace_id: workspaceId, board_id: boardId, column_id: targetColumnId, @@ -535,31 +534,17 @@ export function TaskCreateDialog({ ] : [], state: targetState, + start_agent: Boolean(startAgent && !isEditMode && agentProfileId), + agent_profile_id: startAgent ? agentProfileId || undefined : undefined, }); console.log('[TaskCreateDialog] task created', { - taskId: task.id, + taskId: taskResponse.id, startAgent, agentProfileId, }); - if (startAgent && agentProfileId) { - const client = getWebSocketClient(); - if (client) { - try { - console.log('[TaskCreateDialog] orchestrator.start request', { - taskId: task.id, - agentProfileId, - }); - await client.request( - 'orchestrator.start', - { task_id: task.id, agent_profile_id: agentProfileId }, - 15000 - ); - } catch (error) { - console.error('Failed to start agent for new task:', error); - } - } - } - onSuccess?.(task, 'create'); + onSuccess?.(taskResponse, 'create', { + taskSessionId: taskResponse.session_id ?? null, + }); } finally { setTitle(''); setDescription(''); diff --git a/apps/web/components/task-preview-panel.tsx b/apps/web/components/task-preview-panel.tsx index daecc5e8de..03d9c1ba34 100644 --- a/apps/web/components/task-preview-panel.tsx +++ b/apps/web/components/task-preview-panel.tsx @@ -37,7 +37,7 @@ export function TaskPreviewPanel({ task, sessionId = null, onClose, onMaximize } try { await client.request( "message.add", - { task_id: task.id, task_session_id: activeSessionId, content }, + { task_id: task.id, session_id: activeSessionId, content }, 10000 ); } catch (error) { diff --git a/apps/web/components/task/chat/messages/tool-call-message.tsx b/apps/web/components/task/chat/messages/tool-call-message.tsx index 4795777949..968fc013e9 100644 --- a/apps/web/components/task/chat/messages/tool-call-message.tsx +++ b/apps/web/components/task/chat/messages/tool-call-message.tsx @@ -65,6 +65,7 @@ export function ToolCallMessage({ comment, taskId }: ToolCallMessageProps) { const metadata = comment.metadata as ToolCallMetadata | undefined; const pendingPermissions = useAppStore((state) => state.permissions.pending); const removePendingPermission = useAppStore((state) => state.removePendingPermission); + const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const toolCallId = metadata?.tool_call_id; const toolName = metadata?.tool_name ?? ''; @@ -75,7 +76,11 @@ export function ToolCallMessage({ comment, taskId }: ToolCallMessageProps) { // Find pending permission for this tool call (only if we have a valid tool_call_id) const pendingPermission = pendingPermissions.find( - (p) => toolCallId && p.tool_call_id === toolCallId && (!taskId || p.task_id === taskId) + (p) => + toolCallId && + p.tool_call_id === toolCallId && + (!activeSessionId || p.session_id === activeSessionId) && + (!taskId || p.task_id === taskId) ); const isPendingApproval = !!pendingPermission; @@ -93,7 +98,7 @@ export function ToolCallMessage({ comment, taskId }: ToolCallMessageProps) { try { await client.request('permission.respond', { - task_id: pendingPermission.task_id, + session_id: pendingPermission.session_id, pending_id: pendingPermission.pending_id, option_id: cancelled ? undefined : optionId, cancelled, diff --git a/apps/web/components/task/command-approval-dialog.tsx b/apps/web/components/task/command-approval-dialog.tsx index 946739603c..b3e2b06b2c 100644 --- a/apps/web/components/task/command-approval-dialog.tsx +++ b/apps/web/components/task/command-approval-dialog.tsx @@ -14,6 +14,9 @@ import { } from '@kandev/ui/alert-dialog'; import { useAppStore } from '@/components/state-provider'; import { getWebSocketClient } from '@/lib/ws/connection'; +import type { Message } from '@/lib/types/http'; + +const EMPTY_MESSAGES: Message[] = []; type CommandApprovalDialogProps = { taskId: string; @@ -25,7 +28,10 @@ export function CommandApprovalDialog({ taskId, standaloneOnly = false }: Comman const [isResponding, setIsResponding] = useState(false); const pendingPermissions = useAppStore((state) => state.permissions.pending); const removePendingPermission = useAppStore((state) => state.removePendingPermission); - const messages = useAppStore((state) => state.messages.items); + const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); + const messages = useAppStore((state) => + activeSessionId ? state.messages.bySession[activeSessionId] ?? EMPTY_MESSAGES : EMPTY_MESSAGES + ); // Get tool_call_ids from all tool call messages const toolCallIds = useMemo(() => { @@ -43,13 +49,17 @@ export function CommandApprovalDialog({ taskId, standaloneOnly = false }: Comman // If standaloneOnly, filter out permissions that have a matching tool call const permission = useMemo(() => { return pendingPermissions.find((p) => { - if (p.task_id !== taskId) return false; + if (activeSessionId) { + if (p.session_id !== activeSessionId) return false; + } else if (p.task_id !== taskId) { + return false; + } if (standaloneOnly && p.tool_call_id && toolCallIds.has(p.tool_call_id)) { return false; // This permission will be handled by inline UI } return true; }); - }, [pendingPermissions, taskId, standaloneOnly, toolCallIds]); + }, [pendingPermissions, activeSessionId, taskId, standaloneOnly, toolCallIds]); const handleRespond = useCallback( async (optionId: string, cancelled: boolean = false) => { @@ -65,7 +75,7 @@ export function CommandApprovalDialog({ taskId, standaloneOnly = false }: Comman try { await client.request('permission.respond', { - task_id: taskId, + session_id: permission.session_id, pending_id: permission.pending_id, option_id: cancelled ? undefined : optionId, cancelled, @@ -77,7 +87,7 @@ export function CommandApprovalDialog({ taskId, standaloneOnly = false }: Comman setIsResponding(false); } }, - [permission, taskId, removePendingPermission] + [permission, removePendingPermission] ); const handleApprove = useCallback(() => { diff --git a/apps/web/components/task/file-browser.tsx b/apps/web/components/task/file-browser.tsx index 4b20c51341..70539884fb 100644 --- a/apps/web/components/task/file-browser.tsx +++ b/apps/web/components/task/file-browser.tsx @@ -5,14 +5,16 @@ import { IconChevronRight, IconChevronDown, IconFile, IconFolder, IconFolderOpen import { ScrollArea } from '@kandev/ui/scroll-area'; import { getWebSocketClient } from '@/lib/ws/connection'; import { requestFileTree, requestFileContent } from '@/lib/ws/workspace-files'; +import { useSessionAgentctl } from '@/hooks/use-session-agentctl'; import type { FileTreeNode, FileContentResponse, OpenFileTab } from '@/lib/types/backend'; type FileBrowserProps = { - taskId: string; + sessionId: string; onOpenFile: (file: OpenFileTab) => void; }; -export function FileBrowser({ taskId, onOpenFile }: FileBrowserProps) { +export function FileBrowser({ sessionId, onOpenFile }: FileBrowserProps) { + const { isReady } = useSessionAgentctl(sessionId); const [tree, setTree] = useState(null); const [expandedPaths, setExpandedPaths] = useState>(new Set()); const [loadingPaths, setLoadingPaths] = useState>(new Set()); @@ -20,12 +22,19 @@ export function FileBrowser({ taskId, onOpenFile }: FileBrowserProps) { // Load initial tree useEffect(() => { + setTree(null); + setExpandedPaths(new Set()); + setLoadingPaths(new Set()); + setIsLoadingTree(true); + if (!isReady) { + return; + } const loadTree = async () => { try { const client = getWebSocketClient(); if (!client) return; - const response = await requestFileTree(client, taskId, '', 1); + const response = await requestFileTree(client, sessionId, '', 1); setTree(response.root); } catch (error) { console.error('Failed to load file tree:', error); @@ -35,17 +44,18 @@ export function FileBrowser({ taskId, onOpenFile }: FileBrowserProps) { }; loadTree(); - }, [taskId]); + }, [isReady, sessionId]); // Subscribe to file changes and refresh tree useEffect(() => { + if (!isReady) return; const client = getWebSocketClient(); if (!client) return; - const unsubscribe = client.on('workspace.file.changes', async () => { + const unsubscribe = client.on('session.workspace.file.changes', async () => { // Refresh the root tree when any file changes try { - const response = await requestFileTree(client, taskId, '', 1); + const response = await requestFileTree(client, sessionId, '', 1); setTree(response.root); } catch (error) { console.error('Failed to refresh file tree:', error); @@ -53,7 +63,7 @@ export function FileBrowser({ taskId, onOpenFile }: FileBrowserProps) { }); return unsubscribe; - }, [taskId]); + }, [isReady, sessionId]); const toggleExpand = useCallback(async (node: FileTreeNode) => { if (!node.is_dir) return; @@ -71,7 +81,7 @@ export function FileBrowser({ taskId, onOpenFile }: FileBrowserProps) { const client = getWebSocketClient(); if (!client) return; - const response = await requestFileTree(client, taskId, node.path, 1); + const response = await requestFileTree(client, sessionId, node.path, 1); // Update tree with new children const updateNode = (n: FileTreeNode): FileTreeNode => { @@ -101,16 +111,17 @@ export function FileBrowser({ taskId, onOpenFile }: FileBrowserProps) { newExpanded.add(node.path); setExpandedPaths(newExpanded); } - }, [expandedPaths, taskId, tree]); + }, [expandedPaths, sessionId, tree]); const openFile = useCallback(async (node: FileTreeNode) => { if (node.is_dir) return; + if (!isReady) return; try { const client = getWebSocketClient(); if (!client) return; - const response: FileContentResponse = await requestFileContent(client, taskId, node.path); + const response: FileContentResponse = await requestFileContent(client, sessionId, node.path); onOpenFile({ path: node.path, @@ -120,7 +131,7 @@ export function FileBrowser({ taskId, onOpenFile }: FileBrowserProps) { } catch (error) { console.error('Failed to load file content:', error); } - }, [taskId, onOpenFile]); + }, [isReady, sessionId, onOpenFile]); const renderTreeNode = (node: FileTreeNode, depth: number = 0): React.ReactNode => { const isExpanded = expandedPaths.has(node.path); diff --git a/apps/web/components/task/sessions-dropdown.tsx b/apps/web/components/task/sessions-dropdown.tsx index 20938e6b83..1e6674a1fc 100644 --- a/apps/web/components/task/sessions-dropdown.tsx +++ b/apps/web/components/task/sessions-dropdown.tsx @@ -23,7 +23,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { TaskCreateDialog } from '../task-create-dialog'; import { useAppStore } from '@/components/state-provider'; import { getWebSocketClient } from '@/lib/ws/connection'; -import { linkToTaskSession } from '@/lib/links'; +import { linkToSession } from '@/lib/links'; type SessionStatus = 'running' | 'waiting_input' | 'complete' | 'failed' | 'cancelled'; @@ -184,10 +184,10 @@ export function SessionsDropdown({ taskId, activeSessionId = null, taskTitle = ' try { await client.request('task.session.resume', { task_id: taskId, - task_session_id: sessionId, + session_id: sessionId, }, 15000); await fetchSessions(taskId); - router.push(linkToTaskSession(taskId, sessionId)); + router.push(linkToSession(sessionId)); } catch (error) { console.error('Failed to resume task session:', error); } diff --git a/apps/web/components/task/shell-terminal.tsx b/apps/web/components/task/shell-terminal.tsx index add0b0fc83..bec6b2d236 100644 --- a/apps/web/components/task/shell-terminal.tsx +++ b/apps/web/components/task/shell-terminal.tsx @@ -6,13 +6,10 @@ import { FitAddon } from '@xterm/addon-fit'; import '@xterm/xterm/css/xterm.css'; import { useAppStore, useAppStoreApi } from '@/components/state-provider'; import { getWebSocketClient } from '@/lib/ws/connection'; +import { useSession } from '@/hooks/use-session'; +import { useSessionAgentctl } from '@/hooks/use-session-agentctl'; -type ShellTerminalProps = { - taskId: string; - sessionId: string | null; -}; - -export function ShellTerminal({ taskId, sessionId }: ShellTerminalProps) { +export function ShellTerminal() { const terminalRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); @@ -21,15 +18,14 @@ export function ShellTerminal({ taskId, sessionId }: ShellTerminalProps) { const onDataDisposableRef = useRef<{ dispose: () => void } | null>(null); const storeApi = useAppStoreApi(); - const shellOutput = useAppStore((state) => state.shell.outputs[taskId] || ''); - const sessionState = useAppStore((state) => { - if (sessionId) { - return state.taskSessions.items[sessionId]?.state ?? null; - } - return state.taskSessionStatesByTaskId[taskId] ?? null; - }); - const canSubscribe = - Boolean(sessionId) && (sessionState === 'RUNNING' || sessionState === 'WAITING_FOR_INPUT'); + const sessionId = useAppStore((state) => state.tasks.activeSessionId); + const { session, isActive } = useSession(sessionId); + const { isReady: isAgentctlReady } = useSessionAgentctl(sessionId); + const taskId = session?.task_id ?? null; + const shellOutput = useAppStore((state) => + sessionId ? state.shell.outputs[sessionId] || '' : '' + ); + const canSubscribe = Boolean(sessionId && isActive && isAgentctlReady); const send = useCallback( (action: string, payload: Record) => { @@ -101,10 +97,10 @@ export function ShellTerminal({ taskId, sessionId }: ShellTerminalProps) { onDataDisposableRef.current?.dispose(); onDataDisposableRef.current = null; - if (!sessionId) return; + if (!taskId || !sessionId) return; onDataDisposableRef.current = xtermRef.current.onData((data) => { - send('shell.input', { task_id: taskId, session_id: sessionId, data }); + send('shell.input', { session_id: sessionId, data }); }); return () => { @@ -125,20 +121,14 @@ export function ShellTerminal({ taskId, sessionId }: ShellTerminalProps) { } }, [shellOutput]); - // Subscribe to shell on mount to start the shell stream - // Uses retry logic to handle cases where agent isn't ready yet (e.g., session resumption) + // Subscribe to shell once agentctl is ready. useEffect(() => { - if (!canSubscribe) return; + if (!taskId || !sessionId || !canSubscribe) return; - // Increment subscription ID to invalidate any pending requests const currentSubscriptionId = ++subscriptionIdRef.current; - let retryCount = 0; - const maxRetries = 10; - const retryDelay = 1000; // 1 second between retries - let retryTimeout: ReturnType | null = null; // Clear any stale output before subscribing to get fresh buffer - storeApi.getState().clearShellOutput(taskId); + storeApi.getState().clearShellOutput(sessionId); lastOutputLengthRef.current = 0; // Also clear the terminal display @@ -146,44 +136,24 @@ export function ShellTerminal({ taskId, sessionId }: ShellTerminalProps) { xtermRef.current.clear(); } - const attemptSubscribe = () => { - const client = getWebSocketClient(); - if (!client) return; + const client = getWebSocketClient(); + if (!client) return; - // Check if this subscription is still valid - if (subscriptionIdRef.current !== currentSubscriptionId) return; - - client - .request<{ success: boolean; buffer?: string }>('shell.subscribe', { task_id: taskId, session_id: sessionId }) - .then((response) => { - // Only process if this is still the current subscription (handles React Strict Mode) - if (subscriptionIdRef.current !== currentSubscriptionId) return; - - // Write buffered output from response - if (response.buffer) { - storeApi.getState().appendShellOutput(taskId, response.buffer); - } - }) - .catch((err) => { - if (subscriptionIdRef.current !== currentSubscriptionId) return; - - // Retry if agent not ready yet (common during session resumption) - if (retryCount < maxRetries) { - retryCount++; - console.log(`[ShellTerminal] Retrying shell subscription (${retryCount}/${maxRetries})...`); - retryTimeout = setTimeout(attemptSubscribe, retryDelay); - } else { - console.error('Failed to subscribe to shell after retries:', err); - } - }); - }; - - attemptSubscribe(); + client + .request<{ success: boolean; buffer?: string }>('shell.subscribe', { session_id: sessionId }) + .then((response) => { + if (subscriptionIdRef.current !== currentSubscriptionId) return; + if (response.buffer) { + storeApi.getState().appendShellOutput(sessionId, response.buffer); + } + }) + .catch((err) => { + if (subscriptionIdRef.current !== currentSubscriptionId) return; + console.error('Failed to subscribe to shell:', err); + }); return () => { - if (retryTimeout) { - clearTimeout(retryTimeout); - } + subscriptionIdRef.current += 1; }; }, [taskId, sessionId, storeApi, canSubscribe]); diff --git a/apps/web/components/task/task-left-panel.tsx b/apps/web/components/task/task-center-panel.tsx similarity index 91% rename from apps/web/components/task/task-left-panel.tsx rename to apps/web/components/task/task-center-panel.tsx index adacd4769b..37c3a5e2ee 100644 --- a/apps/web/components/task/task-left-panel.tsx +++ b/apps/web/components/task/task-center-panel.tsx @@ -9,32 +9,27 @@ import { TaskChangesPanel } from './task-changes-panel'; import { FileViewerContent } from './file-viewer-content'; import type { OpenFileTab } from '@/lib/types/backend'; import { FILE_EXTENSION_COLORS } from '@/lib/types/backend'; -import { useTaskChatSession } from '@/hooks/use-task-chat-session'; +import { useAppStore } from '@/components/state-provider'; const AGENTS = [ { id: 'codex', label: 'Codex' }, { id: 'claude', label: 'Claude Code' }, ]; -type TaskLeftPanelProps = { - taskId: string | null; - onSendMessage: (content: string) => Promise; +type TaskCenterPanelProps = { selectedDiffPath: string | null; openFileRequest: OpenFileTab | null; onDiffPathHandled: () => void; onFileOpenHandled: () => void; }; -export const TaskLeftPanel = memo(function TaskLeftPanel({ - taskId, - onSendMessage, +export const TaskCenterPanel = memo(function TaskCenterPanel({ selectedDiffPath: externalSelectedDiffPath, openFileRequest, onDiffPathHandled, onFileOpenHandled, -}: TaskLeftPanelProps) { - // Get session ID from task ID - const { taskSessionId } = useTaskChatSession(taskId); +}: TaskCenterPanelProps) { + const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); const [leftTab, setLeftTab] = useState('chat'); const [selectedDiffPath, setSelectedDiffPath] = useState(null); const [notes, setNotes] = useState(''); @@ -156,12 +151,8 @@ export const TaskLeftPanel = memo(function TaskLeftPanel({ - {taskId ? ( - + {activeTaskId ? ( + ) : (
No task selected diff --git a/apps/web/components/task/task-changes-panel.tsx b/apps/web/components/task/task-changes-panel.tsx index e87237e8f7..a987b3f38b 100644 --- a/apps/web/components/task/task-changes-panel.tsx +++ b/apps/web/components/task/task-changes-panel.tsx @@ -10,6 +10,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { cn } from '@/lib/utils'; import { getLocalStorage, setLocalStorage } from '@/lib/local-storage'; import { useAppStore } from '@/components/state-provider'; +import { useSessionGitStatus } from '@/hooks/use-session-git-status'; type TaskChangesPanelProps = { selectedDiffPath: string | null; @@ -26,22 +27,23 @@ const TaskChangesPanel = memo(function TaskChangesPanel({ getLocalStorage('task-diff-view-mode', DEFAULT_DIFF_MODE) ); const { resolvedTheme } = useTheme(); - const gitStatus = useAppStore((state) => state.gitStatus); + const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); + const gitStatus = useSessionGitStatus(activeSessionId); // Convert git status files to array for display const changedFiles = useMemo(() => { - if (!gitStatus.files || Object.keys(gitStatus.files).length === 0) { + if (!gitStatus?.files || Object.keys(gitStatus.files).length === 0) { return []; } return Object.values(gitStatus.files); - }, [gitStatus.files]); + }, [gitStatus]); const diffTargets = useMemo( () => (selectedDiffPath ? [selectedDiffPath] : changedFiles.map((file) => file.path)), [selectedDiffPath, changedFiles] ); const diffModeEnum = diffViewMode === 'split' ? DiffModeEnum.Split : DiffModeEnum.Unified; const diffTheme = resolvedTheme === 'dark' ? 'dark' : 'light'; - const selectedFile = selectedDiffPath ? gitStatus.files[selectedDiffPath] : null; + const selectedFile = selectedDiffPath && gitStatus ? gitStatus.files[selectedDiffPath] : null; const selectedDiffContent = selectedFile?.diff ?? ''; const isSingleDiffSelected = Boolean(selectedDiffPath && selectedFile); @@ -160,7 +162,7 @@ const TaskChangesPanel = memo(function TaskChangesPanel({ ) : (
{diffTargets.map((path) => { - const file = gitStatus.files[path]; + const file = gitStatus?.files?.[path]; if (!file || !file.diff) { return null; } diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index 4ccbf48c4c..b36f430bf3 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -15,13 +15,15 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/components/state-provider'; +import { getWebSocketClient } from '@/lib/ws/connection'; import type { Message } from '@/lib/types/http'; import { SHORTCUTS } from '@/lib/keyboard/constants'; import { KeyboardShortcutTooltip } from '@/components/keyboard-shortcut-tooltip'; import { TaskChatInput } from '@/components/task/task-chat-input'; import { useLazyLoadMessages } from '@/hooks/use-lazy-load-messages'; -import { useTaskSession } from '@/hooks/use-task-session'; -import { useTaskMessages } from '@/hooks/use-task-messages'; +import { useSession } from '@/hooks/use-session'; +import { useTask } from '@/hooks/use-task'; +import { useSessionMessages } from '@/hooks/use-session-messages'; import { MessageRenderer } from '@/components/task/chat/message-renderer'; import { RunningIndicator } from '@/components/task/chat/messages/running-indicator'; import { TodoSummary } from '@/components/task/chat/todo-summary'; @@ -33,14 +35,14 @@ type AgentOption = { type TaskChatPanelProps = { agents: AgentOption[]; - onSend: (message: string) => void; - sessionId: string | null; + onSend?: (message: string) => void; + sessionId?: string | null; }; export function TaskChatPanel({ agents, onSend, - sessionId, + sessionId = null, }: TaskChatPanelProps) { const [messageInput, setMessageInput] = useState(''); const [selectedAgent, setSelectedAgent] = useState(agents[0]?.id ?? ''); @@ -50,20 +52,21 @@ export function TaskChatPanel({ const lastAgentMessageCountRef = useRef(0); const wasAtBottomRef = useRef(true); + const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); + const resolvedSessionId = sessionId ?? activeSessionId; + // Get task session details and derived task information from session ID - const { session, taskId, taskDescription, isWorking } = useTaskSession(sessionId); + const { session } = useSession(resolvedSessionId); + const task = useTask(session?.task_id ?? null); + const taskId = session?.task_id ?? null; + const taskDescription = task?.description ?? null; + const isWorking = session?.state === 'STARTING' || session?.state === 'RUNNING'; // Fetch messages for this session - useTaskMessages(taskId, sessionId); - - const messagesState = useAppStore((state) => state.messages); - const messages = messagesState?.items ?? []; - const messagesLoading = messagesState?.isLoading ?? false; + const { messages, isLoading: messagesLoading } = useSessionMessages(resolvedSessionId); const isInitialLoading = messagesLoading && messages.length === 0; const showLoadingState = (messagesLoading || isInitialLoading) && !isWorking; - const { loadMore, hasMore, isLoading: isLoadingMore } = useLazyLoadMessages( - messagesState?.sessionId ?? null - ); + const { loadMore, hasMore, isLoading: isLoadingMore } = useLazyLoadMessages(resolvedSessionId); // Filter to only show chat-relevant types. const visibleMessages = messages.filter((message) => @@ -84,7 +87,7 @@ export function TaskChatPanel({ ? { id: 'task-description', task_id: taskId ?? '', - task_session_id: messagesState?.sessionId ?? '', + session_id: resolvedSessionId ?? '', author_type: 'user', content: taskDescription, type: 'message', @@ -169,6 +172,20 @@ export function TaskChatPanel({ }); }, [virtualItems, hasMore, isLoadingMore, loadMore]); + const handleSendMessage = useCallback(async (message: string) => { + if (!taskId || !resolvedSessionId) { + console.error('No active task session. Start an agent before sending a message.'); + return; + } + const client = getWebSocketClient(); + if (!client) return; + await client.request( + 'message.add', + { task_id: taskId, session_id: resolvedSessionId, content: message }, + 10000 + ); + }, [resolvedSessionId, taskId]); + const handleSubmit = async (event?: FormEvent) => { event?.preventDefault(); const trimmed = messageInput.trim(); @@ -178,7 +195,11 @@ export function TaskChatPanel({ setMessageInput(''); }); try { - await onSend(trimmed); + if (onSend) { + await onSend(trimmed); + } else { + await handleSendMessage(trimmed); + } } finally { setIsSending(false); } diff --git a/apps/web/components/task/task-files-panel.tsx b/apps/web/components/task/task-files-panel.tsx index 9f0e174789..71316218d7 100644 --- a/apps/web/components/task/task-files-panel.tsx +++ b/apps/web/components/task/task-files-panel.tsx @@ -13,6 +13,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kandev/ui/tabs'; import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { LineStat } from '@/components/diff-stat'; import { useAppStore } from '@/components/state-provider'; +import { useSessionGitStatus } from '@/hooks/use-session-git-status'; import type { FileInfo } from '@/lib/state/store'; import { FileBrowser } from '@/components/task/file-browser'; @@ -23,7 +24,6 @@ type OpenFileTab = { }; type TaskFilesPanelProps = { - taskId: string | null; onSelectDiffPath: (path: string) => void; onOpenFile: (file: OpenFileTab) => void; }; @@ -75,13 +75,14 @@ const splitPath = (path: string) => { }; }; -const TaskFilesPanel = memo(function TaskFilesPanel({ taskId, onSelectDiffPath, onOpenFile }: TaskFilesPanelProps) { +const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiffPath, onOpenFile }: TaskFilesPanelProps) { const [topTab, setTopTab] = useState<'diff' | 'files'>('diff'); - const gitStatus = useAppStore((state) => state.gitStatus); + const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); + const gitStatus = useSessionGitStatus(activeSessionId); // Convert git status files to array for display const changedFiles = useMemo(() => { - if (!gitStatus.files || Object.keys(gitStatus.files).length === 0) { + if (!gitStatus?.files || Object.keys(gitStatus.files).length === 0) { return []; } return Object.values(gitStatus.files).map((file) => ({ @@ -91,7 +92,7 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ taskId, onSelectDiffPath, minus: file.deletions, oldPath: file.old_path, })); - }, [gitStatus.files]); + }, [gitStatus]); return (
@@ -174,8 +175,8 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ taskId, onSelectDiffPath,
- {taskId ? ( - + {activeSessionId ? ( + ) : (
No task selected diff --git a/apps/web/components/task/task-layout.tsx b/apps/web/components/task/task-layout.tsx index d6e45c0f92..bf3208b437 100644 --- a/apps/web/components/task/task-layout.tsx +++ b/apps/web/components/task/task-layout.tsx @@ -1,58 +1,24 @@ 'use client'; -import { memo, useMemo, useState } from 'react'; +import { memo, useState } from 'react'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@kandev/ui/resizable'; import { getLocalStorage, setLocalStorage } from '@/lib/local-storage'; -import { TaskLeftPanel } from './task-left-panel'; +import { TaskCenterPanel } from './task-center-panel'; import { TaskRightPanel } from './task-right-panel'; import { TaskFilesPanel } from './task-files-panel'; import { TaskSessionSidebar } from './task-session-sidebar'; import type { OpenFileTab } from '@/lib/types/backend'; -import type { TaskSession, TaskState } from '@/lib/types/http'; const DEFAULT_HORIZONTAL_LAYOUT: [number, number, number] = [18, 57, 25]; type TaskLayoutProps = { - taskId: string | null; - sessionId: string | null; - onSendMessage: (content: string) => Promise; - tasks: Array<{ - id: string; - title: string; - state?: TaskState; - description?: string; - columnId?: string; - repositoryPath?: string; - }>; - columns: Array<{ id: string; title: string }>; workspaceId: string | null; boardId: string | null; - workspaceName: string; - agentLabelsById: Record; - activeSessionId: string | null; - sessionsByTask: Record; - onSelectSession: (taskId: string, sessionId: string) => void; - onLoadTaskSessions: (taskId: string) => void; - onSelectTask: (taskId: string, sessionId: string | null) => void; - onCreateSession: (taskId: string, data: { prompt: string; agentProfileId: string; executorId: string; environmentId: string }) => void; }; export const TaskLayout = memo(function TaskLayout({ - taskId, - sessionId, - onSendMessage, - tasks, - columns, workspaceId, boardId, - workspaceName, - agentLabelsById, - activeSessionId, - sessionsByTask, - onSelectSession, - onLoadTaskSessions, - onSelectTask, - onCreateSession, }: TaskLayoutProps) { const [horizontalLayout, setHorizontalLayout] = useState<[number, number, number]>( getLocalStorage('task-layout-horizontal-v2', DEFAULT_HORIZONTAL_LAYOUT) @@ -60,19 +26,6 @@ export const TaskLayout = memo(function TaskLayout({ const [selectedDiffPath, setSelectedDiffPath] = useState(null); const [openFileRequest, setOpenFileRequest] = useState(null); - const switcherTasks = useMemo( - () => - tasks.map((task) => ({ - id: task.id, - title: task.title, - state: task.state, - description: task.description, - columnId: task.columnId, - repositoryPath: task.repositoryPath, - })), - [tasks] - ); - const handleSelectDiffPath = (path: string) => { setSelectedDiffPath(path); }; @@ -83,7 +36,6 @@ export const TaskLayout = memo(function TaskLayout({ const topFilesPanel = ( @@ -101,26 +53,13 @@ export const TaskLayout = memo(function TaskLayout({ > - setSelectedDiffPath(null)} @@ -129,7 +68,7 @@ export const TaskLayout = memo(function TaskLayout({ - +
diff --git a/apps/web/components/task/task-right-panel.tsx b/apps/web/components/task/task-right-panel.tsx index 34b3a1b35b..1b220c95df 100644 --- a/apps/web/components/task/task-right-panel.tsx +++ b/apps/web/components/task/task-right-panel.tsx @@ -12,13 +12,11 @@ import { ShellTerminal } from '@/components/task/shell-terminal'; type TaskRightPanelProps = { topPanel: ReactNode; - taskId: string; - sessionId: string | null; }; const DEFAULT_RIGHT_LAYOUT: [number, number] = [55, 45]; -const TaskRightPanel = memo(function TaskRightPanel({ topPanel, taskId, sessionId }: TaskRightPanelProps) { +const TaskRightPanel = memo(function TaskRightPanel({ topPanel }: TaskRightPanelProps) { const [rightLayout, setRightLayout] = useState<[number, number]>(() => getLocalStorage('task-layout-right', DEFAULT_RIGHT_LAYOUT) ); @@ -180,7 +178,7 @@ const TaskRightPanel = memo(function TaskRightPanel({ topPanel, taskId, sessionI {terminalIds.map((id) => (
- +
))} diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index 86c6f1da27..0526825c46 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -1,13 +1,18 @@ 'use client'; -import { useEffect, useState } from 'react'; -import type { TaskSession, TaskState } from '@/lib/types/http'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { TaskState } from '@/lib/types/http'; import { TaskSessionSwitcher } from './task-session-switcher'; import { TaskSwitcher } from './task-switcher'; import { Button } from '@kandev/ui/button'; import { IconPlus } from '@tabler/icons-react'; import { TaskCreateDialog } from '@/components/task-create-dialog'; -import { useAppStoreApi } from '@/components/state-provider'; +import { useAppStore, useAppStoreApi } from '@/components/state-provider'; +import { getWebSocketClient } from '@/lib/ws/connection'; +import { linkToSession } from '@/lib/links'; +import { listTaskSessions } from '@/lib/http'; +import { useTaskSessions } from '@/hooks/use-task-sessions'; +import { useTasks } from '@/hooks/use-tasks'; type TaskSummary = { id: string; @@ -19,46 +24,152 @@ type TaskSummary = { }; type TaskSessionSidebarProps = { - workspaceName: string; - tasks: TaskSummary[]; - columns: Array<{ id: string; title: string }>; workspaceId: string | null; boardId: string | null; - activeTaskId: string | null; - activeSessionId: string | null; - sessionsByTask: Record; - agentLabelsById: Record; - onSelectSession: (taskId: string, sessionId: string) => void; - onLoadTaskSessions: (taskId: string) => void; - onSelectTask: (taskId: string, sessionId: string | null) => void; - onCreateSession: (taskId: string, data: { prompt: string; agentProfileId: string; executorId: string; environmentId: string }) => void; }; -export function TaskSessionSidebar({ - workspaceName, - tasks, - columns, - workspaceId, - boardId, - activeTaskId, - activeSessionId, - sessionsByTask, - agentLabelsById, - onSelectSession, - onLoadTaskSessions, - onSelectTask, - onCreateSession, -}: TaskSessionSidebarProps) { - const [selectedTaskId, setSelectedTaskId] = useState(activeTaskId); - const selectedSessions = selectedTaskId ? sessionsByTask[selectedTaskId] ?? [] : []; +export function TaskSessionSidebar({ workspaceId, boardId }: TaskSessionSidebarProps) { + const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); + const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); + const sessionsById = useAppStore((state) => state.taskSessions.items); + const { tasks } = useTasks(boardId); + const columns = useAppStore((state) => state.kanban.columns); + const workspaces = useAppStore((state) => state.workspaces.items); + const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + const setActiveSession = useAppStore((state) => state.setActiveSession); + const setActiveTask = useAppStore((state) => state.setActiveTask); + const setTaskSessionsForTask = useAppStore((state) => state.setTaskSessionsForTask); + const setTaskSessionsLoading = useAppStore((state) => state.setTaskSessionsLoading); + const selectedTaskId = useMemo(() => { + if (activeSessionId) { + return sessionsById[activeSessionId]?.task_id ?? activeTaskId; + } + return activeTaskId; + }, [activeSessionId, activeTaskId, sessionsById]); + const { sessions: selectedSessions, loadSessions: loadSelectedSessions } = useTaskSessions(selectedTaskId ?? null); const [taskDialogOpen, setTaskDialogOpen] = useState(false); const [sessionDialogOpen, setSessionDialogOpen] = useState(false); const [sessionTask, setSessionTask] = useState(null); const store = useAppStoreApi(); + const workspaceName = useMemo(() => { + if (!workspaceId) return 'Workspace'; + return workspaces.find((workspace) => workspace.id === workspaceId)?.name ?? 'Workspace'; + }, [workspaceId, workspaces]); + + const tasksWithRepositories = useMemo(() => { + const repositories = workspaceId ? repositoriesByWorkspace[workspaceId] ?? [] : []; + const repositoryPathsById = new Map(repositories.map((repo) => [repo.id, repo.local_path])); + return tasks.map((task) => ({ + id: task.id, + title: task.title, + state: task.state as TaskState | undefined, + description: task.description, + columnId: task.columnId, + repositoryPath: task.repositoryId ? repositoryPathsById.get(task.repositoryId) : undefined, + })); + }, [repositoriesByWorkspace, tasks, workspaceId]); + useEffect(() => { - setSelectedTaskId(activeTaskId); - }, [activeTaskId]); + if (!selectedTaskId) return; + const client = getWebSocketClient(); + if (!client) return; + const unsubscribers = selectedSessions.map((session) => client.subscribeSession(session.id)); + return () => { + unsubscribers.forEach((unsubscribe) => unsubscribe()); + }; + }, [selectedSessions, selectedTaskId]); + + const updateUrl = useCallback((sessionId: string) => { + if (typeof window === 'undefined') return; + window.history.replaceState({}, '', linkToSession(sessionId)); + }, []); + + const loadTaskSessionsForTask = useCallback( + async (taskId: string) => { + const state = store.getState(); + if (state.taskSessionsByTask.loadedByTaskId[taskId]) { + return state.taskSessionsByTask.itemsByTaskId[taskId] ?? []; + } + if (state.taskSessionsByTask.loadingByTaskId[taskId]) { + return state.taskSessionsByTask.itemsByTaskId[taskId] ?? []; + } + setTaskSessionsLoading(taskId, true); + try { + const response = await listTaskSessions(taskId, { cache: 'no-store' }); + setTaskSessionsForTask(taskId, response.sessions ?? []); + return response.sessions ?? []; + } catch (error) { + console.error('Failed to load task sessions:', error); + setTaskSessionsForTask(taskId, []); + return []; + } finally { + setTaskSessionsLoading(taskId, false); + } + }, + [setTaskSessionsForTask, setTaskSessionsLoading, store] + ); + + const handleSelectSession = useCallback( + (taskId: string, sessionId: string) => { + setActiveSession(taskId, sessionId); + updateUrl(sessionId); + }, + [setActiveSession, updateUrl] + ); + + const handleSelectTask = useCallback( + async (taskId: string) => { + const sessions = await loadTaskSessionsForTask(taskId); + const sessionId = sessions[0]?.id ?? null; + if (!sessionId) { + setActiveTask(taskId); + return; + } + setActiveSession(taskId, sessionId); + updateUrl(sessionId); + }, + [loadTaskSessionsForTask, setActiveSession, setActiveTask, updateUrl] + ); + + const handleCreateSession = useCallback( + async (taskId: string, data: { prompt: string; agentProfileId: string; executorId: string; environmentId: string }) => { + const client = getWebSocketClient(); + if (!client) return; + + try { + const response = await client.request<{ + success: boolean; + task_id: string; + agent_instance_id: string; + session_id?: string; + state: string; + }>( + 'orchestrator.start', + { task_id: taskId, agent_profile_id: data.agentProfileId }, + 15000 + ); + + if (response?.session_id && data.prompt.trim()) { + await client.request( + 'message.add', + { task_id: taskId, session_id: response.session_id, content: data.prompt.trim() }, + 10000 + ); + } + + await loadSelectedSessions(true); + + if (response?.session_id) { + setActiveSession(taskId, response.session_id); + updateUrl(response.session_id); + } + } catch (error) { + console.error('Failed to create task session:', error); + } + }, + [loadSelectedSessions, setActiveSession, updateUrl] + ); return ( <> @@ -78,28 +189,23 @@ export function TaskSessionSidebar({
{ if (!selectedTaskId) return; - const task = tasks.find((item) => item.id === selectedTaskId) ?? null; + const task = tasksWithRepositories.find((item) => item.id === selectedTaskId) ?? null; setSessionTask(task); setSessionDialogOpen(true); }} /> ({ id: column.id, title: column.title }))} activeTaskId={activeTaskId} selectedTaskId={selectedTaskId} - sessionsByTask={sessionsByTask} - onSelectTask={(taskId, sessionId) => { - setSelectedTaskId(taskId); - onSelectTask(taskId, sessionId); + onSelectTask={(taskId) => { + handleSelectTask(taskId); }} - onLoadTaskSessions={onLoadTaskSessions} />
@@ -111,7 +217,7 @@ export function TaskSessionSidebar({ boardId={boardId} defaultColumnId={columns[0]?.id ?? null} columns={columns.map((column) => ({ id: column.id, title: column.title }))} - onSuccess={(task) => { + onSuccess={(task, _mode, meta) => { store.setState((state) => { if (state.kanban.boardId !== task.board_id) return state; const nextTask = { @@ -133,8 +239,11 @@ export function TaskSessionSidebar({ }, }; }); - setSelectedTaskId(task.id); - onSelectTask(task.id, null); + setActiveTask(task.id); + if (meta?.taskSessionId) { + setActiveSession(task.id, meta.taskSessionId); + updateUrl(meta.taskSessionId); + } }} /> { if (!sessionTask) return; - onCreateSession(sessionTask.id, data); + handleCreateSession(sessionTask.id, data); }} /> diff --git a/apps/web/components/task/task-session-switcher.tsx b/apps/web/components/task/task-session-switcher.tsx index d2d6d7ce42..4f8bf4e502 100644 --- a/apps/web/components/task/task-session-switcher.tsx +++ b/apps/web/components/task/task-session-switcher.tsx @@ -6,12 +6,11 @@ import type { TaskSession, TaskSessionState } from '@/lib/types/http'; import { Badge } from '@kandev/ui/badge'; import { Command, CommandGroup, CommandItem, CommandList } from '@kandev/ui/command'; import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; +import { useAppStore } from '@/components/state-provider'; type SessionSwitcherProps = { taskId: string | null; - activeSessionId: string | null; sessions: TaskSession[]; - agentLabelsById: Record; onSelectSession: (taskId: string, sessionId: string) => void; showHeader?: boolean; onCreateSession?: () => void; @@ -88,13 +87,16 @@ function formatDuration(startedAt: string, isRunning: boolean, now: number) { export function TaskSessionSwitcher({ taskId, - activeSessionId, sessions, - agentLabelsById, onSelectSession, showHeader = true, onCreateSession, }: SessionSwitcherProps) { + const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); + const agentProfiles = useAppStore((state) => state.agentProfiles.items); + const agentLabelsById = useMemo(() => { + return Object.fromEntries(agentProfiles.map((profile) => [profile.id, profile.label])); + }, [agentProfiles]); const [currentTime, setCurrentTime] = useState(() => Date.now()); const sortedSessions = useMemo(() => { return [...sessions].sort((a, b) => { diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index cc3711cf50..51c884d8c0 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'; import { IconChevronRight } from '@tabler/icons-react'; -import type { TaskSession, TaskState } from '@/lib/types/http'; +import type { TaskState } from '@/lib/types/http'; import { Item, ItemActions, @@ -28,9 +28,7 @@ type TaskSwitcherProps = { columns: Array<{ id: string; title: string }>; activeTaskId: string | null; selectedTaskId: string | null; - sessionsByTask: Record; - onLoadTaskSessions: (taskId: string) => void; - onSelectTask: (taskId: string, sessionId: string | null) => void; + onSelectTask: (taskId: string) => void; }; export function TaskSwitcher({ @@ -38,8 +36,6 @@ export function TaskSwitcher({ columns, activeTaskId, selectedTaskId, - sessionsByTask, - onLoadTaskSessions, onSelectTask, }: TaskSwitcherProps) { const [collapsedColumnIds, setCollapsedColumnIds] = useState>(() => new Set()); @@ -107,7 +103,6 @@ export function TaskSwitcher({ {columnTasks.map((task) => { const isActive = task.id === activeTaskId; - const sessions = sessionsByTask[task.id] ?? []; const isSelected = task.id === selectedTaskId || isActive; const repoLabel = task.repositoryPath ? truncateRepoPath(task.repositoryPath) : 'No repository'; return ( @@ -123,16 +118,12 @@ export function TaskSwitcher({ href="#" onClick={(event) => { event.preventDefault(); - const firstSessionId = sessions[0]?.id ?? null; - onSelectTask(task.id, firstSessionId); - onLoadTaskSessions(task.id); + onSelectTask(task.id); }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); - const firstSessionId = sessions[0]?.id ?? null; - onSelectTask(task.id, firstSessionId); - onLoadTaskSessions(task.id); + onSelectTask(task.id); } }} className="flex w-full items-center gap-2" diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index 0a5a997b8e..2eaa3b87d3 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -38,6 +38,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@kandev/ui/popover'; import { SessionsDropdown } from './sessions-dropdown'; import { CommitStatBadge, LineStat } from '@/components/diff-stat'; import { useAppStore } from '@/components/state-provider'; +import { useSessionGitStatus } from '@/hooks/use-session-git-status'; import { formatUserHomePath } from '@/lib/utils'; type TaskTopBarProps = { @@ -78,8 +79,7 @@ const TaskTopBar = memo(function TaskTopBar({ const [startDialogOpen, setStartDialogOpen] = useState(false); const [selectedAgentProfileId, setSelectedAgentProfileId] = useState(null); - // Get git status from store - const gitStatus = useAppStore((state) => state.gitStatus); + const gitStatus = useSessionGitStatus(activeSessionId ?? null); const agentProfiles = useAppStore((state) => state.agentProfiles.items); const resolvedAgentProfileId = useMemo(() => { @@ -94,20 +94,15 @@ const TaskTopBar = memo(function TaskTopBar({ // Use worktree branch if available, otherwise fall back to base branch const displayBranch = worktreeBranch || baseBranch; - // Calculate total additions and deletions from all files - const { totalAdditions, totalDeletions } = useMemo(() => { - if (!gitStatus.files || Object.keys(gitStatus.files).length === 0) { - return { totalAdditions: 0, totalDeletions: 0 }; + // Calculate total additions and deletions from all files. + let totalAdditions = 0; + let totalDeletions = 0; + if (gitStatus?.files && Object.keys(gitStatus.files).length > 0) { + for (const file of Object.values(gitStatus.files)) { + totalAdditions += file.additions || 0; + totalDeletions += file.deletions || 0; } - - return Object.values(gitStatus.files).reduce( - (acc, file) => ({ - totalAdditions: acc.totalAdditions + (file.additions || 0), - totalDeletions: acc.totalDeletions + (file.deletions || 0), - }), - { totalAdditions: 0, totalDeletions: 0 } - ); - }, [gitStatus.files]); + } const handleBranchClick = (e: React.MouseEvent) => { e.stopPropagation(); @@ -236,29 +231,29 @@ const TaskTopBar = memo(function TaskTopBar({ )} {/* Git Status: Ahead/Behind */} - {(gitStatus.ahead > 0 || gitStatus.behind > 0) && ( + {((gitStatus?.ahead ?? 0) > 0 || (gitStatus?.behind ?? 0) > 0) && (
- {gitStatus.ahead > 0 && ( + {(gitStatus?.ahead ?? 0) > 0 && ( - + - {gitStatus.ahead} commit{gitStatus.ahead !== 1 ? 's' : ''} ahead of {gitStatus.remote_branch || 'remote'} + {gitStatus?.ahead ?? 0} commit{(gitStatus?.ahead ?? 0) !== 1 ? 's' : ''} ahead of {gitStatus?.remote_branch || 'remote'} )} - {gitStatus.behind > 0 && ( + {(gitStatus?.behind ?? 0) > 0 && ( - + - {gitStatus.behind} commit{gitStatus.behind !== 1 ? 's' : ''} behind {gitStatus.remote_branch || 'remote'} + {gitStatus?.behind ?? 0} commit{(gitStatus?.behind ?? 0) !== 1 ? 's' : ''} behind {gitStatus?.remote_branch || 'remote'} )} diff --git a/apps/web/hooks/use-lazy-load-messages.ts b/apps/web/hooks/use-lazy-load-messages.ts index 8fa598c418..e5c0c403f2 100644 --- a/apps/web/hooks/use-lazy-load-messages.ts +++ b/apps/web/hooks/use-lazy-load-messages.ts @@ -3,7 +3,15 @@ import { listTaskSessionMessages } from '@/lib/http'; import { useAppStore } from '@/components/state-provider'; export function useLazyLoadMessages(sessionId: string | null) { - const { hasMore, oldestCursor, isLoading } = useAppStore((state) => state.messages); + const hasMore = useAppStore((state) => + sessionId ? state.messages.metaBySession[sessionId]?.hasMore ?? false : false + ); + const oldestCursor = useAppStore((state) => + sessionId ? state.messages.metaBySession[sessionId]?.oldestCursor ?? null : null + ); + const isLoading = useAppStore((state) => + sessionId ? state.messages.metaBySession[sessionId]?.isLoading ?? false : false + ); const prependMessages = useAppStore((state) => state.prependMessages); const setMessagesMetadata = useAppStore((state) => state.setMessagesMetadata); @@ -12,7 +20,7 @@ export function useLazyLoadMessages(sessionId: string | null) { return 0; } - setMessagesMetadata({ isLoading: true }); + setMessagesMetadata(sessionId, { isLoading: true }); try { const response = await listTaskSessionMessages(sessionId, { limit: 20, @@ -20,13 +28,13 @@ export function useLazyLoadMessages(sessionId: string | null) { sort: 'desc', }); const orderedMessages = [...(response.messages ?? [])].reverse(); - prependMessages(orderedMessages, { + prependMessages(sessionId, orderedMessages, { hasMore: response.has_more, oldestCursor: response.cursor || (orderedMessages[0]?.id ?? null), }); return orderedMessages.length; } catch { - setMessagesMetadata({ isLoading: false }); + setMessagesMetadata(sessionId, { isLoading: false }); return 0; } }, [sessionId, hasMore, isLoading, oldestCursor, prependMessages, setMessagesMetadata]); diff --git a/apps/web/hooks/use-repository.ts b/apps/web/hooks/use-repository.ts new file mode 100644 index 0000000000..df1c5b548c --- /dev/null +++ b/apps/web/hooks/use-repository.ts @@ -0,0 +1,12 @@ +import { useMemo } from 'react'; +import { useAppStore } from '@/components/state-provider'; + +export function useRepository(repositoryId: string | null) { + const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + + return useMemo(() => { + if (!repositoryId) return null; + const repositories = Object.values(repositoriesByWorkspace).flat(); + return repositories.find((repo) => repo.id === repositoryId) ?? null; + }, [repositoriesByWorkspace, repositoryId]); +} diff --git a/apps/web/hooks/use-session-agentctl.ts b/apps/web/hooks/use-session-agentctl.ts new file mode 100644 index 0000000000..e25acc11f4 --- /dev/null +++ b/apps/web/hooks/use-session-agentctl.ts @@ -0,0 +1,28 @@ +import { useEffect } from 'react'; +import { useAppStore } from '@/components/state-provider'; +import { getWebSocketClient } from '@/lib/ws/connection'; + +export function useSessionAgentctl(sessionId: string | null) { + const session = useAppStore((state) => + sessionId ? state.taskSessions.items[sessionId] : undefined + ); + const status = useAppStore((state) => + sessionId ? state.sessionAgentctl.itemsBySessionId[sessionId] : undefined + ); + + useEffect(() => { + if (!session?.id) return; + const client = getWebSocketClient(); + if (!client) return; + return client.subscribeSession(session.id); + }, [session?.id]); + + return { + status: status?.status ?? 'starting', + errorMessage: status?.errorMessage, + agentExecutionId: status?.agentExecutionId, + isReady: status?.status === 'ready', + isStarting: status?.status === 'starting' || !status, + isError: status?.status === 'error', + }; +} diff --git a/apps/web/hooks/use-session-git-status.ts b/apps/web/hooks/use-session-git-status.ts new file mode 100644 index 0000000000..fec50cca45 --- /dev/null +++ b/apps/web/hooks/use-session-git-status.ts @@ -0,0 +1,27 @@ +import { useEffect } from 'react'; +import { useAppStore } from '@/components/state-provider'; +import { getWebSocketClient } from '@/lib/ws/connection'; + +export function useSessionGitStatus(sessionId: string | null) { + const gitStatus = useAppStore((state) => + sessionId ? state.gitStatus.bySessionId[sessionId] : undefined + ); + const clearGitStatus = useAppStore((state) => state.clearGitStatus); + + useEffect(() => { + if (!sessionId) return; + const client = getWebSocketClient(); + if (client) { + const unsubscribe = client.subscribeSession(sessionId); + return () => { + unsubscribe(); + clearGitStatus(sessionId); + }; + } + return () => { + clearGitStatus(sessionId); + }; + }, [clearGitStatus, sessionId]); + + return gitStatus; +} diff --git a/apps/web/hooks/use-task-messages.ts b/apps/web/hooks/use-session-messages.ts similarity index 58% rename from apps/web/hooks/use-task-messages.ts rename to apps/web/hooks/use-session-messages.ts index 2cb55f6975..2e7e3e853c 100644 --- a/apps/web/hooks/use-task-messages.ts +++ b/apps/web/hooks/use-session-messages.ts @@ -3,18 +3,30 @@ import { getWebSocketClient } from '@/lib/ws/connection'; import { useAppStore, useAppStoreApi } from '@/components/state-provider'; import type { TaskSessionState, Message } from '@/lib/types/http'; -interface UseTaskMessagesReturn { +interface UseSessionMessagesReturn { isLoading: boolean; + messages: Message[]; + hasMore: boolean; + oldestCursor: string | null; } -export function useTaskMessages( - taskId: string | null, +const EMPTY_MESSAGES: Message[] = []; +const EMPTY_META = { isLoading: false, hasMore: false, oldestCursor: null }; + +export function useSessionMessages( taskSessionId: string | null -): UseTaskMessagesReturn { +): UseSessionMessagesReturn { const store = useAppStoreApi(); - const messagesState = useAppStore((state) => state.messages); + const messages = useAppStore((state) => + taskSessionId ? state.messages.bySession[taskSessionId] ?? EMPTY_MESSAGES : EMPTY_MESSAGES + ); + const messagesMeta = useAppStore((state) => + taskSessionId + ? state.messages.metaBySession[taskSessionId] ?? EMPTY_META + : EMPTY_META + ); const taskSessionState = useAppStore((state) => - taskId ? (state.taskSessionStatesByTaskId[taskId] ?? null) : null + taskSessionId ? state.taskSessions.items[taskSessionId]?.state ?? null : null ); const connectionStatus = useAppStore((state) => state.connection.status); const [isLoading, setIsLoading] = useState(false); @@ -22,18 +34,11 @@ export function useTaskMessages( const initialFetchStartRef = useRef(null); const lastFetchedSessionIdRef = useRef(null); const lastFetchStateKeyRef = useRef(null); - const hasAgentMessage = messagesState.items.some((message) => message.author_type === 'agent'); + const hasAgentMessage = messages.some((message) => message.author_type === 'agent'); useEffect(() => { - if (!taskId) return; - store.getState().clearGitStatus(); - }, [store, taskId]); - - useEffect(() => { - store.getState().setMessagesSessionId(taskSessionId); if (!taskSessionId) { - console.log('[useTaskMessages] no task_session_id yet, clearing messages'); - store.getState().setMessages(null, []); + console.log('[useSessionMessages] no session_id yet, clearing messages'); initialFetchStartRef.current = null; lastFetchedSessionIdRef.current = null; setIsWaitingForInitialMessages(false); @@ -43,32 +48,28 @@ export function useTaskMessages( useEffect(() => { if (!taskSessionId) return; // Don't set waiting state if messages are already loaded (from SSR or cache) - if (messagesState.sessionId === taskSessionId && messagesState.items.length > 0) { + if (messages.length > 0) { setIsWaitingForInitialMessages(false); return; } - if (messagesState.items.length > 0) return; + if (messages.length > 0) return; if (initialFetchStartRef.current === null) { initialFetchStartRef.current = Date.now(); setIsWaitingForInitialMessages(true); } - }, [taskSessionId, messagesState.sessionId, messagesState.items.length]); + }, [taskSessionId, messages.length]); // Fetch messages on mount and when session changes useEffect(() => { if (!taskSessionId) return; if (connectionStatus !== 'connected') { - console.warn('[useTaskMessages] WebSocket not connected yet, waiting to fetch messages'); + console.warn('[useSessionMessages] WebSocket not connected yet, waiting to fetch messages'); return; } - // Set sessionId immediately so that incoming WebSocket notifications are processed - // before the API call completes (fixes race condition on first agent start) - store.getState().setMessagesSessionId(taskSessionId); - // Check if messages are already loaded (from SSR or previous fetch) - if (messagesState.sessionId === taskSessionId && messagesState.items.length > 0) { - console.log('[useTaskMessages] messages already loaded from SSR or cache, skipping fetch'); + if (messages.length > 0) { + console.log('[useSessionMessages] messages already loaded from SSR or cache, skipping fetch'); lastFetchedSessionIdRef.current = taskSessionId; setIsWaitingForInitialMessages(false); return; @@ -81,25 +82,25 @@ export function useTaskMessages( const fetchMessages = async () => { const client = getWebSocketClient(); if (!client) { - console.warn('[useTaskMessages] WebSocket client not ready'); + console.warn('[useSessionMessages] WebSocket client not ready'); return; } setIsLoading(true); - store.getState().setMessagesLoading(true); + store.getState().setMessagesLoading(taskSessionId, true); if (initialFetchStartRef.current === null) { initialFetchStartRef.current = Date.now(); setIsWaitingForInitialMessages(true); } try { - console.log('[useTaskMessages] requesting message.list', { taskSessionId }); + console.log('[useSessionMessages] requesting message.list', { taskSessionId }); const response = await client.request<{ messages: Message[] }>( 'message.list', - { task_session_id: taskSessionId }, + { session_id: taskSessionId }, 10000 ); - console.log('[useTaskMessages] message.list response:', JSON.stringify(response, null, 2)); + console.log('[useSessionMessages] message.list response:', JSON.stringify(response, null, 2)); store.getState().setMessages(taskSessionId, response.messages ?? []); lastFetchedSessionIdRef.current = taskSessionId; if ((response.messages ?? []).length > 0) { @@ -110,36 +111,26 @@ export function useTaskMessages( store.getState().setMessages(taskSessionId, []); lastFetchedSessionIdRef.current = taskSessionId; } finally { + store.getState().setMessagesLoading(taskSessionId, false); setIsLoading(false); } }; fetchMessages(); - }, [taskSessionId, connectionStatus, messagesState.sessionId, messagesState.items.length, store]); + }, [taskSessionId, connectionStatus, messages.length, store]); - // Subscribe to task for real-time updates useEffect(() => { - if (!taskId) return; - + if (!taskSessionId) return; const client = getWebSocketClient(); - if (!client) { - console.warn('[useTaskMessages] WebSocket client not ready for subscribe'); - return; - } - - // Subscribe to task updates - console.log('[useTaskMessages] subscribing to task', { taskId }); - client.subscribe(taskId); - + if (!client) return; + const unsubscribe = client.subscribeSession(taskSessionId); return () => { - // Unsubscribe when leaving - console.log('[useTaskMessages] unsubscribing from task', { taskId }); - client.unsubscribe(taskId); + unsubscribe(); }; - }, [taskId]); + }, [taskSessionId]); useEffect(() => { - if (!taskSessionId || !taskSessionState || !taskId) return; + if (!taskSessionId || !taskSessionState) return; if (hasAgentMessage) return; const terminalStates = new Set(['WAITING_FOR_INPUT', 'COMPLETED', 'FAILED']); @@ -156,17 +147,17 @@ export function useTaskMessages( const fetchMessages = async () => { const client = getWebSocketClient(); if (!client) { - console.warn('[useTaskMessages] WebSocket client not ready for state fetch'); + console.warn('[useSessionMessages] WebSocket client not ready for state fetch'); return; } setIsLoading(true); - store.getState().setMessagesLoading(true); + store.getState().setMessagesLoading(taskSessionId, true); try { - console.log('[useTaskMessages] requesting message.list after state change', { taskSessionId, taskSessionState }); + console.log('[useSessionMessages] requesting message.list after state change', { taskSessionId, taskSessionState }); const response = await client.request<{ messages: Message[] }>( 'message.list', - { task_session_id: taskSessionId }, + { session_id: taskSessionId }, 10000 ); store.getState().setMessages(taskSessionId, response.messages ?? []); @@ -176,14 +167,18 @@ export function useTaskMessages( } catch (error) { console.error('Failed to fetch messages after state change:', error); } finally { + store.getState().setMessagesLoading(taskSessionId, false); setIsLoading(false); } }; fetchMessages(); - }, [taskSessionId, taskSessionState, hasAgentMessage, store, taskId]); + }, [taskSessionId, taskSessionState, hasAgentMessage, store]); return { - isLoading: isLoading || isWaitingForInitialMessages, + isLoading: isLoading || isWaitingForInitialMessages || messagesMeta.isLoading, + messages, + hasMore: messagesMeta.hasMore, + oldestCursor: messagesMeta.oldestCursor, }; } diff --git a/apps/web/hooks/use-session-resumption.ts b/apps/web/hooks/use-session-resumption.ts index 5a73f799da..1b8b0cdddf 100644 --- a/apps/web/hooks/use-session-resumption.ts +++ b/apps/web/hooks/use-session-resumption.ts @@ -28,7 +28,6 @@ interface UseSessionResumptionReturn { worktreePath: string | null; worktreeBranch: string | null; resumeSession: () => Promise; - startNewSession: (agentProfileId: string) => Promise; } /** @@ -46,11 +45,16 @@ export function useSessionResumption( const [worktreePath, setWorktreePath] = useState(null); const [worktreeBranch, setWorktreeBranch] = useState(null); const connectionStatus = useAppStore((state) => state.connection.status); - // Read task session state from store so WebSocket updates are reflected - const taskSessionState = useAppStore((state) => taskId ? state.taskSessionStatesByTaskId[taskId] ?? null : null); - const setStoreTaskSessionState = useAppStore((state) => state.setTaskSessionState); + const session = useAppStore((state) => + sessionId ? state.taskSessions.items[sessionId] ?? null : null + ); + const setTaskSession = useAppStore((state) => state.setTaskSession); const hasAttemptedResume = useRef(false); + useEffect(() => { + hasAttemptedResume.current = false; + }, [sessionId, taskId]); + // Check session status and auto-resume if needed useEffect(() => { if (!taskId || !sessionId) return; @@ -70,7 +74,7 @@ export function useSessionResumption( console.log('[useSessionResumption] Checking session status:', { taskId, sessionId }); const status = await client.request('task.session.status', { task_id: taskId, - task_session_id: sessionId, + session_id: sessionId, }); console.log('[useSessionResumption] Session status:', status); setSessionStatus(status); @@ -84,8 +88,15 @@ export function useSessionResumption( // Update worktree info setWorktreePath(status.worktree_path ?? null); setWorktreeBranch(status.worktree_branch ?? null); - if (taskId && status.state) { - setStoreTaskSessionState(taskId, status.state as TaskSessionState); + if (taskId && sessionId && status.state) { + setTaskSession({ + id: sessionId, + task_id: taskId, + state: status.state as TaskSessionState, + progress: session?.progress ?? 0, + started_at: session?.started_at ?? '', + updated_at: session?.updated_at ?? '', + }); } // 2. If agent is already running, we're good @@ -108,14 +119,21 @@ export function useSessionResumption( error?: string; }>('task.session.resume', { task_id: taskId, - task_session_id: sessionId, + session_id: sessionId, }, 30000); if (resumeResp.success) { console.log('[useSessionResumption] Session resumed successfully'); setResumptionState('resumed'); - if (taskId && resumeResp.state) { - setStoreTaskSessionState(taskId, resumeResp.state as TaskSessionState); + if (taskId && sessionId && resumeResp.state) { + setTaskSession({ + id: sessionId, + task_id: taskId, + state: resumeResp.state as TaskSessionState, + progress: session?.progress ?? 0, + started_at: session?.started_at ?? '', + updated_at: session?.updated_at ?? '', + }); } if (resumeResp.worktree_path) { setWorktreePath(resumeResp.worktree_path); @@ -144,7 +162,7 @@ export function useSessionResumption( }; checkAndResume(); - }, [taskId, sessionId, connectionStatus, setStoreTaskSessionState]); + }, [taskId, sessionId, connectionStatus, setTaskSession, session]); // Manual resume function const resumeSession = useCallback(async (): Promise => { @@ -165,13 +183,20 @@ export function useSessionResumption( error?: string; }>('task.session.resume', { task_id: taskId, - task_session_id: sessionId, + session_id: sessionId, }, 30000); if (response.success) { setResumptionState('resumed'); - if (taskId && response.state) { - setStoreTaskSessionState(taskId, response.state as TaskSessionState); + if (taskId && sessionId && response.state) { + setTaskSession({ + id: sessionId, + task_id: taskId, + state: response.state as TaskSessionState, + progress: session?.progress ?? 0, + started_at: session?.started_at ?? '', + updated_at: session?.updated_at ?? '', + }); } if (response.worktree_path) { setWorktreePath(response.worktree_path); @@ -190,60 +215,15 @@ export function useSessionResumption( setError(err instanceof Error ? err.message : 'Unknown error'); return false; } - }, [taskId, sessionId, setStoreTaskSessionState]); - - // Start new session function (fallback when resume fails) - const startNewSession = useCallback(async (agentProfileId: string): Promise => { - if (!taskId) return false; - - const client = getWebSocketClient(); - if (!client) return false; - - setResumptionState('resuming'); - setError(null); - - try { - const response = await client.request<{ - success: boolean; - task_session_id?: string; - state?: string; - worktree_path?: string; - worktree_branch?: string; - }>('orchestrator.start', { - task_id: taskId, - agent_profile_id: agentProfileId, - }, 15000); - - if (response.success) { - setResumptionState('resumed'); - if (taskId && response.state) { - setStoreTaskSessionState(taskId, response.state as TaskSessionState); - } - if (response.worktree_path) { - setWorktreePath(response.worktree_path); - } - if (response.worktree_branch) { - setWorktreeBranch(response.worktree_branch); - } - return true; - } - return false; - } catch (err) { - setResumptionState('error'); - setError(err instanceof Error ? err.message : 'Unknown error'); - return false; - } - }, [taskId, setStoreTaskSessionState]); + }, [taskId, sessionId, setTaskSession, session]); return { resumptionState, sessionStatus, error, - taskSessionState, + taskSessionState: session?.state ?? null, worktreePath, worktreeBranch, resumeSession, - startNewSession, }; } - diff --git a/apps/web/hooks/use-session-worktrees.ts b/apps/web/hooks/use-session-worktrees.ts new file mode 100644 index 0000000000..087c409ea9 --- /dev/null +++ b/apps/web/hooks/use-session-worktrees.ts @@ -0,0 +1,24 @@ +import { useMemo } from 'react'; +import { useAppStore } from '@/components/state-provider'; +import { useSession } from '@/hooks/use-session'; + +export function useSessionWorktrees(sessionId: string | null) { + const { session } = useSession(sessionId); + const worktrees = useAppStore((state) => state.worktrees.items); + const sessionWorktreesBySessionId = useAppStore( + (state) => state.sessionWorktreesBySessionId.itemsBySessionId + ); + + return useMemo(() => { + if (!sessionId) return []; + const worktreeIds = sessionWorktreesBySessionId[sessionId]; + if (worktreeIds?.length) { + return worktreeIds.map((id) => worktrees[id]).filter(Boolean); + } + if (session?.worktree_id) { + const worktree = worktrees[session.worktree_id]; + return worktree ? [worktree] : []; + } + return []; + }, [session?.worktree_id, sessionId, sessionWorktreesBySessionId, worktrees]); +} diff --git a/apps/web/hooks/use-session.ts b/apps/web/hooks/use-session.ts new file mode 100644 index 0000000000..c3b2debb5f --- /dev/null +++ b/apps/web/hooks/use-session.ts @@ -0,0 +1,32 @@ +import { useEffect, useMemo } from 'react'; +import { useAppStore } from '@/components/state-provider'; +import { getWebSocketClient } from '@/lib/ws/connection'; +import type { TaskSession } from '@/lib/types/http'; + +type UseSessionResult = { + session: TaskSession | null; + isActive: boolean; +}; + +export function useSession(sessionId: string | null): UseSessionResult { + const session = useAppStore((state) => + sessionId ? state.taskSessions.items[sessionId] ?? null : null + ); + + const isActive = useMemo(() => { + if (!session?.state) return false; + return session.state === 'RUNNING' || session.state === 'WAITING_FOR_INPUT'; + }, [session?.state]); + + useEffect(() => { + if (!session?.id) return; + const client = getWebSocketClient(); + if (!client) return; + const unsubscribe = client.subscribeSession(session.id); + return () => { + unsubscribe(); + }; + }, [session?.id]); + + return { session, isActive }; +} diff --git a/apps/web/hooks/use-task-agent.ts b/apps/web/hooks/use-task-agent.ts index 389c95a58d..504b569b22 100644 --- a/apps/web/hooks/use-task-agent.ts +++ b/apps/web/hooks/use-task-agent.ts @@ -1,7 +1,9 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { getWebSocketClient } from '@/lib/ws/connection'; import { useAppStore } from '@/components/state-provider'; -import type { TaskSessionState, Task } from '@/lib/types/http'; +import type { TaskSessionState, Task, TaskSession } from '@/lib/types/http'; + +const EMPTY_SESSIONS: TaskSession[] = []; interface UseTaskAgentReturn { isAgentRunning: boolean; @@ -22,9 +24,20 @@ export function useTaskAgent(task: Task | null): UseTaskAgentReturn { const [worktreePath, setWorktreePath] = useState(task?.worktree_path ?? null); const [worktreeBranch, setWorktreeBranch] = useState(task?.worktree_branch ?? null); const connectionStatus = useAppStore((state) => state.connection.status); - const wsTaskSessionState = useAppStore((state) => - task?.id ? state.taskSessionStatesByTaskId[task.id] : undefined + const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); + const activeSession = useAppStore((state) => + activeSessionId ? state.taskSessions.items[activeSessionId] ?? null : null ); + const sessionsForTask = useAppStore((state) => + task?.id ? state.taskSessionsByTask.itemsByTaskId[task.id] ?? EMPTY_SESSIONS : EMPTY_SESSIONS + ); + const wsTaskSessionState = useMemo(() => { + if (!task?.id) return undefined; + if (activeSession && activeSession.task_id === task.id) { + return activeSession.state; + } + return sessionsForTask[0]?.state; + }, [activeSession, sessionsForTask, task?.id]); useEffect(() => { setIsAgentRunning(false); @@ -53,7 +66,7 @@ export function useTaskAgent(task: Task | null): UseTaskAgentReturn { has_execution: boolean; task_id: string; state?: string; - task_session_id?: string; + session_id?: string; }>('task.execution', { task_id: task.id }); console.log('[useTaskAgent] Task execution check:', response); @@ -62,8 +75,8 @@ export function useTaskAgent(task: Task | null): UseTaskAgentReturn { if (response.state) { setTaskSessionState(response.state as TaskSessionState); } - if (response.task_session_id) { - setAgentSessionId(response.task_session_id); + if (response.session_id) { + setAgentSessionId(response.session_id); } } else { setIsAgentRunning(false); @@ -98,7 +111,7 @@ export function useTaskAgent(task: Task | null): UseTaskAgentReturn { success: boolean; task_id: string; agent_instance_id: string; - task_session_id?: string; + session_id?: string; state: string; worktree_path?: string; worktree_branch?: string; @@ -114,8 +127,8 @@ export function useTaskAgent(task: Task | null): UseTaskAgentReturn { console.log('[useTaskAgent] orchestrator.start response', response); setIsAgentRunning(true); setTaskSessionState(response.state as TaskSessionState); - if (response?.task_session_id) { - setAgentSessionId(response.task_session_id); + if (response?.session_id) { + setAgentSessionId(response.session_id); } // Update worktree info from response diff --git a/apps/web/hooks/use-task-chat-session.ts b/apps/web/hooks/use-task-chat-session.ts index 32f58070e9..b2cacb8cca 100644 --- a/apps/web/hooks/use-task-chat-session.ts +++ b/apps/web/hooks/use-task-chat-session.ts @@ -13,13 +13,13 @@ export function useTaskChatSession(taskId: string | null): UseTaskChatSessionRet const store = useAppStoreApi(); const [taskSessionIdsByTaskId, setTaskSessionIdsByTaskId] = useState>({}); const connectionStatus = useAppStore((state) => state.connection.status); - const taskSessionState = useAppStore((state) => - taskId ? state.taskSessionStatesByTaskId?.[taskId] ?? null : null - ); const taskSessionId = useMemo( () => (taskId ? taskSessionIdsByTaskId[taskId] ?? null : null), [taskId, taskSessionIdsByTaskId] ); + const taskSessionState = useAppStore((state) => + taskSessionId ? state.taskSessions.items[taskSessionId]?.state ?? null : null + ); useEffect(() => { if (!taskId) return; @@ -34,27 +34,27 @@ export function useTaskChatSession(taskId: string | null): UseTaskChatSessionRet has_execution: boolean; task_id: string; state?: string; - task_session_id?: string; + session_id?: string; }>('task.execution', { task_id: taskId }); setTaskSessionIdsByTaskId((prev) => { if (!taskId) return prev; - if (!response.has_execution || !response.task_session_id) { + if (!response.has_execution || !response.session_id) { if (!prev[taskId]) return prev; const next = { ...prev }; delete next[taskId]; return next; } - if (prev[taskId] === response.task_session_id) { + if (prev[taskId] === response.session_id) { return prev; } - return { ...prev, [taskId]: response.task_session_id }; + return { ...prev, [taskId]: response.session_id }; }); - // Store partial session object in the store so useTaskSession can access it - if (response.has_execution && response.task_session_id && response.state) { + // Store partial session object in the store so useSession can access it + if (response.has_execution && response.session_id && response.state) { store.getState().setTaskSession({ - id: response.task_session_id, + id: response.session_id, task_id: taskId, state: response.state as TaskSessionState, progress: 0, diff --git a/apps/web/hooks/use-task-repositories.ts b/apps/web/hooks/use-task-repositories.ts new file mode 100644 index 0000000000..f84e2ef3cb --- /dev/null +++ b/apps/web/hooks/use-task-repositories.ts @@ -0,0 +1,11 @@ +import { useAppStore } from '@/components/state-provider'; +import { useTask } from '@/hooks/use-task'; + +export function useTaskRepositories(taskId: string | null) { + const task = useTask(taskId); + const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + + if (!task?.repositoryId) return []; + const repositories = Object.values(repositoriesByWorkspace).flat(); + return repositories.filter((repo) => repo.id === task.repositoryId); +} diff --git a/apps/web/hooks/use-task-session.ts b/apps/web/hooks/use-task-session.ts deleted file mode 100644 index 4773fb6048..0000000000 --- a/apps/web/hooks/use-task-session.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { useMemo } from 'react'; -import { useAppStore } from '@/components/state-provider'; -import type { TaskSession } from '@/lib/types/http'; - -type UseTaskSessionReturn = { - session: TaskSession | null; - taskId: string | null; - taskDescription: string | null; - isWorking: boolean; -}; - -/** - * Hook to get task session details and derived task information from a session ID. - * The session ID is the primary identifier - everything else is derived from it. - * - * Falls back to deriving task_id from messages if session isn't in store yet. - */ -export function useTaskSession(sessionId: string | null): UseTaskSessionReturn { - const session = useAppStore((state) => - sessionId ? state.taskSessions.items[sessionId] ?? null : null - ); - - // Get task ID from session or fallback to messages - const messages = useAppStore((state) => state.messages.items); - const taskIdFromMessages = useMemo(() => { - if (!sessionId || !messages.length) return null; - const messageForSession = messages.find((m) => m.task_session_id === sessionId); - return messageForSession?.task_id ?? null; - }, [sessionId, messages]); - - const taskId = session?.task_id ?? taskIdFromMessages; - - const task = useAppStore((state) => - taskId ? state.kanban.tasks.find((t) => t.id === taskId) ?? null : null - ); - - const taskDescription = task?.description ?? null; - - // Get session state from store by task ID if session object not available - const sessionStateFromTaskId = useAppStore((state) => - taskId ? state.taskSessionStatesByTaskId[taskId] ?? null : null - ); - - const isWorking = useMemo(() => { - const state = session?.state ?? sessionStateFromTaskId; - if (!state) return false; - return state === 'STARTING' || state === 'RUNNING'; - }, [session, sessionStateFromTaskId]); - - return { - session, - taskId, - taskDescription, - isWorking, - }; -} diff --git a/apps/web/hooks/use-task-sessions.ts b/apps/web/hooks/use-task-sessions.ts new file mode 100644 index 0000000000..1c989155b5 --- /dev/null +++ b/apps/web/hooks/use-task-sessions.ts @@ -0,0 +1,43 @@ +import { useCallback, useEffect } from 'react'; +import { useAppStore } from '@/components/state-provider'; +import { listTaskSessions } from '@/lib/http'; +import type { TaskSession } from '@/lib/types/http'; + +const EMPTY_SESSIONS: TaskSession[] = []; + +export function useTaskSessions(taskId: string | null) { + const sessions = useAppStore((state) => + taskId ? state.taskSessionsByTask.itemsByTaskId[taskId] ?? EMPTY_SESSIONS : EMPTY_SESSIONS + ); + const isLoading = useAppStore((state) => + taskId ? state.taskSessionsByTask.loadingByTaskId[taskId] ?? false : false + ); + const isLoaded = useAppStore((state) => + taskId ? state.taskSessionsByTask.loadedByTaskId[taskId] ?? false : false + ); + const setTaskSessionsForTask = useAppStore((state) => state.setTaskSessionsForTask); + const setTaskSessionsLoading = useAppStore((state) => state.setTaskSessionsLoading); + + const loadSessions = useCallback(async (force = false) => { + if (!taskId) return; + if (!force && (isLoading || isLoaded)) return; + setTaskSessionsLoading(taskId, true); + try { + const response = await listTaskSessions(taskId, { cache: 'no-store' }); + setTaskSessionsForTask(taskId, response.sessions ?? []); + } catch (error) { + console.error('Failed to load task sessions:', error); + setTaskSessionsForTask(taskId, []); + } finally { + setTaskSessionsLoading(taskId, false); + } + }, [isLoaded, isLoading, setTaskSessionsForTask, setTaskSessionsLoading, taskId]); + + useEffect(() => { + if (!taskId) return; + if (isLoaded || isLoading) return; + loadSessions(); + }, [isLoaded, isLoading, loadSessions, taskId]); + + return { sessions, isLoading, isLoaded, loadSessions }; +} diff --git a/apps/web/hooks/use-task.ts b/apps/web/hooks/use-task.ts new file mode 100644 index 0000000000..c611138a35 --- /dev/null +++ b/apps/web/hooks/use-task.ts @@ -0,0 +1,21 @@ +import { useEffect } from 'react'; +import { useAppStore } from '@/components/state-provider'; +import { getWebSocketClient } from '@/lib/ws/connection'; + +export function useTask(taskId: string | null) { + const task = useAppStore((state) => + taskId ? state.kanban.tasks.find((item) => item.id === taskId) ?? null : null + ); + + useEffect(() => { + if (!taskId) return; + const client = getWebSocketClient(); + if (!client) return; + const unsubscribe = client.subscribe(taskId); + return () => { + unsubscribe(); + }; + }, [taskId]); + + return task; +} diff --git a/apps/web/hooks/use-tasks.ts b/apps/web/hooks/use-tasks.ts new file mode 100644 index 0000000000..bccc097e62 --- /dev/null +++ b/apps/web/hooks/use-tasks.ts @@ -0,0 +1,19 @@ +import { useMemo } from 'react'; +import { useBoardSnapshot } from '@/hooks/use-board-snapshot'; +import { useAppStore } from '@/components/state-provider'; + +export function useTasks(boardId: string | null) { + useBoardSnapshot(boardId); + + const kanbanBoardId = useAppStore((state) => state.kanban.boardId); + const tasks = useAppStore((state) => state.kanban.tasks); + + const boardTasks = useMemo(() => { + if (!boardId || kanbanBoardId !== boardId) { + return []; + } + return tasks; + }, [boardId, kanbanBoardId, tasks]); + + return { tasks: boardTasks }; +} diff --git a/apps/web/hooks/use-worktree.ts b/apps/web/hooks/use-worktree.ts new file mode 100644 index 0000000000..ce6f1d8e1a --- /dev/null +++ b/apps/web/hooks/use-worktree.ts @@ -0,0 +1,7 @@ +import { useAppStore } from '@/components/state-provider'; + +export function useWorktree(worktreeId: string | null) { + return useAppStore((state) => + worktreeId ? state.worktrees.items[worktreeId] ?? null : null + ); +} diff --git a/apps/web/lib/http/index.ts b/apps/web/lib/http/index.ts index a9f5ad0aa9..f13190fb71 100644 --- a/apps/web/lib/http/index.ts +++ b/apps/web/lib/http/index.ts @@ -12,8 +12,10 @@ import type { RepositoryBranchesResponse, NotificationProvidersResponse, NotificationProvider, + CreateTaskResponse, Task, TaskSessionsResponse, + TaskSessionResponse, UserSettingsResponse, Workspace, } from '@/lib/types/http'; @@ -42,7 +44,7 @@ async function fetchJson(pathOrUrl: string, options?: ApiRequestOptions): Pro }, }); if (!response.ok) { - throw new Error(`Request failed: ${response.status} ${response.statusText}`); + throw new Error(`Request failed: ${response.status} ${response.statusText} (${url})`); } if (response.status === 204) { return undefined as T; @@ -103,10 +105,12 @@ export async function createTask( default_branch?: string; }>; state?: Task['state']; + start_agent?: boolean; + agent_profile_id?: string; }, options?: ApiRequestOptions ) { - return fetchJson('/api/v1/tasks', { + return fetchJson('/api/v1/tasks', { ...options, init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) }, }); @@ -118,7 +122,6 @@ export async function updateTask( title?: string; description?: string; position?: number; - assigned_to?: string; state?: Task['state']; repositories?: Array<{ repository_id: string; @@ -222,6 +225,10 @@ export async function listTaskSessions(taskId: string, options?: ApiRequestOptio return fetchJson(`/api/v1/tasks/${taskId}/sessions`, options); } +export async function fetchTaskSession(taskSessionId: string, options?: ApiRequestOptions) { + return fetchJson(`/api/v1/task-sessions/${taskSessionId}`, options); +} + export async function listTaskSessionMessages( taskSessionId: string, params?: { limit?: number; before?: string; after?: string; sort?: 'asc' | 'desc' }, diff --git a/apps/web/lib/links.ts b/apps/web/lib/links.ts index ccbb803dd3..9ba5d90476 100644 --- a/apps/web/lib/links.ts +++ b/apps/web/lib/links.ts @@ -1,3 +1,3 @@ -export function linkToTaskSession(taskId: string, sessionId: string): string { - return `/task/${taskId}/${sessionId}`; +export function linkToSession(sessionId: string): string { + return `/s/${sessionId}`; } diff --git a/apps/web/lib/notifications/events.ts b/apps/web/lib/notifications/events.ts index 7d4fcbe3db..bbe93d7bdd 100644 --- a/apps/web/lib/notifications/events.ts +++ b/apps/web/lib/notifications/events.ts @@ -1,5 +1,5 @@ export const NOTIFICATION_EVENT_TASK_SESSION_WAITING_FOR_INPUT = - 'task_session.waiting_for_input'; + 'session.waiting_for_input'; export const DEFAULT_NOTIFICATION_EVENTS = [NOTIFICATION_EVENT_TASK_SESSION_WAITING_FOR_INPUT]; diff --git a/apps/web/lib/ssr/mapper.ts b/apps/web/lib/ssr/mapper.ts index 3761836e40..5e106f0413 100644 --- a/apps/web/lib/ssr/mapper.ts +++ b/apps/web/lib/ssr/mapper.ts @@ -36,20 +36,30 @@ export function snapshotToState(snapshot: BoardSnapshot): Partial { export function taskToState( task: Task, + sessionId?: string | null, messages?: { items: Message[]; hasMore?: boolean; oldestCursor?: string | null } ): Partial { + const resolvedSessionId = + sessionId ?? messages?.items[0]?.session_id ?? null; return { tasks: { activeTaskId: task.id, + activeSessionId: resolvedSessionId, }, - messages: messages - ? { - sessionId: messages.items[0]?.task_session_id ?? null, - items: messages.items, - isLoading: false, - hasMore: messages.hasMore ?? false, - oldestCursor: messages.oldestCursor ?? (messages.items[0]?.id ?? null), - } - : undefined, + messages: + resolvedSessionId && messages + ? { + bySession: { + [resolvedSessionId]: messages.items, + }, + metaBySession: { + [resolvedSessionId]: { + isLoading: false, + hasMore: messages.hasMore ?? false, + oldestCursor: messages.oldestCursor ?? (messages.items[0]?.id ?? null), + }, + }, + } + : undefined, }; } diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index 82c2db36dd..76d5deb2c1 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -2,7 +2,6 @@ import type { ReactNode } from 'react'; import type { Agent, AgentDiscovery, - TaskSessionState, Branch, Environment, Executor, @@ -72,6 +71,7 @@ export type BoardState = { export type TaskState = { activeTaskId: string | null; + activeSessionId: string | null; }; export type AgentState = { @@ -114,9 +114,9 @@ export type TerminalState = { }; export type ShellState = { - // Map of taskId to shell output buffer (raw bytes as string) + // Map of sessionId to shell output buffer (raw bytes as string) outputs: Record; - // Map of taskId to shell status + // Map of sessionId to shell status statuses: Record< string, { @@ -137,8 +137,7 @@ export type FileInfo = { diff?: string; }; -export type GitStatusState = { - taskId: string | null; +export type GitStatusEntry = { branch: string | null; remote_branch: string | null; modified: string[]; @@ -152,6 +151,26 @@ export type GitStatusState = { timestamp: string | null; }; +export type GitStatusState = { + bySessionId: Record; +}; + +export type Worktree = { + id: string; + sessionId: string; + repositoryId?: string; + path?: string; + branch?: string; +}; + +export type WorktreesState = { + items: Record; +}; + +export type SessionWorktreesState = { + itemsBySessionId: Record; +}; + export type DiffState = { files: Array<{ path: string; status: 'A' | 'M' | 'D'; plus: number; minus: number }>; }; @@ -186,17 +205,38 @@ export type ConnectionState = { }; export type MessagesState = { - sessionId: string | null; - items: Message[]; - isLoading: boolean; - hasMore: boolean; - oldestCursor: string | null; + bySession: Record; + metaBySession: Record< + string, + { + isLoading: boolean; + hasMore: boolean; + oldestCursor: string | null; + } + >; }; export type TaskSessionsState = { items: Record; }; +export type TaskSessionsByTaskState = { + itemsByTaskId: Record; + loadingByTaskId: Record; + loadedByTaskId: Record; +}; + +export type SessionAgentctlStatus = { + status: 'starting' | 'ready' | 'error'; + errorMessage?: string; + agentExecutionId?: string; + updatedAt?: string; +}; + +export type SessionAgentctlState = { + itemsBySessionId: Record; +}; + export type AppState = { kanban: KanbanState; workspaces: WorkspaceState; @@ -219,7 +259,10 @@ export type AppState = { connection: ConnectionState; messages: MessagesState; taskSessions: TaskSessionsState; - taskSessionStatesByTaskId: Record; + taskSessionsByTask: TaskSessionsByTaskState; + sessionAgentctl: SessionAgentctlState; + worktrees: WorktreesState; + sessionWorktreesBySessionId: SessionWorktreesState; permissions: PermissionsState; hydrate: (state: Partial) => void; setActiveWorkspace: (workspaceId: string | null) => void; @@ -238,32 +281,45 @@ export type AppState = { setSettingsData: (next: Partial) => void; setUserSettings: (settings: UserSettingsState) => void; setTerminalOutput: (terminalId: string, data: string) => void; - appendShellOutput: (taskId: string, data: string) => void; + appendShellOutput: (sessionId: string, data: string) => void; setShellStatus: ( - taskId: string, + sessionId: string, status: { available: boolean; running?: boolean; shell?: string; cwd?: string } ) => void; - clearShellOutput: (taskId: string) => void; + clearShellOutput: (sessionId: string) => void; setConnectionStatus: (status: ConnectionState['status'], error?: string | null) => void; setMessages: ( - sessionId: string | null, + sessionId: string, messages: Message[], meta?: { hasMore?: boolean; oldestCursor?: string | null } ) => void; - setMessagesSessionId: (sessionId: string | null) => void; addMessage: (message: Message) => void; updateMessage: (message: Message) => void; - prependMessages: (messages: Message[], meta?: { hasMore?: boolean; oldestCursor?: string | null }) => void; - setMessagesMetadata: (meta: { hasMore?: boolean; isLoading?: boolean; oldestCursor?: string | null }) => void; - setMessagesLoading: (loading: boolean) => void; + prependMessages: ( + sessionId: string, + messages: Message[], + meta?: { hasMore?: boolean; oldestCursor?: string | null } + ) => void; + setMessagesMetadata: ( + sessionId: string, + meta: { hasMore?: boolean; isLoading?: boolean; oldestCursor?: string | null } + ) => void; + setMessagesLoading: (sessionId: string, loading: boolean) => void; + setActiveSession: (taskId: string, sessionId: string) => void; + setActiveTask: (taskId: string) => void; + clearActiveSession: () => void; setTaskSession: (session: TaskSession) => void; - setTaskSessionState: (taskId: string, state: TaskSessionState) => void; - setGitStatus: (taskId: string, gitStatus: Omit) => void; - clearGitStatus: () => void; + setTaskSessionsForTask: (taskId: string, sessions: TaskSession[]) => void; + setTaskSessionsLoading: (taskId: string, loading: boolean) => void; + setSessionAgentctlStatus: (sessionId: string, status: SessionAgentctlStatus) => void; + setWorktree: (worktree: Worktree) => void; + setSessionWorktrees: (sessionId: string, worktreeIds: string[]) => void; + setGitStatus: (sessionId: string, gitStatus: GitStatusEntry) => void; + clearGitStatus: (sessionId: string) => void; bumpAgentProfilesVersion: () => void; addPendingPermission: (permission: PendingPermission) => void; removePendingPermission: (pendingId: string) => void; - clearPendingPermissions: (taskId?: string) => void; + clearPendingPermissions: (sessionId?: string) => void; }; export type AppStore = ReturnType; @@ -279,7 +335,7 @@ const defaultState: AppState = { repositories: { itemsByWorkspaceId: {}, loadingByWorkspaceId: {}, loadedByWorkspaceId: {} }, repositoryBranches: { itemsByRepositoryId: {}, loadingByRepositoryId: {}, loadedByRepositoryId: {} }, settingsData: { executorsLoaded: false, environmentsLoaded: false, agentsLoaded: false }, - tasks: { activeTaskId: null }, + tasks: { activeTaskId: null, activeSessionId: null }, agents: { agents: [] }, agentProfiles: { items: [], version: 0 }, userSettings: { @@ -292,24 +348,14 @@ const defaultState: AppState = { terminal: { terminals: [] }, shell: { outputs: {}, statuses: {} }, diffs: { files: [] }, - gitStatus: { - taskId: null, - branch: null, - remote_branch: null, - modified: [], - added: [], - deleted: [], - untracked: [], - renamed: [], - ahead: 0, - behind: 0, - files: {}, - timestamp: null, - }, + gitStatus: { bySessionId: {} }, connection: { status: 'disconnected', error: null }, - messages: { sessionId: null, items: [], isLoading: false, hasMore: false, oldestCursor: null }, + messages: { bySession: {}, metaBySession: {} }, taskSessions: { items: {} }, - taskSessionStatesByTaskId: {}, + taskSessionsByTask: { itemsByTaskId: {}, loadingByTaskId: {}, loadedByTaskId: {} }, + sessionAgentctl: { itemsBySessionId: {} }, + worktrees: { items: {} }, + sessionWorktreesBySessionId: { itemsBySessionId: {} }, permissions: { pending: [] }, hydrate: () => undefined, setActiveWorkspace: () => undefined, @@ -333,14 +379,20 @@ const defaultState: AppState = { clearShellOutput: () => undefined, setConnectionStatus: () => undefined, setMessages: () => undefined, - setMessagesSessionId: () => undefined, addMessage: () => undefined, updateMessage: () => undefined, prependMessages: () => undefined, setMessagesMetadata: () => undefined, setMessagesLoading: () => undefined, + setActiveSession: () => undefined, + setActiveTask: () => undefined, + clearActiveSession: () => undefined, setTaskSession: () => undefined, - setTaskSessionState: () => undefined, + setTaskSessionsForTask: () => undefined, + setTaskSessionsLoading: () => undefined, + setSessionAgentctlStatus: () => undefined, + setWorktree: () => undefined, + setSessionWorktrees: () => undefined, setGitStatus: () => undefined, clearGitStatus: () => undefined, bumpAgentProfilesVersion: () => undefined, @@ -375,14 +427,20 @@ function mergeInitialState( | 'clearShellOutput' | 'setConnectionStatus' | 'setMessages' - | 'setMessagesSessionId' | 'addMessage' | 'updateMessage' | 'prependMessages' | 'setMessagesMetadata' | 'setMessagesLoading' + | 'setActiveSession' + | 'setActiveTask' + | 'clearActiveSession' | 'setTaskSession' - | 'setTaskSessionState' + | 'setTaskSessionsForTask' + | 'setTaskSessionsLoading' + | 'setSessionAgentctlStatus' + | 'setWorktree' + | 'setSessionWorktrees' | 'setGitStatus' | 'clearGitStatus' | 'bumpAgentProfilesVersion' @@ -413,9 +471,12 @@ function mergeInitialState( connection: { ...defaultState.connection, ...initialState.connection }, messages: { ...defaultState.messages, ...initialState.messages }, taskSessions: { ...defaultState.taskSessions, ...initialState.taskSessions }, - taskSessionStatesByTaskId: { - ...defaultState.taskSessionStatesByTaskId, - ...initialState.taskSessionStatesByTaskId, + taskSessionsByTask: { ...defaultState.taskSessionsByTask, ...initialState.taskSessionsByTask }, + sessionAgentctl: { ...defaultState.sessionAgentctl, ...initialState.sessionAgentctl }, + worktrees: { ...defaultState.worktrees, ...initialState.worktrees }, + sessionWorktreesBySessionId: { + ...defaultState.sessionWorktreesBySessionId, + ...initialState.sessionWorktreesBySessionId, }, permissions: { ...defaultState.permissions, ...initialState.permissions }, }; @@ -446,11 +507,19 @@ export function createAppStore(initialState?: Partial) { if (state.terminal) Object.assign(draft.terminal, state.terminal); if (state.shell) Object.assign(draft.shell, state.shell); if (state.diffs) Object.assign(draft.diffs, state.diffs); + if (state.gitStatus) Object.assign(draft.gitStatus, state.gitStatus); if (state.connection) Object.assign(draft.connection, state.connection); if (state.messages) Object.assign(draft.messages, state.messages); if (state.taskSessions) Object.assign(draft.taskSessions, state.taskSessions); - if (state.taskSessionStatesByTaskId) { - Object.assign(draft.taskSessionStatesByTaskId, state.taskSessionStatesByTaskId); + if (state.taskSessionsByTask) { + Object.assign(draft.taskSessionsByTask, state.taskSessionsByTask); + } + if (state.sessionAgentctl) { + Object.assign(draft.sessionAgentctl, state.sessionAgentctl); + } + if (state.worktrees) Object.assign(draft.worktrees, state.worktrees); + if (state.sessionWorktreesBySessionId) { + Object.assign(draft.sessionWorktreesBySessionId, state.sessionWorktreesBySessionId); } if (state.permissions) Object.assign(draft.permissions, state.permissions); }), @@ -539,17 +608,17 @@ export function createAppStore(initialState?: Partial) { draft.terminal.terminals.push({ id: terminalId, output: [data] }); } }), - appendShellOutput: (taskId, data) => + appendShellOutput: (sessionId, data) => set((draft) => { - draft.shell.outputs[taskId] = (draft.shell.outputs[taskId] || '') + data; + draft.shell.outputs[sessionId] = (draft.shell.outputs[sessionId] || '') + data; }), - setShellStatus: (taskId, status) => + setShellStatus: (sessionId, status) => set((draft) => { - draft.shell.statuses[taskId] = status; + draft.shell.statuses[sessionId] = status; }), - clearShellOutput: (taskId) => + clearShellOutput: (sessionId) => set((draft) => { - draft.shell.outputs[taskId] = ''; + draft.shell.outputs[sessionId] = ''; }), setConnectionStatus: (status, error = null) => set((draft) => { @@ -558,132 +627,167 @@ export function createAppStore(initialState?: Partial) { }), setMessages: (sessionId, messages, meta) => set((draft) => { - draft.messages.sessionId = sessionId; - draft.messages.items = messages; - draft.messages.isLoading = false; - draft.messages.hasMore = meta?.hasMore ?? false; - if (meta?.oldestCursor !== undefined) { - draft.messages.oldestCursor = meta.oldestCursor; - } else if (messages.length) { - draft.messages.oldestCursor = messages[0].id; - } else { - draft.messages.oldestCursor = null; - } - }), - setMessagesSessionId: (sessionId) => - set((draft) => { - draft.messages.sessionId = sessionId; - // Initialize items array if null to allow addMessage to work - // before setMessages is called - if (draft.messages.items === null) { - draft.messages.items = []; - } + draft.messages.bySession[sessionId] = messages; + const existingMeta = draft.messages.metaBySession[sessionId]; + draft.messages.metaBySession[sessionId] = { + isLoading: false, + hasMore: meta?.hasMore ?? existingMeta?.hasMore ?? false, + oldestCursor: + meta?.oldestCursor ?? (messages.length ? messages[0].id : existingMeta?.oldestCursor ?? null), + }; }), addMessage: (message) => set((draft) => { - // Initialize items array if null - if (draft.messages.items === null) { - draft.messages.items = []; + if (!message.session_id) { + return; } - if (!draft.messages.sessionId) { - draft.messages.sessionId = message.task_session_id; + const sessionId = message.session_id; + const list = draft.messages.bySession[sessionId] ?? []; + const exists = list.some((item) => item.id === message.id); + if (exists) { + return; } - // Only add if this message is for the current session - if (draft.messages.sessionId === message.task_session_id) { - // Check if message already exists (avoid duplicates) - const exists = draft.messages.items.some((item) => item.id === message.id); - if (!exists) { - draft.messages.items.push(message); - if (!draft.messages.oldestCursor) { - draft.messages.oldestCursor = message.id; - } - } + draft.messages.bySession[sessionId] = [...list, message]; + const meta = draft.messages.metaBySession[sessionId] ?? { + isLoading: false, + hasMore: false, + oldestCursor: null, + }; + if (!meta.oldestCursor) { + meta.oldestCursor = message.id; } + draft.messages.metaBySession[sessionId] = meta; }), updateMessage: (message) => set((draft) => { - if (!draft.messages.sessionId) { - draft.messages.sessionId = message.task_session_id; + if (!message.session_id) { + return; } - // Only update if this message is for the current session - if (draft.messages.sessionId === message.task_session_id && draft.messages.items) { - const index = draft.messages.items.findIndex((item) => item.id === message.id); - if (index !== -1) { - draft.messages.items[index] = message; - } + const sessionId = message.session_id; + const list = draft.messages.bySession[sessionId]; + if (!list) return; + const index = list.findIndex((item) => item.id === message.id); + if (index !== -1) { + list[index] = message; } }), - prependMessages: (messages, meta) => + prependMessages: (sessionId, messages, meta) => set((draft) => { - if (draft.messages.items === null) { - draft.messages.items = []; - } - const existingIds = new Set(draft.messages.items.map((item) => item.id)); + const existing = draft.messages.bySession[sessionId] ?? []; + const existingIds = new Set(existing.map((item) => item.id)); const incoming = messages.filter((item) => !existingIds.has(item.id)); - if (incoming.length) { - draft.messages.items = [...incoming, ...draft.messages.items]; - } - if (meta?.hasMore !== undefined) { - draft.messages.hasMore = meta.hasMore; - } - if (meta?.oldestCursor !== undefined) { - draft.messages.oldestCursor = meta.oldestCursor; - } else if (draft.messages.items.length) { - draft.messages.oldestCursor = draft.messages.items[0].id; - } else { - draft.messages.oldestCursor = null; - } - draft.messages.isLoading = false; + const next = incoming.length ? [...incoming, ...existing] : existing; + draft.messages.bySession[sessionId] = next; + const currentMeta = draft.messages.metaBySession[sessionId] ?? { + isLoading: false, + hasMore: false, + oldestCursor: null, + }; + draft.messages.metaBySession[sessionId] = { + isLoading: false, + hasMore: meta?.hasMore ?? currentMeta.hasMore, + oldestCursor: + meta?.oldestCursor ?? (next.length ? next[0].id : currentMeta.oldestCursor ?? null), + }; }), - setMessagesMetadata: (meta) => + setMessagesMetadata: (sessionId, meta) => set((draft) => { - if (meta.hasMore !== undefined) { - draft.messages.hasMore = meta.hasMore; - } - if (meta.isLoading !== undefined) { - draft.messages.isLoading = meta.isLoading; - } - if (meta.oldestCursor !== undefined) { - draft.messages.oldestCursor = meta.oldestCursor; - } + const currentMeta = draft.messages.metaBySession[sessionId] ?? { + isLoading: false, + hasMore: false, + oldestCursor: null, + }; + draft.messages.metaBySession[sessionId] = { + isLoading: meta.isLoading ?? currentMeta.isLoading, + hasMore: meta.hasMore ?? currentMeta.hasMore, + oldestCursor: meta.oldestCursor ?? currentMeta.oldestCursor, + }; }), - setMessagesLoading: (loading) => + setMessagesLoading: (sessionId, loading) => set((draft) => { - draft.messages.isLoading = loading; + const currentMeta = draft.messages.metaBySession[sessionId] ?? { + isLoading: false, + hasMore: false, + oldestCursor: null, + }; + currentMeta.isLoading = loading; + draft.messages.metaBySession[sessionId] = currentMeta; + }), + setActiveSession: (taskId, sessionId) => + set((draft) => { + draft.tasks.activeTaskId = taskId; + draft.tasks.activeSessionId = sessionId; + }), + setActiveTask: (taskId) => + set((draft) => { + draft.tasks.activeTaskId = taskId; + draft.tasks.activeSessionId = null; + }), + clearActiveSession: () => + set((draft) => { + draft.tasks.activeTaskId = null; + draft.tasks.activeSessionId = null; }), setTaskSession: (session) => set((draft) => { draft.taskSessions.items[session.id] = session; - // Also update the task session state by task ID - draft.taskSessionStatesByTaskId[session.task_id] = session.state; + if (session.worktree_id) { + draft.worktrees.items[session.worktree_id] = { + id: session.worktree_id, + sessionId: session.id, + repositoryId: session.repository_id ?? undefined, + path: session.worktree_path ?? undefined, + branch: session.worktree_branch ?? undefined, + }; + draft.sessionWorktreesBySessionId.itemsBySessionId[session.id] = [session.worktree_id]; + } }), - setTaskSessionState: (taskId, state) => + setTaskSessionsForTask: (taskId, sessions) => set((draft) => { - draft.taskSessionStatesByTaskId[taskId] = state; + sessions.forEach((session) => { + draft.taskSessions.items[session.id] = session; + if (session.worktree_id) { + draft.worktrees.items[session.worktree_id] = { + id: session.worktree_id, + sessionId: session.id, + repositoryId: session.repository_id ?? undefined, + path: session.worktree_path ?? undefined, + branch: session.worktree_branch ?? undefined, + }; + draft.sessionWorktreesBySessionId.itemsBySessionId[session.id] = [session.worktree_id]; + } + }); + draft.taskSessionsByTask.itemsByTaskId[taskId] = sessions; + draft.taskSessionsByTask.loadedByTaskId[taskId] = true; + draft.taskSessionsByTask.loadingByTaskId[taskId] = false; }), - setGitStatus: (taskId, gitStatus) => + setTaskSessionsLoading: (taskId, loading) => set((draft) => { - draft.gitStatus = { - taskId, - ...gitStatus, - }; + draft.taskSessionsByTask.loadingByTaskId[taskId] = loading; }), - clearGitStatus: () => + setSessionAgentctlStatus: (sessionId, status) => set((draft) => { - draft.gitStatus = { - taskId: null, - branch: null, - remote_branch: null, - modified: [], - added: [], - deleted: [], - untracked: [], - renamed: [], - ahead: 0, - behind: 0, - files: {}, - timestamp: null, - }; + draft.sessionAgentctl.itemsBySessionId[sessionId] = status; + }), + setWorktree: (worktree) => + set((draft) => { + draft.worktrees.items[worktree.id] = worktree; + const existing = draft.sessionWorktreesBySessionId.itemsBySessionId[worktree.sessionId] ?? []; + if (!existing.includes(worktree.id)) { + draft.sessionWorktreesBySessionId.itemsBySessionId[worktree.sessionId] = [...existing, worktree.id]; + } + }), + setSessionWorktrees: (sessionId, worktreeIds) => + set((draft) => { + draft.sessionWorktreesBySessionId.itemsBySessionId[sessionId] = worktreeIds; + }), + setGitStatus: (sessionId, gitStatus) => + set((draft) => { + draft.gitStatus.bySessionId[sessionId] = gitStatus; + }), + clearGitStatus: (sessionId) => + set((draft) => { + delete draft.gitStatus.bySessionId[sessionId]; }), bumpAgentProfilesVersion: () => set((draft) => { @@ -701,10 +805,10 @@ export function createAppStore(initialState?: Partial) { set((draft) => { draft.permissions.pending = draft.permissions.pending.filter((p) => p.pending_id !== pendingId); }), - clearPendingPermissions: (taskId) => + clearPendingPermissions: (sessionId) => set((draft) => { - if (taskId) { - draft.permissions.pending = draft.permissions.pending.filter((p) => p.task_id !== taskId); + if (sessionId) { + draft.permissions.pending = draft.permissions.pending.filter((p) => p.session_id !== sessionId); } else { draft.permissions.pending = []; } diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index 1dc61e9437..066f0d51aa 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -7,7 +7,7 @@ export type BackendMessageType = | 'agent.updated' | 'terminal.output' | 'diff.update' - | 'git.status' + | 'session.git.status' | 'system.error' | 'workspace.created' | 'workspace.updated' @@ -18,10 +18,13 @@ export type BackendMessageType = | 'column.created' | 'column.updated' | 'column.deleted' - | 'message.added' - | 'message.updated' - | 'task_session.state_changed' - | 'task_session.waiting_for_input' + | 'session.message.added' + | 'session.message.updated' + | 'session.state_changed' + | 'session.waiting_for_input' + | 'session.agentctl_starting' + | 'session.agentctl_ready' + | 'session.agentctl_error' | 'executor.created' | 'executor.updated' | 'executor.deleted' @@ -32,8 +35,8 @@ export type BackendMessageType = | 'agent.profile.created' | 'agent.profile.updated' | 'user.settings.updated' - | 'workspace.file.changes' - | 'shell.output' + | 'session.workspace.file.changes' + | 'session.shell.output' | 'permission.requested'; export type BackendMessage = { @@ -133,7 +136,7 @@ export type ColumnPayload = { export type MessageAddedPayload = { task_id: string; message_id: string; - task_session_id: string; + session_id: string; author_type: 'user' | 'agent'; author_id?: string; content: string; @@ -145,18 +148,25 @@ export type MessageAddedPayload = { export type TaskSessionStateChangedPayload = { task_id: string; - task_session_id: string; + session_id: string; old_state?: string; new_state: string; }; export type TaskSessionWaitingForInputPayload = { task_id: string; - task_session_id: string; + session_id: string; title: string; body: string; }; +export type TaskSessionAgentctlPayload = { + task_id: string; + session_id: string; + agent_execution_id?: string; + error_message?: string; +}; + export type FileInfo = { path: string; status: 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed'; @@ -168,6 +178,7 @@ export type FileInfo = { export type GitStatusPayload = { task_id: string; + session_id?: string; branch: string; remote_branch?: string; modified: string[]; @@ -237,6 +248,7 @@ export type UserSettingsUpdatedPayload = { export type ShellOutputPayload = { task_id: string; + session_id: string; type: 'output' | 'exit'; data?: string; code?: number; @@ -271,7 +283,7 @@ export type BackendMessageMap = { 'agent.updated': BackendMessage<'agent.updated', AgentUpdatePayload>; 'terminal.output': BackendMessage<'terminal.output', TerminalOutputPayload>; 'diff.update': BackendMessage<'diff.update', DiffUpdatePayload>; - 'git.status': BackendMessage<'git.status', GitStatusPayload>; + 'session.git.status': BackendMessage<'session.git.status', GitStatusPayload>; 'system.error': BackendMessage<'system.error', SystemErrorPayload>; 'workspace.created': BackendMessage<'workspace.created', WorkspacePayload>; 'workspace.updated': BackendMessage<'workspace.updated', WorkspacePayload>; @@ -282,10 +294,13 @@ export type BackendMessageMap = { 'column.created': BackendMessage<'column.created', ColumnPayload>; 'column.updated': BackendMessage<'column.updated', ColumnPayload>; 'column.deleted': BackendMessage<'column.deleted', ColumnPayload>; - 'message.added': BackendMessage<'message.added', MessageAddedPayload>; - 'message.updated': BackendMessage<'message.updated', MessageAddedPayload>; - 'task_session.state_changed': BackendMessage<'task_session.state_changed', TaskSessionStateChangedPayload>; - 'task_session.waiting_for_input': BackendMessage<'task_session.waiting_for_input', TaskSessionWaitingForInputPayload>; + 'session.message.added': BackendMessage<'session.message.added', MessageAddedPayload>; + 'session.message.updated': BackendMessage<'session.message.updated', MessageAddedPayload>; + 'session.state_changed': BackendMessage<'session.state_changed', TaskSessionStateChangedPayload>; + 'session.waiting_for_input': BackendMessage<'session.waiting_for_input', TaskSessionWaitingForInputPayload>; + 'session.agentctl_starting': BackendMessage<'session.agentctl_starting', TaskSessionAgentctlPayload>; + 'session.agentctl_ready': BackendMessage<'session.agentctl_ready', TaskSessionAgentctlPayload>; + 'session.agentctl_error': BackendMessage<'session.agentctl_error', TaskSessionAgentctlPayload>; 'executor.created': BackendMessage<'executor.created', ExecutorPayload>; 'executor.updated': BackendMessage<'executor.updated', ExecutorPayload>; 'executor.deleted': BackendMessage<'executor.deleted', ExecutorPayload>; @@ -296,8 +311,8 @@ export type BackendMessageMap = { 'agent.profile.created': BackendMessage<'agent.profile.created', AgentProfileChangedPayload>; 'agent.profile.updated': BackendMessage<'agent.profile.updated', AgentProfileChangedPayload>; 'user.settings.updated': BackendMessage<'user.settings.updated', UserSettingsUpdatedPayload>; - 'workspace.file.changes': BackendMessage<'workspace.file.changes', FileChangeNotificationPayload>; - 'shell.output': BackendMessage<'shell.output', ShellOutputPayload>; + 'session.workspace.file.changes': BackendMessage<'session.workspace.file.changes', FileChangeNotificationPayload>; + 'session.shell.output': BackendMessage<'session.shell.output', ShellOutputPayload>; 'permission.requested': BackendMessage<'permission.requested', PermissionRequestedPayload>; }; diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 6ae78571c3..91c89983c2 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -100,7 +100,6 @@ export type Task = { state: TaskState; priority: number; repositories?: TaskRepository[]; - assigned_agent_id?: string | null; created_at: string; updated_at: string; metadata?: Record | null; @@ -109,6 +108,11 @@ export type Task = { worktree_branch?: string | null; }; +export type CreateTaskResponse = Task & { + session_id?: string; + agent_execution_id?: string; +}; + export type TaskSession = { id: string; task_id: string; @@ -140,6 +144,10 @@ export type TaskSessionsResponse = { total: number; }; +export type TaskSessionResponse = { + session: TaskSession; +}; + export type NotificationProviderType = 'local' | 'apprise'; export type NotificationProvider = { @@ -303,7 +311,7 @@ export type MessageType = export type Message = { id: string; - task_session_id: string; + session_id: string; task_id: string; author_type: MessageAuthorType; author_id?: string; diff --git a/apps/web/lib/ws/client.ts b/apps/web/lib/ws/client.ts index 2514e47d7d..269511a9aa 100644 --- a/apps/web/lib/ws/client.ts +++ b/apps/web/lib/ws/client.ts @@ -42,8 +42,9 @@ export class WebSocketClient { private reconnectAttempts = 0; private reconnectTimer: ReturnType | null = null; private intentionalClose = false; - private subscriptions = new Set(); - private userSubscriptionActive = false; + private subscriptions = new Map(); + private sessionSubscriptions = new Map(); + private userSubscriptionCount = 0; private heartbeatTimer: ReturnType | null = null; private heartbeatTimeoutTimer: ReturnType | null = null; private lastMessageTime: number = Date.now(); @@ -178,8 +179,10 @@ export class WebSocketClient { } subscribe(taskId: string) { - this.subscriptions.add(taskId); - if (this.status === 'open') { + const currentCount = this.subscriptions.get(taskId) ?? 0; + const nextCount = currentCount + 1; + this.subscriptions.set(taskId, nextCount); + if (this.status === 'open' && nextCount === 1) { this.send({ id: generateUUID(), type: 'request', @@ -187,11 +190,27 @@ export class WebSocketClient { payload: { task_id: taskId }, }); } + return () => this.unsubscribe(taskId); + } + + subscribeSession(sessionId: string) { + const currentCount = this.sessionSubscriptions.get(sessionId) ?? 0; + const nextCount = currentCount + 1; + this.sessionSubscriptions.set(sessionId, nextCount); + if (this.status === 'open' && nextCount === 1) { + this.send({ + id: generateUUID(), + type: 'request', + action: 'session.subscribe', + payload: { session_id: sessionId }, + }); + } + return () => this.unsubscribeSession(sessionId); } subscribeUser() { - this.userSubscriptionActive = true; - if (this.status === 'open') { + this.userSubscriptionCount += 1; + if (this.status === 'open' && this.userSubscriptionCount === 1) { this.send({ id: generateUUID(), type: 'request', @@ -202,20 +221,46 @@ export class WebSocketClient { } unsubscribe(taskId: string) { - this.subscriptions.delete(taskId); - if (this.status === 'open') { - this.send({ - id: generateUUID(), - type: 'request', - action: 'task.unsubscribe', - payload: { task_id: taskId }, - }); + const currentCount = this.subscriptions.get(taskId); + if (!currentCount) return; + const nextCount = currentCount - 1; + if (nextCount <= 0) { + this.subscriptions.delete(taskId); + if (this.status === 'open') { + this.send({ + id: generateUUID(), + type: 'request', + action: 'task.unsubscribe', + payload: { task_id: taskId }, + }); + } + return; } + this.subscriptions.set(taskId, nextCount); + } + + unsubscribeSession(sessionId: string) { + const currentCount = this.sessionSubscriptions.get(sessionId); + if (!currentCount) return; + const nextCount = currentCount - 1; + if (nextCount <= 0) { + this.sessionSubscriptions.delete(sessionId); + if (this.status === 'open') { + this.send({ + id: generateUUID(), + type: 'request', + action: 'session.unsubscribe', + payload: { session_id: sessionId }, + }); + } + return; + } + this.sessionSubscriptions.set(sessionId, nextCount); } unsubscribeUser() { - this.userSubscriptionActive = false; - if (this.status === 'open') { + this.userSubscriptionCount = Math.max(0, this.userSubscriptionCount - 1); + if (this.status === 'open' && this.userSubscriptionCount === 0) { this.send({ id: generateUUID(), type: 'request', @@ -299,7 +344,7 @@ export class WebSocketClient { private resubscribe() { // Re-subscribe to all tasks after reconnection - this.subscriptions.forEach((taskId) => { + this.subscriptions.forEach((_count, taskId) => { this.send({ id: generateUUID(), type: 'request', @@ -307,7 +352,15 @@ export class WebSocketClient { payload: { task_id: taskId }, }); }); - if (this.userSubscriptionActive) { + this.sessionSubscriptions.forEach((_count, sessionId) => { + this.send({ + id: generateUUID(), + type: 'request', + action: 'session.subscribe', + payload: { session_id: sessionId }, + }); + }); + if (this.userSubscriptionCount > 0) { this.send({ id: generateUUID(), type: 'request', diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts index 61c52d674b..eb73ad5116 100644 --- a/apps/web/lib/ws/handlers/agent-session.ts +++ b/apps/web/lib/ws/handlers/agent-session.ts @@ -5,17 +5,14 @@ import type { TaskSessionState } from '@/lib/types/http'; export function registerTaskSessionHandlers(store: StoreApi): WsHandlers { return { - 'task_session.state_changed': (message) => { + 'session.state_changed': (message) => { const payload = message.payload; if (!payload?.task_id || !payload?.new_state) { return; } const taskId = payload.task_id; const newState = payload.new_state as TaskSessionState; - const sessionId = payload.task_session_id; - - // Update task session state by task ID - store.getState().setTaskSessionState(taskId, newState); + const sessionId = payload.session_id; // Also update or create the session object if we have the session ID if (sessionId) { @@ -37,7 +34,61 @@ export function registerTaskSessionHandlers(store: StoreApi): WsHandle updated_at: '', }); } + + const sessionsByTask = store.getState().taskSessionsByTask.itemsByTaskId[taskId]; + if (sessionsByTask) { + const hasSession = sessionsByTask.some((session) => session.id === sessionId); + if (!hasSession) { + store.getState().setTaskSessionsForTask(taskId, [...sessionsByTask, { + id: sessionId, + task_id: taskId, + state: newState, + progress: 0, + started_at: '', + updated_at: '', + }]); + } else { + const nextSessions = sessionsByTask.map((session) => + session.id === sessionId ? { ...session, state: newState } : session + ); + store.getState().setTaskSessionsForTask(taskId, nextSessions); + } + } } }, + 'session.agentctl_starting': (message) => { + const payload = message.payload; + if (!payload?.session_id) { + return; + } + store.getState().setSessionAgentctlStatus(payload.session_id, { + status: 'starting', + agentExecutionId: payload.agent_execution_id, + updatedAt: message.timestamp, + }); + }, + 'session.agentctl_ready': (message) => { + const payload = message.payload; + if (!payload?.session_id) { + return; + } + store.getState().setSessionAgentctlStatus(payload.session_id, { + status: 'ready', + agentExecutionId: payload.agent_execution_id, + updatedAt: message.timestamp, + }); + }, + 'session.agentctl_error': (message) => { + const payload = message.payload; + if (!payload?.session_id) { + return; + } + store.getState().setSessionAgentctlStatus(payload.session_id, { + status: 'error', + agentExecutionId: payload.agent_execution_id, + errorMessage: payload.error_message, + updatedAt: message.timestamp, + }); + }, }; } diff --git a/apps/web/lib/ws/handlers/git-status.ts b/apps/web/lib/ws/handlers/git-status.ts index b749813e50..d872d8fba9 100644 --- a/apps/web/lib/ws/handlers/git-status.ts +++ b/apps/web/lib/ws/handlers/git-status.ts @@ -4,29 +4,20 @@ import type { WsHandlers } from '@/lib/ws/handlers/types'; export function registerGitStatusHandlers(store: StoreApi): WsHandlers { return { - 'git.status': (message) => { + 'session.git.status': (message) => { const payload = message.payload; + if (!payload.session_id) { + return; + } console.log('[WS] git.status received:', { - task_id: payload.task_id, + session_id: payload.session_id, branch: payload.branch, modified: payload.modified.length, added: payload.added.length, deleted: payload.deleted.length, untracked: payload.untracked.length, }); - const state = store.getState(); - - // Only update git status if it's for the current task - // This prevents stale data from showing when switching tasks - if (state.tasks.activeTaskId !== payload.task_id) { - console.log('[WS] Ignoring git.status for different task:', { - current_task: state.tasks.activeTaskId, - received_task: payload.task_id, - }); - return; - } - - state.setGitStatus(payload.task_id, { + store.getState().setGitStatus(payload.session_id, { branch: payload.branch, remote_branch: payload.remote_branch ?? null, modified: payload.modified, diff --git a/apps/web/lib/ws/handlers/messages.ts b/apps/web/lib/ws/handlers/messages.ts index dd67f7c235..66d1e44ba3 100644 --- a/apps/web/lib/ws/handlers/messages.ts +++ b/apps/web/lib/ws/handlers/messages.ts @@ -4,17 +4,14 @@ import type { WsHandlers } from '@/lib/ws/handlers/types'; export function registerMessagesHandlers(store: StoreApi): WsHandlers { return { - 'message.added': (message) => { + 'session.message.added': (message) => { const payload = message.payload; - console.log('[WS] message.added payload:', JSON.stringify(payload, null, 2)); - const state = store.getState(); - console.log('[WS] store state:', { - hasAddMessage: typeof state.addMessage, - messagesSessionId: state.messages?.sessionId, - }); - state.addMessage({ + if (!payload.session_id) { + return; + } + store.getState().addMessage({ id: payload.message_id, - task_session_id: payload.task_session_id, + session_id: payload.session_id, task_id: payload.task_id, author_type: payload.author_type, author_id: payload.author_id, @@ -24,15 +21,15 @@ export function registerMessagesHandlers(store: StoreApi): WsHandlers requests_input: payload.requests_input, created_at: payload.created_at, }); - console.log('[WS] addMessage called'); }, - 'message.updated': (message) => { + 'session.message.updated': (message) => { const payload = message.payload; - console.log('[WS] message.updated payload:', JSON.stringify(payload, null, 2)); - const state = store.getState(); - state.updateMessage({ + if (!payload.session_id) { + return; + } + store.getState().updateMessage({ id: payload.message_id, - task_session_id: payload.task_session_id, + session_id: payload.session_id, task_id: payload.task_id, author_type: payload.author_type, author_id: payload.author_id, @@ -42,7 +39,6 @@ export function registerMessagesHandlers(store: StoreApi): WsHandlers requests_input: payload.requests_input, created_at: payload.created_at, }); - console.log('[WS] updateMessage called'); }, }; } diff --git a/apps/web/lib/ws/handlers/tasks.ts b/apps/web/lib/ws/handlers/tasks.ts index 8080d9c18b..f602f5e8f5 100644 --- a/apps/web/lib/ws/handlers/tasks.ts +++ b/apps/web/lib/ws/handlers/tasks.ts @@ -67,20 +67,11 @@ export function registerTasksHandlers(store: StoreApi): WsHandlers { })); }, 'task.state_changed': (message) => { - console.log('[WS Router] task.state_changed received:', { - task_id: message.payload.task_id, - board_id: message.payload.board_id, - column_id: message.payload.column_id, - state: message.payload.state, - }); store.setState((state) => { - console.log('[WS Router] Current board_id:', state.kanban.boardId, 'Event board_id:', message.payload.board_id); if (state.kanban.boardId !== message.payload.board_id) { - console.log('[WS Router] Skipping - board_id mismatch'); return state; } const existingTask = state.kanban.tasks.find((t) => t.id === message.payload.task_id); - console.log('[WS Router] Existing task:', existingTask); const nextTask = { id: message.payload.task_id, columnId: message.payload.column_id, @@ -90,7 +81,6 @@ export function registerTasksHandlers(store: StoreApi): WsHandlers { state: message.payload.state, repositoryId: message.payload.repository_id ?? existingTask?.repositoryId, }; - console.log('[WS Router] Next task:', nextTask); return { ...state, kanban: { diff --git a/apps/web/lib/ws/handlers/terminals.ts b/apps/web/lib/ws/handlers/terminals.ts index 29b1666cd4..be5defbfa6 100644 --- a/apps/web/lib/ws/handlers/terminals.ts +++ b/apps/web/lib/ws/handlers/terminals.ts @@ -7,13 +7,16 @@ export function registerTerminalsHandlers(store: StoreApi): WsHandlers 'terminal.output': (message) => { store.getState().setTerminalOutput(message.payload.terminalId, message.payload.data); }, - 'shell.output': (message) => { - const { task_id, type, data } = message.payload; + 'session.shell.output': (message) => { + const { session_id, type, data } = message.payload; + if (!session_id) { + return; + } if (type === 'output' && data) { - store.getState().appendShellOutput(task_id, data); + store.getState().appendShellOutput(session_id, data); } else if (type === 'exit') { // Shell exited - update status - store.getState().setShellStatus(task_id, { available: false }); + store.getState().setShellStatus(session_id, { available: false }); } }, }; diff --git a/apps/web/lib/ws/workspace-files.ts b/apps/web/lib/ws/workspace-files.ts index d90214b30c..05941e5708 100644 --- a/apps/web/lib/ws/workspace-files.ts +++ b/apps/web/lib/ws/workspace-files.ts @@ -6,12 +6,12 @@ import type { FileTreeResponse, FileContentResponse } from '@/lib/types/backend' */ export async function requestFileTree( client: WebSocketClient, - taskId: string, + sessionId: string, path: string = '', depth: number = 1 ): Promise { return client.request('workspace.tree.get', { - task_id: taskId, + session_id: sessionId, path, depth, }); @@ -22,11 +22,11 @@ export async function requestFileTree( */ export async function requestFileContent( client: WebSocketClient, - taskId: string, + sessionId: string, path: string ): Promise { return client.request('workspace.file.get', { - task_id: taskId, + session_id: sessionId, path, }); } diff --git a/docs/agent-orchestration-ui.md b/docs/agent-orchestration-ui.md new file mode 100644 index 0000000000..994da89ba9 --- /dev/null +++ b/docs/agent-orchestration-ui.md @@ -0,0 +1,249 @@ +# Agent Orchestration + Session UI Data Flow + +This document describes how a task session is created, how agentctl/agent lifecycle events flow through the backend, and how the session details page consumes those events. + +## Overview (Actors) + +- **Client UI**: Next.js app with session details page and panels (chat, terminal, git, files). +- **Backend**: Orchestrator + WebSocket gateway + task/session store. +- **agentctl**: Sidecar HTTP/WS server inside agent container (or standalone process) that manages the agent subprocess and exposes ACP + workspace streams. +- **Agent**: ACP-speaking process (JSON-RPC over stdin/stdout) that performs work. + +## Orchestration Flow (Task Session Creation) + +``` +Client UI + | HTTP: POST /api/v1/tasks { start_agent: true, agent_profile_id } + v +Backend Task Service + | create task + first session + | start agentctl orchestration + | create task_session (STARTING) + | launch agentctl (container or standalone) + | wait for /health + | POST /api/v1/start + | POST /api/v1/acp/call initialize + | POST /api/v1/acp/call session/new + | POST /api/v1/acp/call session/prompt + v +agentctl + | spawns agent process + | streams ACP updates + workspace streams + v +Agent + | ACP notifications (session/update) + v +agentctl -> Backend + | ACP update stream -> backend + | backend updates task_session state + messages + | backend publishes WS notifications + v +Client UI + | session.subscribe + | receive session-scoped events +``` + +## Backend Event Routing (Session Scoped) + +Events are now routed primarily by **session_id**. The WS gateway maintains **session subscriber lists**. It broadcasts session events only to clients subscribed to that session. + +``` +agentctl WS /api/v1/acp/stream + -> backend ACP handler + -> task_session state update + -> message persisted + -> WS broadcast: session.channel + +agentctl WS /api/v1/workspace/git-status/stream + -> backend git-status handler + -> attach to session + -> WS broadcast: session.channel + +agentctl WS /api/v1/output/stream + -> backend shell handler + -> session resolve (session_id -> task_id) + -> WS broadcast: session.channel +``` + +Session notifications: +- `session.agentctl_starting` +- `session.agentctl_ready` +- `session.agentctl_error` +- `session.state_changed` +- `session.message.added`, `session.message.updated` +- `session.shell.output`, `session.shell.status` +- `session.git.status` +- `session.workspace.file.changes` + +## Frontend Session Page Flow (SSR + WS) + +``` +Server (SSR) + - fetch session by id + - fetch parent task + workspace context + - hydrate store (tasks, sessions, repos, agent profiles) + +Client (Hydrated) + - read sessionId from URL (/s/:sessionId) + - set activeSessionId in store + - subscribe to session events + - child components read from store + hooks +``` + +### Component Subscriptions + +Each component subscribes to the data it needs via hooks. The **WS client deduplicates subscriptions** so multiple components can safely request the same channel. + +``` +TaskSessionPage (/s/:sessionId) + -> useSession(sessionId) + -> subscribe: session.{sessionId} + + TaskChatPanel + -> useSessionMessages(sessionId) + -> subscribe: session.{sessionId} + + ShellTerminal + -> useSessionAgentctl(sessionId) + -> subscribe: session.{sessionId} + -> useShellOutput(sessionId) + -> subscribe: session.{sessionId} + + TaskChangesPanel (Git) + -> useSessionGitStatus(sessionId) + -> subscribe: session.{sessionId} + + TaskFilesPanel + -> useSessionAgentctl(sessionId) + -> wait for readiness + -> useSessionWorktrees(sessionId) + -> subscribe: session.{sessionId} +``` + +## Event + State Flow Diagram + +``` +Agent + | ACP session/update + v +agentctl + | WS /api/v1/acp/stream + v +Backend ACP handler + | update session state/messages + | emit session WS events + v +WS Gateway (session subscribers) + | broadcast to session.{sessionId} + v +WebSocket Client (browser) + | dedupe subscription + | route to store slice + v +Zustand Store + | sessions, messages, shell, git, worktrees + v +Session UI Panels + | hook selectors + | render +``` + +## Concrete Example: New Session -> Message Appears In Chat + +Scenario: user starts a task session and the agent replies with a completion message that appears in the chat panel. + +### Sequence (Calls + Events) + +1) Client -> Backend (HTTP request) +``` +POST /api/v1/tasks +body: + title: "Example task" + workspace_id: "workspace-1" + repository_id: "repo-9" + agent_profile_id: "profile-abc" + start_agent: true +``` + +2) Backend (internal) +- creates task +- creates first task session +- starts agentctl orchestration for the new session +- returns session_id in the HTTP response + +3) Backend -> agentctl (HTTP) +- `POST /api/v1/start` +- `POST /api/v1/acp/call` initialize +- `POST /api/v1/acp/call` session/new +- `POST /api/v1/acp/call` session/prompt + +4) Backend -> Client (WS notification) +``` +action: session.agentctl_starting +payload: + task_id: "task-123" + session_id: "session-789" +``` + +5) Client subscribes to session +``` +action: session.subscribe +payload: + session_id: "session-789" +``` + +6) agentctl -> Backend (ACP notification) +``` +jsonrpc: "2.0" +method: "session/update" +params: + type: "content" + data: + text: "Working on your request..." + session_id: "session-789" +``` + +7) Backend -> Client (WS notification) +``` +action: session.message.added +payload: + session_id: "session-789" + message: + id: "msg-1" + author_type: "agent" + content: "Working on your request..." +``` + +8) Client store update (message handler) +``` +messages.bySession["session-789"] += message +``` + +9) Chat panel render +``` +TaskChatPanel -> useSessionMessages("session-789") -> messages list -> render +``` + +### Full Event List (Typical Session Start) + +- HTTP request: `POST /api/v1/tasks` with `start_agent: true` +- WS notification: `session.agentctl_starting` +- WS request: `session.subscribe` +- WS notification: `session.agentctl_ready` +- ACP notifications from agent: + - `session/update` (content/toolCall/complete/error) +- WS notifications: + - `session.message.added` + - `session.message.updated` + - `session.state_changed` + - `session.shell.status` / `session.shell.output` (if shell active) + - `session.git.status` (if git watcher active) + - `session.workspace.file.changes` (if watcher active) + +## Notes / Guardrails + +- Session is the source of truth; task id is derived from session when needed. +- No fallback to “active task” or “first message” for session ownership. +- Agentctl readiness is explicit; panels that require a live agent should wait for `agentctl_ready`. +- Store slices are keyed by session id (messages, shell output, git status, worktrees). +- WS subscriptions are **session-scoped** and **deduplicated** at the client level. diff --git a/docs/agent-permission-request.md b/docs/agent-permission-request.md new file mode 100644 index 0000000000..acec96a7a2 --- /dev/null +++ b/docs/agent-permission-request.md @@ -0,0 +1,104 @@ +# Agent Permission Requests + +This document describes how permission requests flow between the agent, backend, and frontend. + +## Overview +Some agents (e.g., Auggie) require user approval for sensitive tool actions (run command, write file, etc.). The agentctl sidecar streams permission requests to the backend, which then broadcasts them to the UI. The user responds via the UI, and the backend forwards the response back to agentctl. + +## High-Level Flow +``` +Agent (ACP/CLI) + | + | permission_request (tool call needs approval) + v +agentctl (permission stream) + | + | WS /api/v1/acp/permissions/stream + v +Backend (lifecycle manager) + | + | events.ACPMessage (type=permission_request) + v +Backend (WS gateway) + | + | session.permission.requested (WS notification) + v +Frontend (CommandApprovalDialog / tool-call UI) + | + | permission.respond (WS request) + v +Backend (orchestrator) + | + | RespondToPermissionBySessionID + v +agentctl (POST /api/v1/acp/permissions/respond) + | + v +Agent continues (approved/rejected) +``` + +## Backend Components +- **agentctl permission stream** + - agentctl exposes `GET /api/v1/acp/permissions/stream`. + - Streams `PermissionNotification` from the agent process. +- **lifecycle StreamManager** + - Connects to the permission stream. + - Publishes `events.ACPMessage` with `type=permission_request`. +- **orchestrator watcher** + - Parses ACP messages and forwards permission requests to WS clients. +- **gateway websocket** + - Broadcasts `session.permission.requested` to session subscribers. +- **orchestrator handlers** + - Handles `permission.respond` and forwards to agentctl. + +## Frontend Components +- **CommandApprovalDialog** + - Shows permission requests not tied to a visible tool call. +- **ToolCallMessage inline actions** + - Shows approve/reject inline for tool call messages. +- **WebSocket client** + - Subscribes to `session.permission.requested`. + - Sends `permission.respond` with `{ session_id, pending_id, option_id, cancelled }`. + +## Message Shapes + +### Permission Requested (WS notification) +```json +{ + "type": "notification", + "action": "session.permission.requested", + "payload": { + "pending_id": "perm-123", + "session_id": "session-abc", + "task_id": "task-xyz", + "tool_call_id": "tool-456", + "title": "Command Execution Approval", + "options": [ + { "option_id": "allow_once", "name": "Allow once", "kind": "allow_once" }, + { "option_id": "reject_once", "name": "Reject", "kind": "reject_once" } + ], + "action_type": "command", + "action_details": { "command": "rm -rf .", "cwd": "/workspace" }, + "created_at": "2026-01-18T00:00:00Z" + } +} +``` + +### Permission Respond (WS request) +```json +{ + "type": "request", + "action": "permission.respond", + "payload": { + "session_id": "session-abc", + "pending_id": "perm-123", + "option_id": "allow_once", + "cancelled": false + } +} +``` + +## Notes +- Permissions are scoped to a **session**, not a task. +- If the session has no active UI, the pending permission is still stored on the backend and sent when the client subscribes. +- The agent can define different tools that require permission via its registry config. diff --git a/docs/agentctl-lifecycle.md b/docs/agentctl-lifecycle.md new file mode 100644 index 0000000000..d683257149 --- /dev/null +++ b/docs/agentctl-lifecycle.md @@ -0,0 +1,127 @@ +# Agentctl session lifecycle (Kandev) + +This document explains how **agentctl** fits into the Kandev execution flow, what “ready” means, and which parts of the system depend on agentctl being up. + +## What agentctl is + +`agentctl` is a sidecar process that runs inside each agent container (or standalone runtime). It provides: + +- **HTTP API** for lifecycle operations (`/health`, `/api/v1/start`, `/api/v1/stop`, etc.) +- **ACP bridge** (stdin/stdout JSON‑RPC) to the agent process (Auggie/Codex/etc.) +- **WebSockets** for streaming ACP updates and agent output +- **Shell access** to the workspace (terminal output + input) + +It is the backend’s *control plane* for an agent execution. + +## High‑level flow (runtime‑agnostic) + +``` +User action (start task) + | + v +Kandev orchestrator + | + | 1) Create task session in DB + | 2) Launch agent runtime (Docker or standalone) + v +agentctl process starts + | + | 3) HTTP server comes up (health + control APIs) + v +Backend waits for agentctl readiness + | + | 4) agentctl ready => shell/workspace access available + v +ACP session initialization + | + | 5) initialize -> session/new or session/load + | 6) prompt is sent + v +Agent runs, ACP updates stream + | + | 7) session/update notifications => WS to client + v +Task session state transitions +``` + +## “Ready” concepts (important distinction) + +There are **two different “ready” moments**: + +1) **agentctl ready** + - Meaning: agentctl HTTP server is healthy and can accept requests. + - Practical impact: shell/workspace endpoints become usable. + - Source: `lifecycle.Manager.waitForAgentctlReady`. + - Today: only logged; **not emitted as a WS event**. + +2) **agent ready** (prompt completed) + - Meaning: agent finished the current prompt and is idle/ready for follow‑up. + - Source: `events.AgentReady` (after prompt completion). + - Today: internal event for orchestrator state transitions. + +The frontend should not rely on “agent ready” when it needs shell access; shell access depends on agentctl readiness instead. + +## Current backend signals + +- **agentctl readiness** + - `agentctl is ready` (agentctl client log) + - `agentctl ready - shell/workspace access available` (lifecycle manager log) + +- **agent ready** (post‑prompt) + - `events.AgentReady` internal event (used by orchestrator state transitions) + +There is **no WS event** that tells the client “agentctl is ready.” + +## Where shell streaming depends on agentctl + +- Client calls `shell.subscribe` (WS request) +- Backend checks: + - agent execution exists for the task + - agentctl is connected +- Backend starts a streaming loop that relays agentctl shell output as `shell.output` WS notifications + +If agentctl isn’t ready yet, `shell.subscribe` fails and the frontend currently retries. + +## Suggested improvement (optional) + +Introduce a WS notification like: + +``` +{ "action": "task.session.agentctl_ready", "payload": { "task_id": "...", "task_session_id": "..." } } +``` + +So the client can subscribe once and only request shell after it receives readiness. + +## End‑to‑end diagram (detailed) + +``` +Client Backend (Kandev) agentctl Agent + | | | | + | orchestrator.start | | | + |------------------------------->| | | + | | create task session | | + | | launch runtime | | + | |---------------------------->| process starts | + | | | HTTP server up | + | | waitForAgentctlReady | | + | |---------------------------->| /health | + | |<----------------------------| 200 OK | + | | (agentctl ready) | | + | | ACP initialize | | + | |---------------------------->| initialize | + | | ACP session/new | | + | |---------------------------->| session/new | + | | ACP prompt | | + | |---------------------------->| session/prompt | + | | |--------------------->| + | | | agent runs | + | | WS stream | | + |<-------------------------------| (session/update notifications) | +``` + +## References + +- `apps/backend/internal/agent/lifecycle/manager.go` (waitForAgentctlReady) +- `apps/backend/internal/agentctl/client/client.go` (WaitForReady) +- `apps/backend/internal/agent/handlers/shell_handlers.go` (shell.subscribe) +- `apps/backend/internal/events/types.go` (`AgentReady`) diff --git a/docs/relation-events.md b/docs/relation-events.md new file mode 100644 index 0000000000..722d60914b --- /dev/null +++ b/docs/relation-events.md @@ -0,0 +1,124 @@ +# Relationship Events LLD + +This document defines backend-driven state propagation and how WS notifications mirror those changes to the UI. Clients do **not** emit these events. + +## Core Rules + +1) **Session state changes drive task state changes**. +2) **Agentctl lifecycle changes drive session state changes**, which then drive task state changes. +3) **Task state changes may move columns**, but only between **In Progress** and **Review** for now. + +All state propagation happens **server-side** on internal events. WebSocket events are outbound mirrors for UI synchronization. + +--- + +## 1) Session State -> Task State + +**Trigger (backend only)** +- `events.TaskSessionStateChanged` (internal event bus) + +**Flow** +1. Resolve `task_id` from `session_id`. +2. Map `TaskSessionState -> TaskState` (table below). +3. If task state changes, call `taskSvc.UpdateTaskState(taskID, newState)`. +4. Emit `events.TaskStateChanged` and WS `task.state_changed`. +5. Emit WS `session.state_changed` for clients. + +**Mapping Table** + +| TaskSessionState | TaskState | Board Column | +|-------------------|---------------|-----------------------| +| CREATED | SCHEDULING | In Progress (if any) | +| STARTING | SCHEDULING | In Progress (if any) | +| RUNNING | IN_PROGRESS | In Progress | +| WAITING_FOR_INPUT | REVIEW | Review | +| COMPLETED | REVIEW | Review | +| ERROR | ERROR | (no auto-move) | +| CANCELLED | CANCELLED | (no auto-move) | + +Notes: +- `WAITING_FOR_INPUT -> REVIEW` and `COMPLETED -> REVIEW` are the default paths. If a board has no Review column, keep task in its current column. +- If the mapped task state equals current task state, do nothing. + +--- + +## 2) Agentctl State -> Session State -> Task State + +**Triggers (backend only)** +- `events.AgentctlStarting` +- `events.AgentctlReady` +- `events.AgentctlError` + +**Agentctl -> Session Mapping** + +| Agentctl Event | Session State | +|--------------------------|---------------| +| AgentctlStarting | STARTING | +| AgentctlReady | RUNNING | +| AgentctlError | FAILED | + +**Flow** +1. On Agentctl event, update session state in DB. +2. Emit `events.TaskSessionStateChanged` (which triggers session->task mapping). +3. Emit WS `session.agentctl_*` and `session.state_changed`. + +--- + +## 3) Task State -> Column Move + +**Trigger (backend only)** +- `events.TaskStateChanged` + +**Policy (for now)** +- Only move tasks **between In Progress and Review**. +- Do not auto-move for other states. + +**Flow** +1. When task state changes: + - If new state is `IN_PROGRESS`, move to column mapped to In Progress (if exists). + - If new state is `REVIEW`, move to column mapped to Review (if exists). +2. If column lookup fails or column already matches, do nothing. +3. Emit `events.TaskUpdated` and WS `task.updated` with new `column_id`. + +--- + +## WebSocket Notifications (Outbound Only) + +- `session.state_changed` +- `session.agentctl_starting` +- `session.agentctl_ready` +- `session.agentctl_error` +- `task.state_changed` +- `task.updated` + +Clients should treat these as **read-only state mirrors**. + +--- + +## E2E Flow (Client-Initiated) + +| Edge Trigger (Client) | Backend Action | Internal Event(s) | WS Notification(s) | +|----------------------------------------------|---------------------------------------------------|--------------------------------------|-------------------------------------------------| +| `POST /api/v1/tasks` (start_agent: true) | Create task + create session + start agentctl | task.created, task_session.created | task.created, session.agentctl_starting | +| `POST /api/v1/tasks` (start_agent: false) | Create task + create session (idle) | task.created, task_session.created | task.created | +| `message.add` (session message) | Persist message + prompt agent via ACP | message.added | session.message.added | +| `task.state` (manual update) | Update task state | task.state_changed | task.state_changed, task.updated (column move) | +| `task.session.resume` | Resume session + start agentctl | task_session.state_changed | session.agentctl_starting, session.state_changed | + +Notes: +- Column move only occurs for `IN_PROGRESS` / `REVIEW` transitions. + +--- + +## E2E Flow (Agent / agentctl-Initiated) + +| Edge Trigger (Agent/agentctl) | Backend Action | Internal Event(s) | WS Notification(s) | +|----------------------------------------------|---------------------------------------------------|--------------------------------------|-------------------------------------------------| +| Agentctl starting | Update session state → STARTING | task_session.state_changed | session.agentctl_starting, session.state_changed | +| Agentctl ready | Update session state → RUNNING | task_session.state_changed | session.agentctl_ready, session.state_changed | +| Agentctl error | Update session state → ERROR | task_session.state_changed | session.agentctl_error, session.state_changed | +| ACP content/progress | Create/append message chunks | message.added / message.updated | session.message.added / session.message.updated | +| ACP input_required | Create message (requests_input=true) | message.added | session.message.added + session.waiting_for_input | +| Git status stream update | Update session git status | git.status.updated | session.git.status | +| Workspace file change stream | Update session file activity | workspace.file.changed | session.workspace.file.changes | +| Shell output stream | Append shell output | shell.output | session.shell.output | diff --git a/docs/session-page-data-flow.md b/docs/session-page-data-flow.md new file mode 100644 index 0000000000..346d545e92 --- /dev/null +++ b/docs/session-page-data-flow.md @@ -0,0 +1,174 @@ +# Task Session Page Data Flow (SSR + WS + Store) + +This document maps the current data flow for `/task/:id/:sessionId` and highlights which components/hooks depend on which data. It also calls out places where hooks could be made more granular or shared. + +## SSR Entry Point + +Route: `apps/web/app/task/[id]/[sessionId]/page.tsx` + +Server fetches: +- `fetchTask(taskId)` +- `fetchBoardSnapshot(task.board_id)` +- `listAgents()` +- `listRepositories(task.workspace_id)` +- `listTaskSessions(task.id)` for each task in board +- `listTaskSessionMessages(sessionId)` (best-effort) + +Hydration: +- `snapshotToState(snapshot)` → kanban columns + tasks +- `taskToState(task, sessionId, messages?)` → `tasks.activeTaskId`, `tasks.activeSessionId`, `messages.bySession[sessionId]` +- Repositories + agent profiles are merged into store +- Render `` and `` + +## Client Composition + +``` +TaskSessionPage (SSR) + └─ TaskPageClient + ├─ TaskTopBar + └─ TaskLayout + ├─ TaskSessionSidebar (left) + ├─ TaskCenterPanel (chat/changes/notes) + └─ TaskRightPanel (files + terminal) +``` + +## Active Selection (Store) + +Store slice: `tasks.activeTaskId`, `tasks.activeSessionId` +- Set on initial mount by `TaskPageClient` from URL + SSR task. +- Updated when selecting a task/session in sidebar. + +## WebSocket Data Flow + +WebSocket client: +- `apps/web/lib/ws/client.ts` with deduplicated subscriptions. +- Components call `client.subscribe(taskId)`; dedup ensures single WS subscription. +- On reconnect, re-subscribes to active task channels. + +Handlers (global router): +- `message.added` / `message.updated` → `messages.bySession[sessionId]` +- `task_session.state_changed` → `taskSessionStatesByTaskId` + `taskSessions.items` +- `git.status` → `gitStatus` (keyed by task) +- `shell.output` → `shell.outputs[taskId]` + +## Component Data Dependencies + +### TaskPageClient (`apps/web/app/task/[id]/page-client.tsx`) +- Props: `task`, `sessionId`, `initialSessionsByTask`, `initialRepositories`, `initialAgentProfiles` +- Store: + - `tasks.activeTaskId`, `tasks.activeSessionId` via `setActiveSession` + - `kanban.tasks`, `kanban.columns`, `workspaces.items` + - `agentProfiles.items` +- Hooks: + - `useSessionResumption(task?.id, sessionId)` + - `useTaskAgent(task)` (agent state, task.execution polling) + - `useRepositories(workspaceId)` (fallback to SSR data) +- WS: + - Subscribes to all tasks in board (deduped) + - Listens for `task_session.state_changed` to refresh `sessionsByTask` +- Responsibilities: + - URL sync via `history.replaceState` + - Task selection triggers `fetchTask` + `listTaskSessions` + +### TaskSessionSidebar (`apps/web/components/task/task-session-sidebar.tsx`) +- Store: + - `tasks.activeTaskId` + - `tasks.activeSessionId` +- Props: + - tasks + columns + sessionsByTask from `TaskPageClient` +- Uses: + - `TaskSessionSwitcher` (sessions list) + - `TaskSwitcher` (tasks list) +- Calls back to parent for selection and session list reload. + +### TaskCenterPanel (`apps/web/components/task/task-center-panel.tsx`) +- Props: `taskId`, `onSendMessage`, `selectedDiffPath`, `openFileRequest` +- Includes: + - `TaskChatPanel` + - `TaskChangesPanel` + - `FileViewerContent` + +### TaskChatPanel (`apps/web/components/task/task-chat-panel.tsx`) +- Store: + - `tasks.activeSessionId` (fallback unless `sessionId` prop passed) +- Hooks: + - `useTaskSession(sessionId)` → taskId + session details + - `useTaskMessages(taskId, sessionId)` → messages + meta + - `useLazyLoadMessages(sessionId)` → pagination +- Data: + - Renders from `messages.bySession[sessionId]` + +### TaskRightPanel (`apps/web/components/task/task-right-panel.tsx`) +- Store: + - `tasks.activeSessionId` +- Uses: + - `ShellTerminal taskId + activeSessionId` + +### ShellTerminal (`apps/web/components/task/shell-terminal.tsx`) +- Store: + - `shell.outputs[taskId]` + - `taskSessions.items[sessionId]?.state` or `taskSessionStatesByTaskId[taskId]` +- WS: + - Subscribes to shell only when session is RUNNING or WAITING_FOR_INPUT. + +### TaskFilesPanel / TaskChangesPanel +- Store: + - `gitStatus` keyed by active task +- No direct WS subscriptions. + +## ASCII Diagram (Current) + +``` +SSR +┌──────────────────────────────────────────────────────────────┐ +│ /task/:id/:sessionId │ +│ fetchTask + snapshot + agents + repos + sessions + messages │ +│ StateHydrator -> store (activeTaskId + activeSessionId) │ +└──────────────────────────────────────────────────────────────┘ + +Client +┌─────────────────────────────── TaskPageClient ──────────────────────────────┐ +│ setActiveSession(taskId, sessionId) │ +│ subscribe(board tasks) + listen task_session.state_changed │ +│ │ +│ ┌─ TaskSessionSidebar (sessions + tasks) │ +│ ├─ TaskCenterPanel │ +│ │ └─ TaskChatPanel │ +│ │ ├─ useTaskSession(sessionId) │ +│ │ ├─ useTaskMessages(taskId, sessionId) │ +│ │ └─ useLazyLoadMessages(sessionId) │ +│ └─ TaskRightPanel │ +│ └─ ShellTerminal (shell.output + session state) │ +└──────────────────────────────────────────────────────────────────────────────┘ + +WS + task.subscribe -> deduped in client + message.added/updated -> messages.bySession + task_session.state_changed -> taskSessions + taskSessionStatesByTaskId + git.status -> gitStatus (task) + shell.output -> shell.outputs[task] +``` + +## Hooks Review (Granularity + Reuse) + +Current hooks: +- `useTaskMessages(taskId, sessionId)` + - Fetches + sets loading meta + subscribes to task updates. + - Could be split into: + - `useSessionMessages(sessionId)` (only fetch + store) + - `useTaskSubscription(taskId)` (subscription only) +- `useLazyLoadMessages(sessionId)` uses store meta; OK as a focused pagination hook. +- `useTaskSession(sessionId)` derives taskId from messages if session not in store. + - Can be simplified once session list is guaranteed in store. +- `useTaskAgent(task)` + `useSessionResumption(taskId, sessionId)` + - Both access execution state. Consider a combined `useTaskExecution(taskId, sessionId)` hook. +- `useRepositories(workspaceId)` currently handles fetch + loading; OK but could be generalized (e.g. `useListResource`). + +Suggested split for sharing: +- `useTaskSubscription(taskId)` → handles `client.subscribe(taskId)` only. +- `useSessionMessages(sessionId)` → fetch + store; no taskId. +- `useActiveSession()` selector → centralizes active session usage. +- `useTaskGitStatus(taskId)` → pure selector + optional subscribe. +- `useShellOutput(taskId)` → pure selector + optional subscribe. + +This would allow panels to mix and match only what they need without pulling in extra behavior. diff --git a/docs/session-resumption-ll.md b/docs/session-resumption-ll.md new file mode 100644 index 0000000000..340c53f466 --- /dev/null +++ b/docs/session-resumption-ll.md @@ -0,0 +1,243 @@ +# Session Resumption LLD + +## Goals +- Support multiple active sessions per task. +- Resume sessions after backend restart without relying on user navigation. +- Distinguish resumable vs non-resumable agents (no `resume_token`). +- Keep session state consistent across backend, agentctl, and UI. +- Support local and remote executors. + +## Key Assumptions +- A session is the unit of execution; each session has at most one active agent execution. +- Task can have multiple sessions concurrently (active or historical). +- Agents that support session resumption provide a `resume_token` in session metadata. +- Agents that do not support resumption cannot resume after restart. +- Remote executors keep running across backend restarts; the backend must reconnect. + +## Data Model (existing + additions) +- `task_sessions` + - `id`, `task_id`, `state`, `agent_profile_id`, `agent_execution_id`, `metadata` (includes `resume_token`) + - `worktree_id`, `worktree_path`, `worktree_branch` + - `executor_id` (local/remote) +- `executors_running` (persistent table) + - `id` (executor instance id), `session_id`, `task_id`, `runtime` + - `agentctl_url`, `agentctl_port`, `container_id` (docker), `pid` (local) + - `status`: `starting | ready | error | stopped` + - `supports_session_resume`, `supports_workspace_only` + - `resume_token` + - `workspace_path`, `worktree_id`, `worktree_path`, `worktree_branch` + - `last_seen_at`, `error_message` +- `executors_cache` (in-memory only) + - `execution_id`, `session_id`, `runtime`, `status`, `agentctl_url/port`, `workspace_path` + +Note: agentctl state is derived from `executors_running.status` and health checks. + +## Agent Capability +- Add `supports_session_resume` boolean to agent registry or agent profile. +- When `false`, mark session as non-resumable on backend restart. +- UI should show: “This agent cannot resume previous sessions. Create a new session.” + +## Executor Types +- **Local Docker**: containers survive backend restart; recover via Docker APIs. +- **Local Standalone**: agentctl processes are local and typically *do not* survive restart. +- **Remote Executor**: agentctl runs remotely; survives backend restart; backend must reconnect. + +## Startup Flow (Backend) +Goal: recover agentctl instances from `executors_running` on backend start, not when a user opens a session. + +1) **Load executors** + - Query `executors_running` where `status` in (`starting`, `ready`). + +2) **Reject non-resumable executors** + - If `supports_session_resume=false`, mark related sessions `ERROR` and set `error_message=not_resumable`. + +3) **Resume executors by runtime** + - **Local Docker**: + - Verify Docker daemon running. + - Locate containers by `executors_running.container_id` or session label. + - If container missing: mark executor `error`, mark sessions `ERROR`. + - If container running: healthcheck agentctl. + - **Local Standalone**: + - Verify worktree path exists. + - If missing: mark executor `error`, mark sessions `ERROR`. + - Start agentctl process and healthcheck. + - **Remote Executor**: + - Call `Health()` on `agentctl_url`. + - If unreachable: mark executor `error`, mark sessions `ERROR`. + +4) **Sync executor cache** + - Register running executions in `executors_cache` keyed by `session_id`. + - Update `executors_running.last_seen_at`. + +5) **Enable workspace access** + - If agentctl is healthy, set `executors_running.status=ready`. + - Shell/fs/git are enabled regardless of agent resume. + +6) **Resume agent session** + - If `supports_session_resume=true` and `resume_token` present, resume agent. + - On success: set `session.state = WAITING_FOR_INPUT`. + - On failure: set `session.state = ERROR`, update `error_message`. + +7) **Publish updates** + - Emit session agentctl events to UI. + +## Executor Resumption Capability +Executors should report whether agent session resume is supported by the runtime + agent, so the backend can decouple executor lifecycle from agent lifecycle. + +### Agentctl Capability Probe +After agentctl is reachable, call a capability endpoint (new or existing) to determine: +- `supports_session_resume`: whether the agent can resume with the session resumption token. +- `supports_workspace_only`: whether workspace-only mode is available (for git/fs/shell without agent). + +Note: `requires_new_session` is redundant with `supports_session_resume=false`. If we keep it, it must be a derived/alias field, not an independent signal. + +This allows:\n +- Executor resume (agentctl + workspace streams) even if agent resume is not possible. +- The UI to enable shell/git/files regardless of agent resumption outcome. + +## Session Resumption Flow (Backend) +Triggered when user explicitly requests resume. + +1) Validate session: + - Session exists and belongs to task. + - Agent profile supports resume and session has `resume_token`. + - Worktree exists if required. + +2) Create agentctl instance (local only): + - Call `LaunchAgent` with `session_id`, `worktree_path`, `worktree_branch`. + - Persist `executors_running.status=starting`. + +3) Start agent process and resume: + - Resume agent session with `resume_token`. + - On success: + - `session.state = WAITING_FOR_INPUT` + - `executors_running.status = ready` + - `task.state = REVIEW` + - On failure: + - `session.state = ERROR` + - `executors_running.status = error` + - Persist `error_message`. + +## Session Status API +### `task.session.status` +Return session-specific status (no task-level fallback): +- `session_id`, `task_id`, `state`, `agent_profile_id` +- `executor_status`, `agent_execution_id`, `runtime` +- `is_agent_running` (based on execution for that session) +- `is_resumable` (resume supported + `resume_token` present) +- `needs_resume` (no running execution + resumable) +- `resume_reason` (e.g., `agent_not_running`, `not_resumable`, `missing_resume_token`) +- `worktree_path`, `worktree_branch` + +## WebSocket Events +Session-scoped events: +- `session.agentctl_starting` +- `session.agentctl_ready` +- `session.agentctl_error` +- `session.state_changed` +- `session.message.added`, `session.message.updated` + +Payloads must include `session_id` and `task_id`. + +## Frontend Flow +1) **SSR page** loads session + task + messages and hydrates store. +2) **useSessionResumption** + - Calls `task.session.status`. + - If `needs_resume && is_resumable`: show “Resume session” CTA or auto-resume (configurable). + - If `!is_resumable`: show “Create new session” CTA. +3) **UI states** + - `executor_status=starting`: show spinner and disable shell/file panels. + - `executor_status=ready`: allow shell/file access. + - `executor_status=error`: show error badge and retry/resume action. + +## Multi-Session per Task Considerations +- Executor and lifecycle manager must key executions by `session_id` (done). +- `task.session.status` must only check `execution.SessionID == sessionID`. +- WebSocket subscriptions should be per-session to avoid cross-session updates. + +## Non-Resumable Agents +- Add `supports_session_resume=false` in agent registry/profile. +- Backend returns `is_resumable=false` in status. +- UI displays a banner: “This agent doesn’t support resuming. Start a new session.” + +## Open Decisions +- Whether to keep `executors_running` lean or store full runtime metadata. +- Auto-resume on backend startup vs on-demand (current request: startup). +- Whether to auto-resume all resumable sessions or only those in RUNNING/WAITING_FOR_INPUT. +- Remote executor reconnect timeout/backoff policy. + +## Resumption Flow Diagrams + +### Local Docker Executor (agentctl container survives) +``` +Backend Restart + | + |-- load executors_running (starting/ready) + |-- docker: RecoverInstances() + | -> map container to executors_running.id or session_id label + |-- agentctl Health() + |-- agentctl Capabilities? (supports_session_resume) + |-- register AgentExecution(session_id, runtime=local_docker) + |-- executors_running.status = ready (workspace-only enabled) + | + |-- if supports_session_resume & resume_token present: + | ResumeSession + | session.state = WAITING_FOR_INPUT + | else: + | session.state = ERROR (not_resumable) + | + '-- WS events to UI: + session.agentctl_ready / session.state_changed +``` + +### Local Standalone Executor (agentctl does NOT survive) +``` +Backend Restart + | + |-- load executors_running (starting/ready) + |-- no local instances to recover + |-- LaunchAgent from executors_running (workspace-only) + |-- executors_running.status = starting + |-- agentctl Health() + |-- agentctl Capabilities? (supports_session_resume) + |-- executors_running.status = ready (workspace-only enabled) + | + |-- if supports_session_resume & resume_token present: + | ResumeSession + | session.state = WAITING_FOR_INPUT + | else: + | session.state = ERROR (not_resumable) + | + '-- WS events to UI: + session.agentctl_starting / ready / error +``` + +### Remote Executor (agentctl survives remotely) +``` +Backend Restart + | + |-- load executors_running: agentctl_url, id + |-- connect to agentctl Health() + |-- agentctl Capabilities? (supports_session_resume) + |-- register AgentExecution(session_id, runtime=remote) + |-- executors_running.status = ready (workspace-only enabled) + | + |-- if supports_session_resume & resume_token present: + | ResumeSession + | session.state = WAITING_FOR_INPUT + | else: + | session.state = ERROR (not_resumable) + | + '-- WS events to UI: + session.agentctl_ready / session.state_changed +``` + +## Executor vs Agent Resumption +Separating executor resume from agent resume allows: +- Shell/git/files to function as soon as agentctl is reachable. +- Agent resume to fail gracefully without losing workspace access. + +Example UI behavior: +1) `session.agentctl_ready` -> enable shell/git/files panels. +2) If `supports_session_resume=false` -> show “Create new session to continue messaging.” +3) If resume fails -> show error but keep workspace panels active.