3d2a04e776
* feat(orchestrator): reuse existing same-profile session on workflow step switch
When a workflow step transition needs a different agent profile, look up
an existing session on the same task that already uses the target profile
and promote it back to primary instead of creating a fresh one. Reuses
COMPLETED and FAILED sessions (CANCELLED stays excluded — explicit user
stops shouldn't be auto-revived).
Backend (event_handlers_workflow.go):
- findReusableSessionForProfile: most recently updated session matching
the target profile, skipping CANCELLED.
- reuseSessionForStep: promotes the session to primary; if terminal,
revives via reviveReusedSession.
- reviveReusedSession: distinguishes "previously launched" (has
executors_running record -> flip to WAITING_FOR_INPUT, lazy-resume via
ResumeSession with stored token) from "never launched" (no record ->
flip to CREATED so autoStartStepPrompt routes through
StartCreatedSession for a full fresh launch). Clears stale
ErrorMessage/CompletedAt so the UI stops surfacing prior failures.
- createNewSessionForStep + completeAndStopSession: shared helpers.
Backend (task_operations.go):
- StartCreatedSession no longer calls processOnTurnStartViaEngine.
User-initiated messages already fire on_turn_start in
dispatchPromptAsync. Workflow-driven auto-start prompts arrived
because the workflow just transitioned us into this step via
on_turn_complete; firing on_turn_start again cascades the workflow
back out and the redirect-to-active-session logic delivers the prompt
to the wrong session's agent.
Frontend (web):
- task.updated handler follows focus to the new primary when the user
is viewing the task and was sitting on the previous primary.
- pinnedSessionId tracks user-initiated session selection
(setActiveSession sets it; setActiveSessionAuto leaves it alone).
The terminal-handoff branch in maybeAdoptSessionOnTransition skips
replacement when the terminating session matches the pin, so manually
clicking a session no longer yanks focus back to the workflow primary
each time the pinned session terminates.
Tests:
- TestSwitchSessionForStep_ReusesExistingProfileSession (never-launched).
- TestSwitchSessionForStep_ReusesPreviouslyLaunchedSession (launched).
- TestSwitchSessionForStep_ReusesFailedSession (FAILED reuse + cleanup).
- tasks.test.ts: focus-follow behavior across 4 scenarios.
- agent-session.test.ts: pinnedSessionId guard plus migration to
setActiveSessionAuto for WS-driven adoption.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(orchestrator): deferred task move with hand-off prompt + boot/turn-end event split
Adds move_task_kandev MCP tool that lets a workflow agent (e.g. QA at "Review")
move a task to another step with a prompt for the receiving agent. The move is
queued as a PendingMove and applied on the calling agent's turn-end so on_enter
processing doesn't race the still-running turn.
Also splits agent.ready into agent.boot_ready + agent.ready at the lifecycle
layer. Previously both signals shared one event and the orchestrator
disambiguated them via a resumeInProgressSessions flag — which had a race
when the boot signal arrived before persistResumeState wrote state=STARTING,
causing the boot to fire on_turn_complete against the just-revived step and
ping-pong the task forward through the workflow.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: address PR #743 review feedback (Greptile + CodeRabbit)
Backend:
- isTransientPromptError no longer treats ErrExecutionNotFound as transient,
preventing executeQueuedMessage from infinite-requeueing on permanent
missing-execution errors. autoStartStepPrompt's explicit errors.Is check
before the transient branch keeps the recovery path working (Greptile P2 #1)
- applyPendingMove drains the orphaned hand-off prompt on ApplyTransition
failure so it can't be misdelivered to the source step on a future turn
(Greptile P2 #2)
- handleMoveTask returns errors instead of panicking when messageQueue is
nil; queueMoveTaskPrompt returns an error so the deferred path fails fast
if the queue can't accept the prompt (CodeRabbit CR-2)
- handleMoveTask rejects when no primary session exists and a hand-off prompt
is required, instead of silently dropping it (CodeRabbit CR-3)
- autoStartStepPrompt preserves attachments from queued messages (was: silently
dropped); attachment-only messages are no longer skipped; the fallback path
also forwards attachments (CodeRabbit CR-5)
- applyPendingMove reinserts the pending move on early failure paths (config
missing, task/step load errors) so a future turn-end retries naturally
(CodeRabbit CR-6)
- registerKanbanTools split into 3 helpers (list/profile/task) to satisfy
backend function-size lint limits (CodeRabbit CR-1)
Web:
- maybeAdoptSessionOnTransition's auto-adopt path also checks pinnedSessionId
to handle the reverse-event-ordering race (old COMPLETED arrives before new
STARTING) — pinned sessions are no longer yanked off the user. Regression
test added (CodeRabbit CR-7)
Tests:
- mockAgentManager records (executionID, prompt) pairs; new helper
capturedPromptsForExecution actually filters by exec ID instead of
returning all captured prompts (CodeRabbit CR-4)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(web): respect pinned session in primary-follow-focus path
When the user clicks a session, setActiveSession sets pinnedSessionId. If a
workflow profile switch then updates the task's primarySessionId while the
user happens to still be on the previous primary, the focus-follow block in
tasks.ts was calling setActiveSessionAuto(taskId, newPrimary) and silently
overriding the explicit pin. Add pinnedSessionId !== previousPrimary as a
fifth condition. Regression test in tasks.test.ts covers exactly this
sequence.
Addresses Greptile P1 on PR #743.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: address CodeRabbit review on PR #743 (3 new comments)
- handleMoveTask idle path now rolls back the queued hand-off prompt when
taskSvc.MoveTask fails, so a "you were moved..." message can't be delivered
on a future turn for a transition that didn't actually happen. Added
TakeQueued to the MessageQueuer interface to support this.
- lookupSession now returns (*models.TaskSession, error) so callers can
distinguish a backend lookup failure from a legitimate "no primary session"
state. handleMoveTask maps the error case to ErrorCodeInternalError instead
of collapsing it into ErrorCodeValidation.
- queueMoveTaskPrompt returns errors instead of log-and-continue (signature
change already shipped); existing tests updated to reflect the new contract.
- Refactored TestPendingMove_ReviewToInProgress_OneTransitionOnly: the
~280-line monolithic test is now split into a pendingMoveScenario builder
+ assertion helpers, each well under the 80-line / 50-statement lint limit.
All 14 review threads resolved.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: relax move_task_kandev prompt requirement; fix CI lint
CI surfaced two issues after rebasing onto main:
- nestif lint warning on autoStartStepPrompt's queued-message merge block.
Extracted the take-and-merge logic into takeAndMergeHandoffMessage helper,
flattening the nested conditionals.
- E2E test settings/config-management 'agent can move a task to a different
step' failed because my CR-3 fix made the move_task_kandev prompt parameter
required. That broke admin/config-mode moves where there's no agent to
hand off to. Made prompt optional: when supplied AND the source session
is mid-turn, the deferred-move flow runs (queue prompt + record
PendingMove); otherwise just move the task. Refactored handleMoveTask
into deferMoveTaskWithPrompt and applyMoveTaskImmediate to keep each
function under the 80-line lint limit.
Tool descriptions in mcp/server/server.go and mcp/server/config_handlers.go
updated to reflect prompt's now-optional status. moveTaskHandler in
config_handlers.go forwards prompt only when set.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(mcp): treat 'no primary session' as a not-found, not an internal error
GetPrimarySessionByTaskID returns a plain error string ("no primary session
found for task: <id>") when the repo has no rows. My CR-B refactor was
treating ALL lookup errors as ErrorCodeInternalError, which broke the E2E
test 'agent can move a task to a different step' (config-mode move on a
freshly-created task that has no agent yet).
Match the not-found message and return (nil, nil); only real backend
failures bubble up as InternalError. Distinguishing properly with
errors.Is would require an exported sentinel that doesn't exist; matching
the noRowsErr substring is the pragmatic fix.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
605 lines
19 KiB
Go
605 lines
19 KiB
Go
// Package watcher provides event subscription and dispatching for the Orchestrator.
|
|
package watcher
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"sync"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/agent/lifecycle"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/events"
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
v1 "github.com/kandev/kandev/pkg/api/v1"
|
|
)
|
|
|
|
// TaskEventData contains data from task events
|
|
type TaskEventData struct {
|
|
TaskID string `json:"task_id"`
|
|
Task *v1.Task `json:"task,omitempty"`
|
|
OldState *v1.TaskState `json:"old_state,omitempty"`
|
|
NewState *v1.TaskState `json:"new_state,omitempty"`
|
|
}
|
|
|
|
// AgentEventData contains data from agent events
|
|
type AgentEventData struct {
|
|
TaskID string `json:"task_id"`
|
|
SessionID string `json:"session_id"`
|
|
AgentExecutionID string `json:"agent_execution_id"`
|
|
AgentProfileID string `json:"agent_profile_id"`
|
|
ExitCode *int `json:"exit_code,omitempty"`
|
|
ErrorMessage string `json:"error_message,omitempty"`
|
|
}
|
|
|
|
// ACPSessionEventData contains data from ACP session events
|
|
type ACPSessionEventData struct {
|
|
TaskID string `json:"task_id"`
|
|
SessionID string `json:"session_id"`
|
|
AgentExecutionID string `json:"agent_execution_id"`
|
|
ACPSessionID string `json:"acp_session_id"`
|
|
}
|
|
|
|
// PermissionRequestData contains data from permission_request events
|
|
type PermissionRequestData struct {
|
|
TaskID string `json:"task_id"`
|
|
TaskSessionID string `json:"session_id"`
|
|
AgentID string `json:"agent_id"`
|
|
PendingID string `json:"pending_id"`
|
|
ToolCallID string `json:"tool_call_id"`
|
|
Title string `json:"title"`
|
|
Options []map[string]interface{} `json:"options"`
|
|
ActionType string `json:"action_type"`
|
|
ActionDetails map[string]interface{} `json:"action_details"`
|
|
}
|
|
|
|
// GitEventData is an alias for lifecycle.GitEventPayload.
|
|
// Kept for backwards compatibility with existing handler signatures.
|
|
type GitEventData = lifecycle.GitEventPayload
|
|
|
|
// TaskMovedEventData contains data from task.moved events (manual step changes).
|
|
type TaskMovedEventData struct {
|
|
TaskID string `json:"task_id"`
|
|
FromStepID string `json:"from_step_id"`
|
|
ToStepID string `json:"to_step_id"`
|
|
SessionID string `json:"session_id"`
|
|
WorkflowID string `json:"workflow_id"`
|
|
TaskDescription string `json:"task_description"`
|
|
}
|
|
|
|
// ContextWindowData contains data from context window events
|
|
type ContextWindowData struct {
|
|
TaskID string `json:"task_id"`
|
|
TaskSessionID string `json:"session_id"`
|
|
AgentID string `json:"agent_id"`
|
|
ContextWindowSize int64 `json:"context_window_size"`
|
|
ContextWindowUsed int64 `json:"context_window_used"`
|
|
ContextWindowRemaining int64 `json:"context_window_remaining"`
|
|
ContextEfficiency float64 `json:"context_efficiency"`
|
|
Timestamp string `json:"timestamp"`
|
|
}
|
|
|
|
// EventHandlers contains callbacks for different event types
|
|
type EventHandlers struct {
|
|
// Task events
|
|
OnTaskCreated func(ctx context.Context, data TaskEventData)
|
|
OnTaskUpdated func(ctx context.Context, data TaskEventData)
|
|
OnTaskStateChanged func(ctx context.Context, data TaskEventData)
|
|
OnTaskDeleted func(ctx context.Context, data TaskEventData)
|
|
OnTaskMoved func(ctx context.Context, data TaskMovedEventData)
|
|
|
|
// Agent events
|
|
OnAgentStarted func(ctx context.Context, data AgentEventData)
|
|
OnAgentRunning func(ctx context.Context, data AgentEventData)
|
|
OnAgentBootReady func(ctx context.Context, data AgentEventData) // ACP session initialized; idle, no turn yet
|
|
OnAgentReady func(ctx context.Context, data AgentEventData) // Turn ended; agent idle waiting for follow-up
|
|
OnAgentCompleted func(ctx context.Context, data AgentEventData)
|
|
OnAgentFailed func(ctx context.Context, data AgentEventData)
|
|
OnAgentStopped func(ctx context.Context, data AgentEventData)
|
|
OnACPSessionCreated func(ctx context.Context, data ACPSessionEventData)
|
|
|
|
// Agent stream events (tool calls, message chunks, complete, etc.)
|
|
OnAgentStreamEvent func(ctx context.Context, payload *lifecycle.AgentStreamEventPayload)
|
|
|
|
// Permission request events
|
|
OnPermissionRequest func(ctx context.Context, data PermissionRequestData)
|
|
|
|
// Unified git events (status, commit, reset, snapshot)
|
|
OnGitEvent func(ctx context.Context, data GitEventData)
|
|
|
|
// Context window events
|
|
OnContextWindowUpdated func(ctx context.Context, data ContextWindowData)
|
|
}
|
|
|
|
// Watcher subscribes to events and dispatches to handlers
|
|
type Watcher struct {
|
|
eventBus bus.EventBus
|
|
handlers EventHandlers
|
|
logger *logger.Logger
|
|
queue string
|
|
|
|
subscriptions []bus.Subscription
|
|
mu sync.Mutex
|
|
running bool
|
|
}
|
|
|
|
// NewWatcher creates a new event watcher
|
|
func NewWatcher(eventBus bus.EventBus, handlers EventHandlers, queue string, log *logger.Logger) *Watcher {
|
|
if queue == "" {
|
|
queue = "orchestrator"
|
|
}
|
|
return &Watcher{
|
|
eventBus: eventBus,
|
|
handlers: handlers,
|
|
logger: log.WithFields(zap.String("component", "watcher")),
|
|
queue: queue,
|
|
subscriptions: make([]bus.Subscription, 0),
|
|
}
|
|
}
|
|
|
|
// Start begins watching for events
|
|
func (w *Watcher) Start(ctx context.Context) error {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
|
|
if w.running {
|
|
return nil
|
|
}
|
|
|
|
w.logger.Info("Starting event watcher")
|
|
|
|
// Subscribe to task events
|
|
if err := w.subscribeToTaskEvents(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Subscribe to agent events
|
|
if err := w.subscribeToAgentEvents(); err != nil {
|
|
w.unsubscribeAll()
|
|
return err
|
|
}
|
|
|
|
// Subscribe to ACP session events
|
|
if err := w.subscribeToACPSessionEvents(); err != nil {
|
|
w.unsubscribeAll()
|
|
return err
|
|
}
|
|
|
|
// Subscribe to agent stream events
|
|
if err := w.subscribeToAgentStreamEvents(); err != nil {
|
|
w.unsubscribeAll()
|
|
return err
|
|
}
|
|
|
|
// Subscribe to permission request events
|
|
if err := w.subscribeToPermissionRequestEvents(); err != nil {
|
|
w.unsubscribeAll()
|
|
return err
|
|
}
|
|
|
|
// Subscribe to unified git events
|
|
if err := w.subscribeToGitEvents(); err != nil {
|
|
w.unsubscribeAll()
|
|
return err
|
|
}
|
|
|
|
// Subscribe to context window events
|
|
if err := w.subscribeToContextWindowEvents(); err != nil {
|
|
w.unsubscribeAll()
|
|
return err
|
|
}
|
|
|
|
w.running = true
|
|
w.logger.Info("Event watcher started", zap.Int("subscriptions", len(w.subscriptions)))
|
|
return nil
|
|
}
|
|
|
|
// Stop stops watching for events
|
|
func (w *Watcher) Stop() error {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
|
|
if !w.running {
|
|
return nil
|
|
}
|
|
|
|
w.logger.Info("Stopping event watcher")
|
|
w.unsubscribeAll()
|
|
w.running = false
|
|
w.logger.Info("Event watcher stopped")
|
|
return nil
|
|
}
|
|
|
|
// IsRunning returns true if the watcher is active
|
|
func (w *Watcher) IsRunning() bool {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
return w.running
|
|
}
|
|
|
|
// unsubscribeAll removes all subscriptions (must be called with lock held)
|
|
func (w *Watcher) unsubscribeAll() {
|
|
for _, sub := range w.subscriptions {
|
|
if err := sub.Unsubscribe(); err != nil {
|
|
w.logger.Error("Failed to unsubscribe", zap.Error(err))
|
|
}
|
|
}
|
|
w.subscriptions = make([]bus.Subscription, 0)
|
|
}
|
|
|
|
// subscribeToTaskEvents subscribes to all task events
|
|
func (w *Watcher) subscribeToTaskEvents() error {
|
|
taskEvents := []struct {
|
|
subject string
|
|
handler func(ctx context.Context, data TaskEventData)
|
|
}{
|
|
{events.TaskCreated, w.handlers.OnTaskCreated},
|
|
{events.TaskUpdated, w.handlers.OnTaskUpdated},
|
|
{events.TaskStateChanged, w.handlers.OnTaskStateChanged},
|
|
{events.TaskDeleted, w.handlers.OnTaskDeleted},
|
|
}
|
|
|
|
for _, te := range taskEvents {
|
|
if te.handler == nil {
|
|
continue
|
|
}
|
|
handler := te.handler // capture for closure
|
|
sub, err := w.eventBus.QueueSubscribe(te.subject, w.queue, w.createTaskEventHandler(handler))
|
|
if err != nil {
|
|
w.logger.Error("Failed to subscribe to task event",
|
|
zap.String("subject", te.subject),
|
|
zap.String("queue", w.queue),
|
|
zap.Error(err))
|
|
return err
|
|
}
|
|
w.subscriptions = append(w.subscriptions, sub)
|
|
}
|
|
|
|
// Subscribe to task.moved events (different data type from TaskEventData)
|
|
if w.handlers.OnTaskMoved != nil {
|
|
handler := w.handlers.OnTaskMoved
|
|
sub, err := w.eventBus.QueueSubscribe(events.TaskMoved, w.queue, w.createTaskMovedEventHandler(handler))
|
|
if err != nil {
|
|
w.logger.Error("Failed to subscribe to task moved event",
|
|
zap.String("subject", events.TaskMoved),
|
|
zap.String("queue", w.queue),
|
|
zap.Error(err))
|
|
return err
|
|
}
|
|
w.subscriptions = append(w.subscriptions, sub)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// subscribeToAgentEvents subscribes to all agent events
|
|
func (w *Watcher) subscribeToAgentEvents() error {
|
|
agentEvents := []struct {
|
|
subject string
|
|
handler func(ctx context.Context, data AgentEventData)
|
|
}{
|
|
{events.AgentStarted, w.handlers.OnAgentStarted},
|
|
{events.AgentRunning, w.handlers.OnAgentRunning},
|
|
{events.AgentBootReady, w.handlers.OnAgentBootReady},
|
|
{events.AgentReady, w.handlers.OnAgentReady},
|
|
{events.AgentCompleted, w.handlers.OnAgentCompleted},
|
|
{events.AgentFailed, w.handlers.OnAgentFailed},
|
|
{events.AgentStopped, w.handlers.OnAgentStopped},
|
|
}
|
|
|
|
for _, ae := range agentEvents {
|
|
if ae.handler == nil {
|
|
continue
|
|
}
|
|
handler := ae.handler // capture for closure
|
|
sub, err := w.eventBus.QueueSubscribe(ae.subject, w.queue, w.createAgentEventHandler(handler))
|
|
if err != nil {
|
|
w.logger.Error("Failed to subscribe to agent event",
|
|
zap.String("subject", ae.subject),
|
|
zap.String("queue", w.queue),
|
|
zap.Error(err))
|
|
return err
|
|
}
|
|
w.subscriptions = append(w.subscriptions, sub)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// subscribeToACPSessionEvents subscribes to ACP session lifecycle events
|
|
func (w *Watcher) subscribeToACPSessionEvents() error {
|
|
if w.handlers.OnACPSessionCreated == nil {
|
|
return nil
|
|
}
|
|
|
|
sub, err := w.eventBus.QueueSubscribe(events.AgentACPSessionCreated, w.queue, w.createACPSessionEventHandler(w.handlers.OnACPSessionCreated))
|
|
if err != nil {
|
|
w.logger.Error("Failed to subscribe to ACP session event",
|
|
zap.String("subject", events.AgentACPSessionCreated),
|
|
zap.String("queue", w.queue),
|
|
zap.Error(err))
|
|
return err
|
|
}
|
|
w.subscriptions = append(w.subscriptions, sub)
|
|
return nil
|
|
}
|
|
|
|
// subscribeToAgentStreamEvents subscribes to agent stream events using wildcard
|
|
func (w *Watcher) subscribeToAgentStreamEvents() error {
|
|
if w.handlers.OnAgentStreamEvent == nil {
|
|
return nil
|
|
}
|
|
|
|
// Use wildcard to subscribe to all agent stream events (agent.stream.*)
|
|
subject := events.BuildAgentStreamWildcardSubject()
|
|
|
|
// Use regular subscription (each instance needs all messages for WebSocket streaming)
|
|
sub, err := w.eventBus.Subscribe(subject, w.createAgentStreamEventHandler())
|
|
if err != nil {
|
|
w.logger.Error("Failed to subscribe to agent stream events",
|
|
zap.String("subject", subject),
|
|
zap.Error(err))
|
|
return err
|
|
}
|
|
w.subscriptions = append(w.subscriptions, sub)
|
|
return nil
|
|
}
|
|
|
|
// createTaskEventHandler creates a bus.EventHandler for task events
|
|
func (w *Watcher) createTaskEventHandler(handler func(ctx context.Context, data TaskEventData)) bus.EventHandler {
|
|
return func(ctx context.Context, event *bus.Event) error {
|
|
var data TaskEventData
|
|
if err := w.parseEventData(event.Data, &data); err != nil {
|
|
w.logger.Error("Failed to parse task event data",
|
|
zap.String("event_type", event.Type),
|
|
zap.String("event_id", event.ID),
|
|
zap.Error(err))
|
|
return nil // Don't return error to continue processing other events
|
|
}
|
|
|
|
w.logger.Debug("Handling task event",
|
|
zap.String("event_type", event.Type),
|
|
zap.String("task_id", data.TaskID))
|
|
|
|
handler(ctx, data)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// createTaskMovedEventHandler creates a bus.EventHandler for task.moved events
|
|
func (w *Watcher) createTaskMovedEventHandler(handler func(ctx context.Context, data TaskMovedEventData)) bus.EventHandler {
|
|
return func(ctx context.Context, event *bus.Event) error {
|
|
var data TaskMovedEventData
|
|
if err := w.parseEventData(event.Data, &data); err != nil {
|
|
w.logger.Error("Failed to parse task moved event data",
|
|
zap.String("event_type", event.Type),
|
|
zap.String("event_id", event.ID),
|
|
zap.Error(err))
|
|
return nil
|
|
}
|
|
|
|
w.logger.Debug("Handling task moved event",
|
|
zap.String("task_id", data.TaskID),
|
|
zap.String("from_step", data.FromStepID),
|
|
zap.String("to_step", data.ToStepID))
|
|
|
|
handler(ctx, data)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// createAgentEventHandler creates a bus.EventHandler for agent events
|
|
func (w *Watcher) createAgentEventHandler(handler func(ctx context.Context, data AgentEventData)) bus.EventHandler {
|
|
return func(ctx context.Context, event *bus.Event) error {
|
|
|
|
var data AgentEventData
|
|
if err := w.parseEventData(event.Data, &data); err != nil {
|
|
w.logger.Error("Failed to parse agent event data",
|
|
zap.String("event_type", event.Type),
|
|
zap.String("event_id", event.ID),
|
|
zap.Error(err))
|
|
return nil // Don't return error to continue processing other events
|
|
}
|
|
|
|
w.logger.Debug("Handling agent event",
|
|
zap.String("event_type", event.Type),
|
|
zap.String("task_id", data.TaskID),
|
|
zap.String("agent_execution_id", data.AgentExecutionID))
|
|
|
|
handler(ctx, data)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// createACPSessionEventHandler creates a bus.EventHandler for ACP session events
|
|
func (w *Watcher) createACPSessionEventHandler(handler func(ctx context.Context, data ACPSessionEventData)) bus.EventHandler {
|
|
return func(ctx context.Context, event *bus.Event) error {
|
|
var data ACPSessionEventData
|
|
if err := w.parseEventData(event.Data, &data); err != nil {
|
|
w.logger.Error("Failed to parse ACP session event data",
|
|
zap.String("event_type", event.Type),
|
|
zap.String("event_id", event.ID),
|
|
zap.Error(err))
|
|
return nil
|
|
}
|
|
|
|
w.logger.Debug("Handling ACP session event",
|
|
zap.String("event_type", event.Type),
|
|
zap.String("task_id", data.TaskID),
|
|
zap.String("acp_session_id", data.ACPSessionID))
|
|
|
|
handler(ctx, data)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// createAgentStreamEventHandler creates a bus.EventHandler for agent stream events
|
|
func (w *Watcher) createAgentStreamEventHandler() bus.EventHandler {
|
|
return func(ctx context.Context, event *bus.Event) error {
|
|
// Parse the agent stream event payload
|
|
var payload lifecycle.AgentStreamEventPayload
|
|
if err := w.parseEventData(event.Data, &payload); err != nil {
|
|
w.logger.Error("Failed to parse agent stream event",
|
|
zap.String("event_id", event.ID),
|
|
zap.Error(err))
|
|
return nil // Don't return error to continue processing other events
|
|
}
|
|
|
|
// Validate required fields
|
|
if payload.TaskID == "" {
|
|
w.logger.Warn("Agent stream event missing task_id",
|
|
zap.String("event_id", event.ID))
|
|
return nil
|
|
}
|
|
|
|
eventType := ""
|
|
if payload.Data != nil {
|
|
eventType = payload.Data.Type
|
|
}
|
|
|
|
w.logger.Debug("Handling agent stream event",
|
|
zap.String("task_id", payload.TaskID),
|
|
zap.String("session_id", payload.SessionID),
|
|
zap.String("event_type", eventType))
|
|
|
|
w.handlers.OnAgentStreamEvent(ctx, &payload)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// subscribeToPermissionRequestEvents subscribes to permission request events
|
|
func (w *Watcher) subscribeToPermissionRequestEvents() error {
|
|
if w.handlers.OnPermissionRequest == nil {
|
|
return nil
|
|
}
|
|
|
|
// Use wildcard to subscribe to all permission request events (permission_request.received.{session_id})
|
|
subject := events.BuildPermissionRequestWildcardSubject()
|
|
sub, err := w.eventBus.Subscribe(subject, w.createPermissionRequestHandler())
|
|
if err != nil {
|
|
w.logger.Error("Failed to subscribe to permission request events",
|
|
zap.String("subject", subject),
|
|
zap.Error(err))
|
|
return err
|
|
}
|
|
w.subscriptions = append(w.subscriptions, sub)
|
|
return nil
|
|
}
|
|
|
|
// createPermissionRequestHandler creates a bus.EventHandler for permission request events
|
|
func (w *Watcher) createPermissionRequestHandler() bus.EventHandler {
|
|
return func(ctx context.Context, event *bus.Event) error {
|
|
var data PermissionRequestData
|
|
if err := w.parseEventData(event.Data, &data); err != nil {
|
|
w.logger.Error("Failed to parse permission request event data",
|
|
zap.String("event_type", event.Type),
|
|
zap.String("event_id", event.ID),
|
|
zap.Error(err))
|
|
return nil
|
|
}
|
|
|
|
w.logger.Debug("Handling permission request event",
|
|
zap.String("task_id", data.TaskID),
|
|
zap.String("pending_id", data.PendingID),
|
|
zap.String("title", data.Title))
|
|
|
|
w.handlers.OnPermissionRequest(ctx, data)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// subscribeToGitEvents subscribes to unified git events
|
|
func (w *Watcher) subscribeToGitEvents() error {
|
|
if w.handlers.OnGitEvent == nil {
|
|
return nil
|
|
}
|
|
|
|
subject := events.BuildGitEventWildcardSubject()
|
|
|
|
sub, err := w.eventBus.Subscribe(subject, w.createGitEventHandler())
|
|
if err != nil {
|
|
w.logger.Error("Failed to subscribe to git events",
|
|
zap.String("subject", subject),
|
|
zap.Error(err))
|
|
return err
|
|
}
|
|
w.subscriptions = append(w.subscriptions, sub)
|
|
return nil
|
|
}
|
|
|
|
// createGitEventHandler creates a bus.EventHandler for unified git events
|
|
func (w *Watcher) createGitEventHandler() bus.EventHandler {
|
|
return func(ctx context.Context, event *bus.Event) error {
|
|
var data GitEventData
|
|
if err := w.parseEventData(event.Data, &data); err != nil {
|
|
w.logger.Error("Failed to parse git event data",
|
|
zap.String("event_type", event.Type),
|
|
zap.String("event_id", event.ID),
|
|
zap.Error(err))
|
|
return nil
|
|
}
|
|
|
|
w.logger.Debug("Handling git event",
|
|
zap.String("type", string(data.Type)),
|
|
zap.String("task_id", data.TaskID),
|
|
zap.String("session_id", data.SessionID))
|
|
|
|
w.handlers.OnGitEvent(ctx, data)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// subscribeToContextWindowEvents subscribes to context window events
|
|
func (w *Watcher) subscribeToContextWindowEvents() error {
|
|
if w.handlers.OnContextWindowUpdated == nil {
|
|
return nil
|
|
}
|
|
|
|
// Use wildcard to subscribe to all context window events (context_window.updated.{session_id})
|
|
subject := events.BuildContextWindowWildcardSubject()
|
|
|
|
// Use regular subscription (each instance needs all messages)
|
|
sub, err := w.eventBus.Subscribe(subject, w.createContextWindowHandler())
|
|
if err != nil {
|
|
w.logger.Error("Failed to subscribe to context window events",
|
|
zap.String("subject", subject),
|
|
zap.Error(err))
|
|
return err
|
|
}
|
|
w.subscriptions = append(w.subscriptions, sub)
|
|
return nil
|
|
}
|
|
|
|
// createContextWindowHandler creates a bus.EventHandler for context window events
|
|
func (w *Watcher) createContextWindowHandler() bus.EventHandler {
|
|
return func(ctx context.Context, event *bus.Event) error {
|
|
var data ContextWindowData
|
|
if err := w.parseEventData(event.Data, &data); err != nil {
|
|
w.logger.Error("Failed to parse context window event data",
|
|
zap.String("event_type", event.Type),
|
|
zap.String("event_id", event.ID),
|
|
zap.Error(err))
|
|
return nil // Don't return error to continue processing other events
|
|
}
|
|
|
|
w.logger.Debug("Handling context window event",
|
|
zap.String("task_id", data.TaskID),
|
|
zap.String("session_id", data.TaskSessionID),
|
|
zap.Int64("size", data.ContextWindowSize),
|
|
zap.Int64("used", data.ContextWindowUsed))
|
|
|
|
w.handlers.OnContextWindowUpdated(ctx, data)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// parseEventData converts event data (map or struct) to a typed struct
|
|
func (w *Watcher) parseEventData(data interface{}, target interface{}) error {
|
|
// Marshal to JSON and unmarshal to target type
|
|
jsonData, err := json.Marshal(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return json.Unmarshal(jsonData, target)
|
|
}
|