refactor(orchestrator): extract WatcherDispatchCoordinator + WatcherSource (#1070)

This commit is contained in:
Nicolas Lenepveu
2026-05-25 21:13:23 +02:00
committed by GitHub
parent f1dbad1dbd
commit 9a462e52c8
13 changed files with 1100 additions and 238 deletions
+2
View File
@@ -116,6 +116,8 @@ apps/backend/
- Handles event-driven state transitions via workflow engine
- Located in `internal/orchestrator/`
**Watcher Dispatch Coordinator** (`internal/orchestrator/watcher_dispatch.go`) is the single pipeline that turns a freshly-observed external issue (Linear, Jira, future) into a Kandev task. Bus subscribers for each integration forward the event to `WatcherDispatchCoordinator.Dispatch` with a per-integration `WatcherSource` implementation (`source_linear.go`, `source_jira.go`). Source methods carry the integration-specific bits (reserve dedup, build task request, attach task ID, release, auto-start params); the coordinator owns the cross-cutting pipeline (create task, decide auto-start, error/release handling). Add a new watcher = implement `WatcherSource` + register a one-line bus subscriber. Do NOT add another `createXIssueTask` mirror.
**Workflow Engine** (`internal/workflow/engine/`) provides typed state-machine evaluation:
- `Engine.HandleTrigger()` evaluates step actions for triggers (on_enter, on_turn_start, on_turn_complete, on_exit)
- `TransitionStore` interface abstracts persistence (implemented by `orchestrator.workflowStore`)
@@ -126,6 +126,7 @@ func (s *Service) SetRepositoryResolver(rr RepositoryResolver) {
// SetIssueTaskCreator sets the task creator for issue watch auto-task creation.
func (s *Service) SetIssueTaskCreator(tc IssueTaskCreator) {
s.issueTaskCreator = tc
s.initWatcherCoordinator()
}
// handlePRFeedback logs PR feedback events. WS broadcasting is handled in main.go.
@@ -2,7 +2,6 @@ package orchestrator
import (
"context"
"fmt"
"strings"
"go.uber.org/zap"
@@ -40,128 +39,20 @@ func (s *Service) subscribeJiraEvents() {
}
}
// handleNewJiraIssue creates a Kandev task for a freshly-observed JIRA ticket.
// Reuses the same IssueTaskCreator the GitHub flow uses so task creation logic
// (workflow placement, on-enter actions, etc.) stays in one place.
func (s *Service) handleNewJiraIssue(_ context.Context, event *bus.Event) error {
// handleNewJiraIssue translates a JiraNewIssue bus event into a
// dispatchWatcherEvent call. Symmetric with handleNewLinearIssue.
func (s *Service) handleNewJiraIssue(ctx context.Context, event *bus.Event) error {
evt, ok := event.Data.(*jira.NewJiraIssueEvent)
if !ok {
if !ok || evt == nil || evt.Issue == nil {
return nil
}
if evt.Issue == nil {
return nil
}
s.logger.Info("new jira issue detected from watch",
s.dispatchWatcherEvent(ctx, "jira",
NewJiraWatcherSource(s.jiraService, s.logger), evt,
zap.String("issue_watch_id", evt.IssueWatchID),
zap.String("issue_key", evt.Issue.Key))
if s.issueTaskCreator == nil {
s.logger.Warn("issue task creator not configured, skipping jira task creation")
return nil
}
// Background context: the bus delivery context may be cancelled before
// task creation finishes. The work is independent of the publisher.
go s.createJiraIssueTask(context.Background(), evt)
return nil
}
func (s *Service) createJiraIssueTask(ctx context.Context, evt *jira.NewJiraIssueEvent) {
ticket := evt.Issue
if !s.reserveJiraIssue(ctx, evt) {
return
}
req := &IssueTaskRequest{
WorkspaceID: evt.WorkspaceID,
WorkflowID: evt.WorkflowID,
WorkflowStepID: evt.WorkflowStepID,
Title: fmt.Sprintf("[%s] %s", ticket.Key, ticket.Summary),
Description: interpolateJiraPrompt(evt.Prompt, ticket),
Metadata: map[string]interface{}{
"jira_issue_watch_id": evt.IssueWatchID,
"jira_issue_key": ticket.Key,
"jira_issue_url": ticket.URL,
"jira_status": ticket.StatusName,
"jira_assignee": ticket.AssigneeName,
"agent_profile_id": evt.AgentProfileID,
"executor_profile_id": evt.ExecutorProfileID,
},
}
task, err := s.issueTaskCreator.CreateIssueTask(ctx, req)
if err != nil {
s.logger.Error("failed to create jira issue task",
zap.String("issue_watch_id", evt.IssueWatchID),
zap.String("issue_key", ticket.Key),
zap.Error(err))
s.releaseJiraIssue(ctx, evt)
return
}
s.attachJiraIssueTaskID(ctx, evt, task.ID)
s.logger.Info("created jira issue task",
zap.String("task_id", task.ID),
zap.String("issue_key", ticket.Key))
if !s.shouldAutoStartStep(ctx, evt.WorkflowStepID) {
return
}
if _, err := s.StartTask(
ctx, task.ID, evt.AgentProfileID, "", evt.ExecutorProfileID,
"", task.Description, evt.WorkflowStepID, false, true, nil,
); err != nil {
s.logger.Error("failed to auto-start jira issue task",
zap.String("task_id", task.ID), zap.Error(err))
return
}
s.logger.Info("auto-started jira issue task",
zap.String("task_id", task.ID),
zap.String("issue_key", ticket.Key))
}
// reserveJiraIssue claims the dedup slot before the (relatively expensive)
// task creation. Returns false if another handler beat us to it. When the
// jira service isn't wired (boot order corner case), proceed anyway — better
// to risk a duplicate task than silently drop the event.
func (s *Service) reserveJiraIssue(ctx context.Context, evt *jira.NewJiraIssueEvent) bool {
if s.jiraService == nil {
return true
}
reserved, err := s.jiraService.ReserveIssueWatchTask(ctx, evt.IssueWatchID, evt.Issue.Key, evt.Issue.URL)
if err != nil {
s.logger.Error("failed to reserve jira issue slot",
zap.String("issue_key", evt.Issue.Key), zap.Error(err))
return false
}
if !reserved {
s.logger.Debug("jira issue already reserved by concurrent handler, skipping",
zap.String("issue_key", evt.Issue.Key))
}
return reserved
}
func (s *Service) releaseJiraIssue(ctx context.Context, evt *jira.NewJiraIssueEvent) {
if s.jiraService == nil {
return
}
if err := s.jiraService.ReleaseIssueWatchTask(ctx, evt.IssueWatchID, evt.Issue.Key); err != nil {
s.logger.Warn("failed to release jira issue reservation after task-create failure",
zap.String("issue_key", evt.Issue.Key), zap.Error(err))
}
}
func (s *Service) attachJiraIssueTaskID(ctx context.Context, evt *jira.NewJiraIssueEvent, taskID string) {
if s.jiraService == nil {
return
}
if err := s.jiraService.AssignIssueWatchTaskID(ctx, evt.IssueWatchID, evt.Issue.Key, taskID); err != nil {
s.logger.Error("failed to assign task ID to jira issue reservation",
zap.String("task_id", taskID),
zap.String("issue_key", evt.Issue.Key), zap.Error(err))
}
}
// interpolateJiraPrompt replaces {{issue.*}} placeholders with ticket fields.
// When the template is empty, falls back to the embedded default in
// config/prompts/jira-issue-watch-default.md — same pattern the GitHub
@@ -66,6 +66,15 @@ func setupJiraTaskTest(t *testing.T) *Service {
return createTestService(repo, stepGetter, newMockTaskRepo())
}
// dispatchJiraEvent drives an event through the watcher coordinator
// synchronously using a JiraWatcherSource — mirrors what handleNewJiraIssue
// does, but without the goroutine, so tests can assert on observable side
// effects deterministically.
func dispatchJiraEvent(svc *Service, evt *jira.NewJiraIssueEvent) {
src := NewJiraWatcherSource(svc.jiraService, svc.logger)
svc.watcherCoordinator.Dispatch(context.Background(), src, evt)
}
func TestCreateJiraIssueTask_HappyPath(t *testing.T) {
svc := setupJiraTaskTest(t)
jiraSvc := &mockJiraService{reserveReturn: true}
@@ -73,7 +82,7 @@ func TestCreateJiraIssueTask_HappyPath(t *testing.T) {
creator := &countingIssueTaskCreator{taskID: "task-jira-1"}
svc.SetIssueTaskCreator(creator)
svc.createJiraIssueTask(context.Background(), newJiraIssueEvent())
dispatchJiraEvent(svc, newJiraIssueEvent())
if jiraSvc.reserveCalls != 1 {
t.Errorf("expected 1 Reserve call, got %d", jiraSvc.reserveCalls)
@@ -102,7 +111,7 @@ func TestCreateJiraIssueTask_SkipsWhenAlreadyReserved(t *testing.T) {
creator := &countingIssueTaskCreator{}
svc.SetIssueTaskCreator(creator)
svc.createJiraIssueTask(context.Background(), newJiraIssueEvent())
dispatchJiraEvent(svc, newJiraIssueEvent())
if creator.calls != 0 {
t.Errorf("expected CreateIssueTask NOT to be called when reservation is lost, got %d", creator.calls)
@@ -119,7 +128,7 @@ func TestCreateJiraIssueTask_ReleasesWhenCreateFails(t *testing.T) {
creator := &countingIssueTaskCreator{err: errors.New("task creation failed")}
svc.SetIssueTaskCreator(creator)
svc.createJiraIssueTask(context.Background(), newJiraIssueEvent())
dispatchJiraEvent(svc, newJiraIssueEvent())
if jiraSvc.assignCalls != 0 {
t.Errorf("expected no Assign when task creation failed, got %d", jiraSvc.assignCalls)
@@ -129,6 +138,31 @@ func TestCreateJiraIssueTask_ReleasesWhenCreateFails(t *testing.T) {
}
}
// TestSetIssueTaskCreator_RefreshesCoordinatorTaskCreator pins the wiring
// contract that calling SetIssueTaskCreator again must update the coordinator.
// Regression guard: an earlier version of initWatcherCoordinator returned
// early on the second call and silently kept the original creator.
func TestSetIssueTaskCreator_RefreshesCoordinatorTaskCreator(t *testing.T) {
svc := setupJiraTaskTest(t)
jiraSvc := &mockJiraService{reserveReturn: true}
svc.SetJiraService(jiraSvc)
first := &countingIssueTaskCreator{taskID: "task-first"}
svc.SetIssueTaskCreator(first)
second := &countingIssueTaskCreator{taskID: "task-second"}
svc.SetIssueTaskCreator(second)
dispatchJiraEvent(svc, newJiraIssueEvent())
if first.calls != 0 {
t.Errorf("first creator must not be invoked after being replaced, got %d calls", first.calls)
}
if second.calls != 1 {
t.Errorf("expected the latest creator to be used, got %d calls", second.calls)
}
}
func TestInterpolateJiraPrompt(t *testing.T) {
ticket := &jira.JiraTicket{
Key: "PROJ-7",
@@ -2,7 +2,6 @@ package orchestrator
import (
"context"
"fmt"
"strings"
"go.uber.org/zap"
@@ -11,7 +10,6 @@ import (
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/linear"
"github.com/kandev/kandev/internal/task/models"
)
// LinearService is the subset of linear.Service the orchestrator needs to
@@ -38,132 +36,25 @@ func (s *Service) subscribeLinearEvents() {
}
}
// handleNewLinearIssue creates a Kandev task for a freshly-observed Linear issue.
func (s *Service) handleNewLinearIssue(_ context.Context, event *bus.Event) error {
// handleNewLinearIssue translates a LinearNewIssue bus event into a
// dispatchWatcherEvent call. Integration-specific work
// (reserve, build, attach, release, auto-start params) lives in
// LinearWatcherSource; the pipeline (create, auto-start, error/release
// handling) lives in the coordinator; the shared wiring guards
// (creator/coordinator nil-checks, cancellation detachment) live in
// the dispatchWatcherEvent helper.
func (s *Service) handleNewLinearIssue(ctx context.Context, event *bus.Event) error {
evt, ok := event.Data.(*linear.NewLinearIssueEvent)
if !ok {
if !ok || evt == nil || evt.Issue == nil {
return nil
}
if evt.Issue == nil {
return nil
}
s.logger.Info("new linear issue detected from watch",
s.dispatchWatcherEvent(ctx, "linear",
NewLinearWatcherSource(s.linearService, s.logger), evt,
zap.String("issue_watch_id", evt.IssueWatchID),
zap.String("identifier", evt.Issue.Identifier))
if s.issueTaskCreator == nil {
s.logger.Warn("issue task creator not configured, skipping linear task creation")
return nil
}
// Background context: the bus delivery context may be cancelled before
// task creation finishes. The work is independent of the publisher.
go s.createLinearIssueTask(context.Background(), evt)
return nil
}
// createLinearIssueTask mirrors createJiraIssueTask intentionally: the dedup-
// reserve → create → assign-or-release lifecycle is the same shape across
// both integrations. Refactoring out the symmetry would couple two
// integration packages we'd rather keep independent (their event payloads,
// metadata keys, and prompt placeholders all differ).
//
//nolint:dupl // intentional symmetry with createJiraIssueTask; see comment above
func (s *Service) createLinearIssueTask(ctx context.Context, evt *linear.NewLinearIssueEvent) {
issue := evt.Issue
if !s.reserveLinearIssue(ctx, evt) {
return
}
req := &IssueTaskRequest{
WorkspaceID: evt.WorkspaceID,
WorkflowID: evt.WorkflowID,
WorkflowStepID: evt.WorkflowStepID,
Title: fmt.Sprintf("[%s] %s", issue.Identifier, issue.Title),
Description: interpolateLinearPrompt(evt.Prompt, issue),
Metadata: map[string]interface{}{
"linear_issue_watch_id": evt.IssueWatchID,
"linear_issue_identifier": issue.Identifier,
"linear_issue_url": issue.URL,
"linear_state": issue.StateName,
"linear_assignee": issue.AssigneeName,
models.MetaKeyAgentProfileID: evt.AgentProfileID,
models.MetaKeyExecutorProfileID: evt.ExecutorProfileID,
},
}
task, err := s.issueTaskCreator.CreateIssueTask(ctx, req)
if err != nil {
s.logger.Error("failed to create linear issue task",
zap.String("issue_watch_id", evt.IssueWatchID),
zap.String("identifier", issue.Identifier),
zap.Error(err))
s.releaseLinearIssue(ctx, evt)
return
}
s.attachLinearIssueTaskID(ctx, evt, task.ID)
s.logger.Info("created linear issue task",
zap.String("task_id", task.ID),
zap.String("identifier", issue.Identifier))
if !s.shouldAutoStartStep(ctx, evt.WorkflowStepID) {
return
}
if _, err := s.StartTask(
ctx, task.ID, evt.AgentProfileID, "", evt.ExecutorProfileID,
"", task.Description, evt.WorkflowStepID, false, true, nil,
); err != nil {
s.logger.Error("failed to auto-start linear issue task",
zap.String("task_id", task.ID), zap.Error(err))
return
}
s.logger.Info("auto-started linear issue task",
zap.String("task_id", task.ID),
zap.String("identifier", issue.Identifier))
}
// reserveLinearIssue claims the dedup slot before task creation. When the
// linear service isn't wired (boot order corner case), proceed anyway —
// better to risk a duplicate task than silently drop the event.
func (s *Service) reserveLinearIssue(ctx context.Context, evt *linear.NewLinearIssueEvent) bool {
if s.linearService == nil {
return true
}
reserved, err := s.linearService.ReserveIssueWatchTask(ctx, evt.IssueWatchID, evt.Issue.Identifier, evt.Issue.URL)
if err != nil {
s.logger.Error("failed to reserve linear issue slot",
zap.String("identifier", evt.Issue.Identifier), zap.Error(err))
return false
}
if !reserved {
s.logger.Debug("linear issue already reserved by concurrent handler, skipping",
zap.String("identifier", evt.Issue.Identifier))
}
return reserved
}
func (s *Service) releaseLinearIssue(ctx context.Context, evt *linear.NewLinearIssueEvent) {
if s.linearService == nil {
return
}
if err := s.linearService.ReleaseIssueWatchTask(ctx, evt.IssueWatchID, evt.Issue.Identifier); err != nil {
s.logger.Warn("failed to release linear issue reservation after task-create failure",
zap.String("identifier", evt.Issue.Identifier), zap.Error(err))
}
}
func (s *Service) attachLinearIssueTaskID(ctx context.Context, evt *linear.NewLinearIssueEvent, taskID string) {
if s.linearService == nil {
return
}
if err := s.linearService.AssignIssueWatchTaskID(ctx, evt.IssueWatchID, evt.Issue.Identifier, taskID); err != nil {
s.logger.Error("failed to assign task ID to linear issue reservation",
zap.String("task_id", taskID),
zap.String("identifier", evt.Issue.Identifier), zap.Error(err))
}
}
// interpolateLinearPrompt replaces {{issue.*}} placeholders with issue fields.
// When the template is empty (user didn't configure a custom prompt), it falls
// back to the embedded default at config/prompts/linear-issue-watch-default.md
@@ -259,6 +259,10 @@ type Service struct {
// Issue task creator for auto-creating tasks from issue watch events
issueTaskCreator IssueTaskCreator
// Watcher dispatch coordinator: single seam for the watcher→task pipeline.
// Constructed lazily once issueTaskCreator is wired (see SetIssueTaskCreator).
watcherCoordinator *WatcherDispatchCoordinator
// Jira service for issue watch dedup operations
jiraService JiraService
@@ -0,0 +1,96 @@
package orchestrator
import (
"context"
"errors"
"fmt"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/jira"
)
// JiraWatcherSource adapts the Jira integration onto the WatcherSource
// pipeline. Symmetric with LinearWatcherSource; the only differences are
// the metadata keys and the interpolation function.
type JiraWatcherSource struct {
service JiraService
logger *logger.Logger
}
// NewJiraWatcherSource constructs a source bound to the orchestrator's
// Jira service handle.
func NewJiraWatcherSource(svc JiraService, log *logger.Logger) *JiraWatcherSource {
return &JiraWatcherSource{service: svc, logger: log}
}
func (s *JiraWatcherSource) Name() string { return "jira" }
func (s *JiraWatcherSource) Reserve(ctx context.Context, evt any) (bool, error) {
e, ok := evt.(*jira.NewJiraIssueEvent)
if !ok || e == nil || e.Issue == nil {
return false, errors.New("jira source: event payload missing or wrong type")
}
if s.service == nil {
return true, nil
}
return s.service.ReserveIssueWatchTask(ctx, e.IssueWatchID, e.Issue.Key, e.Issue.URL)
}
func (s *JiraWatcherSource) Release(ctx context.Context, evt any) {
e, ok := evt.(*jira.NewJiraIssueEvent)
if !ok || e == nil || e.Issue == nil || s.service == nil {
return
}
if err := s.service.ReleaseIssueWatchTask(ctx, e.IssueWatchID, e.Issue.Key); err != nil && s.logger != nil {
s.logger.Warn("jira source: release failed",
zap.String("issue_key", e.Issue.Key), zap.Error(err))
}
}
func (s *JiraWatcherSource) BuildTaskRequest(evt any) (*IssueTaskRequest, error) {
e, ok := evt.(*jira.NewJiraIssueEvent)
if !ok || e == nil || e.Issue == nil {
return nil, errors.New("jira source: event payload missing or wrong type")
}
return &IssueTaskRequest{
WorkspaceID: e.WorkspaceID,
WorkflowID: e.WorkflowID,
WorkflowStepID: e.WorkflowStepID,
Title: fmt.Sprintf("[%s] %s", e.Issue.Key, e.Issue.Summary),
Description: interpolateJiraPrompt(e.Prompt, e.Issue),
// Preserve today's literal metadata keys (do NOT normalise to
// models.MetaKey* in this refactor — separate cleanup).
Metadata: map[string]interface{}{
"jira_issue_watch_id": e.IssueWatchID,
"jira_issue_key": e.Issue.Key,
"jira_issue_url": e.Issue.URL,
"jira_status": e.Issue.StatusName,
"jira_assignee": e.Issue.AssigneeName,
"agent_profile_id": e.AgentProfileID,
"executor_profile_id": e.ExecutorProfileID,
},
}, nil
}
func (s *JiraWatcherSource) AttachTaskID(ctx context.Context, evt any, taskID string) error {
e, ok := evt.(*jira.NewJiraIssueEvent)
if !ok || e == nil || e.Issue == nil || s.service == nil {
return nil
}
return s.service.AssignIssueWatchTaskID(ctx, e.IssueWatchID, e.Issue.Key, taskID)
}
func (s *JiraWatcherSource) AutoStartParams(evt any) AutoStartParams {
e, ok := evt.(*jira.NewJiraIssueEvent)
if !ok || e == nil {
return AutoStartParams{}
}
return AutoStartParams{
AgentProfileID: e.AgentProfileID,
ExecutorProfileID: e.ExecutorProfileID,
Prompt: interpolateJiraPrompt(e.Prompt, e.Issue),
WorkflowStepID: e.WorkflowStepID,
}
}
@@ -0,0 +1,164 @@
package orchestrator
import (
"context"
"errors"
"testing"
"github.com/kandev/kandev/internal/jira"
)
type fakeJiraService struct {
reserveOK bool
reserveErr error
assignErr error
releaseErr error
gotReserve []string
gotAssign []string
gotRelease []string
}
func (f *fakeJiraService) ReserveIssueWatchTask(_ context.Context, watchID, key, _ string) (bool, error) {
f.gotReserve = append(f.gotReserve, watchID+":"+key)
return f.reserveOK, f.reserveErr
}
func (f *fakeJiraService) AssignIssueWatchTaskID(_ context.Context, watchID, key, taskID string) error {
f.gotAssign = append(f.gotAssign, watchID+":"+key+":"+taskID)
return f.assignErr
}
func (f *fakeJiraService) ReleaseIssueWatchTask(_ context.Context, watchID, key string) error {
f.gotRelease = append(f.gotRelease, watchID+":"+key)
return f.releaseErr
}
func sampleJiraEvent() *jira.NewJiraIssueEvent {
return &jira.NewJiraIssueEvent{
IssueWatchID: "watch-1",
WorkspaceID: "ws-1",
WorkflowID: "wf-1",
WorkflowStepID: "step-1",
AgentProfileID: "agent-1",
ExecutorProfileID: "exec-1",
Prompt: "Pick up {{issue.key}}: {{issue.summary}}",
Issue: &jira.JiraTicket{
Key: "PROJ-1",
Summary: "Bug",
URL: "https://example.atlassian.net/browse/PROJ-1",
StatusName: "Open",
AssigneeName: "Alice",
Priority: "High",
IssueType: "Bug",
ReporterName: "Bob",
ProjectKey: "PROJ",
Description: "details",
},
}
}
func TestJiraSource_Name(t *testing.T) {
src := &JiraWatcherSource{}
if src.Name() != "jira" {
t.Fatalf("expected name=jira, got %q", src.Name())
}
}
func TestJiraSource_Reserve_Passthrough(t *testing.T) {
svc := &fakeJiraService{reserveOK: true}
src := &JiraWatcherSource{service: svc}
ok, err := src.Reserve(context.Background(), sampleJiraEvent())
if err != nil || !ok {
t.Fatalf("expected reserve ok, got ok=%v err=%v", ok, err)
}
if len(svc.gotReserve) != 1 || svc.gotReserve[0] != "watch-1:PROJ-1" {
t.Fatalf("unexpected reserve args: %v", svc.gotReserve)
}
}
func TestJiraSource_Reserve_NilServiceFailOpen(t *testing.T) {
src := &JiraWatcherSource{service: nil}
ok, err := src.Reserve(context.Background(), sampleJiraEvent())
if err != nil || !ok {
t.Fatalf("expected nil service to fail open, got ok=%v err=%v", ok, err)
}
}
func TestJiraSource_Reserve_Error(t *testing.T) {
svc := &fakeJiraService{reserveErr: errors.New("boom")}
src := &JiraWatcherSource{service: svc}
if _, err := src.Reserve(context.Background(), sampleJiraEvent()); err == nil {
t.Fatal("expected reserve error to surface")
}
}
func TestJiraSource_BuildTaskRequest(t *testing.T) {
src := &JiraWatcherSource{}
req, err := src.BuildTaskRequest(sampleJiraEvent())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if req.Title != "[PROJ-1] Bug" {
t.Errorf("title = %q", req.Title)
}
if req.Description != "Pick up PROJ-1: Bug" {
t.Errorf("prompt interpolation wrong: %q", req.Description)
}
if req.Metadata["jira_issue_key"] != "PROJ-1" {
t.Errorf("missing jira_issue_key metadata")
}
if req.Metadata["agent_profile_id"] != "agent-1" {
t.Errorf("missing agent_profile_id metadata")
}
}
func TestJiraSource_BuildTaskRequest_WrongType(t *testing.T) {
src := &JiraWatcherSource{}
if _, err := src.BuildTaskRequest("nope"); err == nil {
t.Fatal("expected error for wrong event type")
}
}
func TestJiraSource_AttachTaskID(t *testing.T) {
svc := &fakeJiraService{}
src := &JiraWatcherSource{service: svc}
if err := src.AttachTaskID(context.Background(), sampleJiraEvent(), "task-9"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(svc.gotAssign) != 1 || svc.gotAssign[0] != "watch-1:PROJ-1:task-9" {
t.Fatalf("unexpected assign args: %v", svc.gotAssign)
}
}
func TestJiraSource_Release(t *testing.T) {
svc := &fakeJiraService{}
src := &JiraWatcherSource{service: svc}
src.Release(context.Background(), sampleJiraEvent())
if len(svc.gotRelease) != 1 || svc.gotRelease[0] != "watch-1:PROJ-1" {
t.Fatalf("unexpected release args: %v", svc.gotRelease)
}
}
func TestJiraSource_Release_ErrorIsLoggedNotPropagated(t *testing.T) {
svc := &fakeJiraService{releaseErr: errors.New("dedup store down")}
src := &JiraWatcherSource{service: svc, logger: nopLogger(t)}
// Release returns no value: failure must be swallowed and logged.
src.Release(context.Background(), sampleJiraEvent())
if len(svc.gotRelease) != 1 {
t.Fatalf("expected release call to be attempted, got %d", len(svc.gotRelease))
}
}
func TestJiraSource_AutoStartParams(t *testing.T) {
src := &JiraWatcherSource{}
p := src.AutoStartParams(sampleJiraEvent())
if p.AgentProfileID != "agent-1" || p.ExecutorProfileID != "exec-1" {
t.Fatalf("unexpected auto-start params: %+v", p)
}
if p.WorkflowStepID != "step-1" {
t.Errorf("step id wrong: %q", p.WorkflowStepID)
}
if p.Prompt != "Pick up PROJ-1: Bug" {
t.Errorf("prompt should be interpolated, got %q", p.Prompt)
}
}
@@ -0,0 +1,97 @@
package orchestrator
import (
"context"
"errors"
"fmt"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/linear"
"github.com/kandev/kandev/internal/task/models"
)
// LinearWatcherSource adapts the Linear integration onto the WatcherSource
// pipeline. Wraps the existing LinearService (already used by the orchestrator
// for dedup) and the package-local interpolateLinearPrompt so behaviour is
// preserved byte-for-byte versus the old createLinearIssueTask helper.
type LinearWatcherSource struct {
service LinearService
logger *logger.Logger
}
// NewLinearWatcherSource constructs a source bound to the orchestrator's
// Linear service handle. logger may be nil — methods that log will no-op.
func NewLinearWatcherSource(svc LinearService, log *logger.Logger) *LinearWatcherSource {
return &LinearWatcherSource{service: svc, logger: log}
}
func (s *LinearWatcherSource) Name() string { return "linear" }
func (s *LinearWatcherSource) Reserve(ctx context.Context, evt any) (bool, error) {
e, ok := evt.(*linear.NewLinearIssueEvent)
if !ok || e == nil || e.Issue == nil {
return false, errors.New("linear source: event payload missing or wrong type")
}
// Match today's reserveLinearIssue: nil service is "fail open".
if s.service == nil {
return true, nil
}
return s.service.ReserveIssueWatchTask(ctx, e.IssueWatchID, e.Issue.Identifier, e.Issue.URL)
}
func (s *LinearWatcherSource) Release(ctx context.Context, evt any) {
e, ok := evt.(*linear.NewLinearIssueEvent)
if !ok || e == nil || e.Issue == nil || s.service == nil {
return
}
if err := s.service.ReleaseIssueWatchTask(ctx, e.IssueWatchID, e.Issue.Identifier); err != nil && s.logger != nil {
s.logger.Warn("linear source: release failed",
zap.String("identifier", e.Issue.Identifier), zap.Error(err))
}
}
func (s *LinearWatcherSource) BuildTaskRequest(evt any) (*IssueTaskRequest, error) {
e, ok := evt.(*linear.NewLinearIssueEvent)
if !ok || e == nil || e.Issue == nil {
return nil, errors.New("linear source: event payload missing or wrong type")
}
return &IssueTaskRequest{
WorkspaceID: e.WorkspaceID,
WorkflowID: e.WorkflowID,
WorkflowStepID: e.WorkflowStepID,
Title: fmt.Sprintf("[%s] %s", e.Issue.Identifier, e.Issue.Title),
Description: interpolateLinearPrompt(e.Prompt, e.Issue),
Metadata: map[string]interface{}{
"linear_issue_watch_id": e.IssueWatchID,
"linear_issue_identifier": e.Issue.Identifier,
"linear_issue_url": e.Issue.URL,
"linear_state": e.Issue.StateName,
"linear_assignee": e.Issue.AssigneeName,
models.MetaKeyAgentProfileID: e.AgentProfileID,
models.MetaKeyExecutorProfileID: e.ExecutorProfileID,
},
}, nil
}
func (s *LinearWatcherSource) AttachTaskID(ctx context.Context, evt any, taskID string) error {
e, ok := evt.(*linear.NewLinearIssueEvent)
if !ok || e == nil || e.Issue == nil || s.service == nil {
return nil
}
return s.service.AssignIssueWatchTaskID(ctx, e.IssueWatchID, e.Issue.Identifier, taskID)
}
func (s *LinearWatcherSource) AutoStartParams(evt any) AutoStartParams {
e, ok := evt.(*linear.NewLinearIssueEvent)
if !ok || e == nil {
return AutoStartParams{}
}
return AutoStartParams{
AgentProfileID: e.AgentProfileID,
ExecutorProfileID: e.ExecutorProfileID,
Prompt: interpolateLinearPrompt(e.Prompt, e.Issue),
WorkflowStepID: e.WorkflowStepID,
}
}
@@ -0,0 +1,178 @@
package orchestrator
import (
"context"
"errors"
"testing"
"github.com/kandev/kandev/internal/linear"
)
type fakeLinearService struct {
reserveOK bool
reserveErr error
assignErr error
releaseErr error
gotReserve []string
gotAssign []string
gotRelease []string
}
func (f *fakeLinearService) ReserveIssueWatchTask(_ context.Context, watchID, id, _ string) (bool, error) {
f.gotReserve = append(f.gotReserve, watchID+":"+id)
return f.reserveOK, f.reserveErr
}
func (f *fakeLinearService) AssignIssueWatchTaskID(_ context.Context, watchID, id, taskID string) error {
f.gotAssign = append(f.gotAssign, watchID+":"+id+":"+taskID)
return f.assignErr
}
func (f *fakeLinearService) ReleaseIssueWatchTask(_ context.Context, watchID, id string) error {
f.gotRelease = append(f.gotRelease, watchID+":"+id)
return f.releaseErr
}
func sampleLinearEvent() *linear.NewLinearIssueEvent {
return &linear.NewLinearIssueEvent{
IssueWatchID: "watch-1",
WorkspaceID: "ws-1",
WorkflowID: "wf-1",
WorkflowStepID: "step-1",
AgentProfileID: "agent-1",
ExecutorProfileID: "exec-1",
Prompt: "Work on {{issue.identifier}}: {{issue.title}}",
Issue: &linear.LinearIssue{
Identifier: "ENG-1",
Title: "Bug",
URL: "https://linear.app/x/issue/ENG-1",
StateName: "Todo",
AssigneeName: "Alice",
CreatorName: "Bob",
Description: "details",
PriorityLabel: "High",
TeamKey: "ENG",
},
}
}
func TestLinearSource_Name(t *testing.T) {
src := &LinearWatcherSource{}
if src.Name() != "linear" {
t.Fatalf("expected name=linear, got %q", src.Name())
}
}
func TestLinearSource_Reserve_Passthrough(t *testing.T) {
svc := &fakeLinearService{reserveOK: true}
src := &LinearWatcherSource{service: svc}
ok, err := src.Reserve(context.Background(), sampleLinearEvent())
if err != nil || !ok {
t.Fatalf("expected reserve ok, got ok=%v err=%v", ok, err)
}
if len(svc.gotReserve) != 1 || svc.gotReserve[0] != "watch-1:ENG-1" {
t.Fatalf("unexpected reserve args: %v", svc.gotReserve)
}
}
func TestLinearSource_Reserve_NilServiceFailOpen(t *testing.T) {
// Matches today's reserveLinearIssue: when service is unset (boot order
// corner case) the pipeline proceeds — better to risk a dup than drop.
src := &LinearWatcherSource{service: nil}
ok, err := src.Reserve(context.Background(), sampleLinearEvent())
if err != nil || !ok {
t.Fatalf("expected nil service to fail open, got ok=%v err=%v", ok, err)
}
}
func TestLinearSource_Reserve_Error(t *testing.T) {
svc := &fakeLinearService{reserveErr: errors.New("boom")}
src := &LinearWatcherSource{service: svc}
ok, err := src.Reserve(context.Background(), sampleLinearEvent())
if ok {
t.Fatal("expected reserve to fail")
}
if err == nil {
t.Fatal("expected reserve error to surface")
}
}
func TestLinearSource_BuildTaskRequest(t *testing.T) {
src := &LinearWatcherSource{}
req, err := src.BuildTaskRequest(sampleLinearEvent())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if req.Title != "[ENG-1] Bug" {
t.Errorf("title = %q", req.Title)
}
if req.WorkspaceID != "ws-1" || req.WorkflowID != "wf-1" || req.WorkflowStepID != "step-1" {
t.Errorf("workflow fields wrong: %+v", req)
}
if req.Description != "Work on ENG-1: Bug" {
t.Errorf("prompt interpolation wrong: %q", req.Description)
}
if req.Metadata["linear_issue_watch_id"] != "watch-1" {
t.Errorf("missing linear_issue_watch_id metadata")
}
if req.Metadata["linear_issue_identifier"] != "ENG-1" {
t.Errorf("missing linear_issue_identifier metadata")
}
if req.Metadata["agent_profile_id"] != "agent-1" {
t.Errorf("missing agent_profile_id metadata")
}
}
func TestLinearSource_BuildTaskRequest_WrongType(t *testing.T) {
src := &LinearWatcherSource{}
_, err := src.BuildTaskRequest("not an event")
if err == nil {
t.Fatal("expected error for wrong event type")
}
}
func TestLinearSource_AttachTaskID(t *testing.T) {
svc := &fakeLinearService{}
src := &LinearWatcherSource{service: svc}
if err := src.AttachTaskID(context.Background(), sampleLinearEvent(), "task-9"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(svc.gotAssign) != 1 || svc.gotAssign[0] != "watch-1:ENG-1:task-9" {
t.Fatalf("unexpected assign args: %v", svc.gotAssign)
}
}
func TestLinearSource_Release(t *testing.T) {
svc := &fakeLinearService{}
src := &LinearWatcherSource{service: svc}
src.Release(context.Background(), sampleLinearEvent())
if len(svc.gotRelease) != 1 || svc.gotRelease[0] != "watch-1:ENG-1" {
t.Fatalf("unexpected release args: %v", svc.gotRelease)
}
}
func TestLinearSource_Release_ErrorIsLoggedNotPropagated(t *testing.T) {
svc := &fakeLinearService{releaseErr: errors.New("dedup store down")}
src := &LinearWatcherSource{service: svc, logger: nopLogger(t)}
// Release returns no value: failure must be swallowed and logged.
// We only assert it does not panic and that the underlying service
// was still asked to release.
src.Release(context.Background(), sampleLinearEvent())
if len(svc.gotRelease) != 1 {
t.Fatalf("expected release call to be attempted, got %d", len(svc.gotRelease))
}
}
func TestLinearSource_AutoStartParams(t *testing.T) {
src := &LinearWatcherSource{}
p := src.AutoStartParams(sampleLinearEvent())
if p.AgentProfileID != "agent-1" || p.ExecutorProfileID != "exec-1" {
t.Fatalf("unexpected auto-start params: %+v", p)
}
if p.WorkflowStepID != "step-1" {
t.Errorf("step id wrong: %q", p.WorkflowStepID)
}
if p.Prompt != "Work on ENG-1: Bug" {
t.Errorf("prompt should be interpolated, got %q", p.Prompt)
}
}
@@ -0,0 +1,146 @@
package orchestrator
import (
"context"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
)
// Log field keys repeated across coordinator log calls. Hoisted to file
// constants so a typo would be a compile error rather than a silent log-
// search miss.
const (
logFieldSource = "source"
logFieldTaskID = "task_id"
)
// WatcherDispatchCoordinator owns the cross-integration pipeline that turns
// a freshly-observed external issue (Linear, Jira, future: GitHub issues,
// webhooks) into a Kandev task. It is the single seam where throttling,
// observability, retry, or fairness will land — integration-specific code
// stays in WatcherSource implementations.
//
// Pipeline:
//
// Reserve → BuildTaskRequest → CreateIssueTask → AttachTaskID
// → (optional) StartTask
//
// On any failure between Reserve and a successful CreateIssueTask, Release
// is invoked so the dedup row does not strand the issue.
type WatcherDispatchCoordinator struct {
taskCreator IssueTaskCreator
startTask taskStarter
shouldAutoStart func(ctx context.Context, workflowStepID string) bool
logger *logger.Logger
}
// taskStarter wraps Service.StartTask so the coordinator can be tested
// without spinning up the full orchestrator service.
type taskStarter interface {
Start(ctx context.Context, taskID, workflowStepID string, params AutoStartParams) error
}
// AutoStartParams is the data a source contributes when the resulting task's
// workflow step has auto-start enabled.
type AutoStartParams struct {
AgentProfileID string
ExecutorProfileID string
Prompt string
WorkflowStepID string
}
// WatcherSource encapsulates everything integration-specific about turning
// a freshly-observed external issue into a Kandev task. Each method receives
// the bus event payload as `any`; implementations type-assert at the top.
// A failed assertion is a programming error (the subscriber wired the wrong
// source) — implementations panic via the assertion's `ok` branch.
type WatcherSource interface {
// Name returns a stable identifier ("linear", "jira", ...). Used for
// metrics labels and log fields.
Name() string
// Reserve atomically claims the dedup slot for this event. Returns
// (false, nil) when another concurrent reserver already won the race —
// the coordinator treats that as "nothing to do".
Reserve(ctx context.Context, evt any) (bool, error)
// Release rolls back a reservation when downstream work fails. Best
// effort; errors are logged but not surfaced.
Release(ctx context.Context, evt any)
// BuildTaskRequest translates the event into the shape the task creator
// expects. Returning an error triggers Release.
BuildTaskRequest(evt any) (*IssueTaskRequest, error)
// AttachTaskID writes the freshly-created task id back onto the dedup
// row so a future re-observation can short-circuit. Errors are logged
// but do not stop the pipeline — matching existing behaviour.
AttachTaskID(ctx context.Context, evt any, taskID string) error
// AutoStartParams returns the parameters needed to kick the task off
// when its workflow step is configured for auto-start.
AutoStartParams(evt any) AutoStartParams
}
// Dispatch runs one event through the full pipeline. Safe to call from a
// goroutine; callers typically do so in the bus subscriber.
func (c *WatcherDispatchCoordinator) Dispatch(ctx context.Context, src WatcherSource, evt any) {
reserved, err := src.Reserve(ctx, evt)
if err != nil {
c.logger.Error("watcher dispatch: reserve failed",
zap.String(logFieldSource, src.Name()), zap.Error(err))
return
}
if !reserved {
c.logger.Debug("watcher dispatch: already reserved by concurrent handler",
zap.String(logFieldSource, src.Name()))
return
}
req, err := src.BuildTaskRequest(evt)
if err != nil {
c.logger.Error("watcher dispatch: build task request failed",
zap.String(logFieldSource, src.Name()), zap.Error(err))
src.Release(ctx, evt)
return
}
task, err := c.taskCreator.CreateIssueTask(ctx, req)
if err != nil {
c.logger.Error("watcher dispatch: create issue task failed",
zap.String(logFieldSource, src.Name()), zap.Error(err))
src.Release(ctx, evt)
return
}
if err := src.AttachTaskID(ctx, evt, task.ID); err != nil {
c.logger.Error("watcher dispatch: attach task id failed",
zap.String(logFieldSource, src.Name()),
zap.String(logFieldTaskID, task.ID),
zap.Error(err))
// Do NOT release here — matches existing Linear/Jira behaviour:
// attach is a best-effort step, the task is already created.
}
c.logger.Info("watcher dispatch: created issue task",
zap.String(logFieldSource, src.Name()),
zap.String(logFieldTaskID, task.ID))
if !c.shouldAutoStart(ctx, req.WorkflowStepID) {
return
}
params := src.AutoStartParams(evt)
if err := c.startTask.Start(ctx, task.ID, req.WorkflowStepID, params); err != nil {
c.logger.Error("watcher dispatch: auto-start failed",
zap.String(logFieldSource, src.Name()),
zap.String(logFieldTaskID, task.ID),
zap.Error(err))
return
}
c.logger.Info("watcher dispatch: auto-started issue task",
zap.String(logFieldSource, src.Name()),
zap.String(logFieldTaskID, task.ID))
}
@@ -0,0 +1,289 @@
package orchestrator
import (
"context"
"errors"
"testing"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/task/models"
)
// nopLogger returns a *logger.Logger backed by zap.NewNop() for use in tests.
// (The shared logger package does not expose a NewNop helper today.)
func nopLogger(t *testing.T) *logger.Logger {
t.Helper()
l, err := logger.NewFromZap(zap.NewNop())
if err != nil {
t.Fatalf("failed to build nop logger: %v", err)
}
return l
}
// fakeWatcherSource is a configurable test double for WatcherSource. Tests
// flip the public fields to drive coordinator branches; recorded* fields let
// the test assert which coordinator stages actually ran.
type fakeWatcherSource struct {
name string
reserveOK bool
reserveErr error
buildReq *IssueTaskRequest
buildErr error
attachErr error
autoStart AutoStartParams
recordedReserve int
recordedRelease int
recordedBuild int
recordedAttach int
recordedAutoArgs *AutoStartParams
}
func (f *fakeWatcherSource) Name() string { return f.name }
func (f *fakeWatcherSource) Reserve(_ context.Context, _ any) (bool, error) {
f.recordedReserve++
return f.reserveOK, f.reserveErr
}
func (f *fakeWatcherSource) Release(_ context.Context, _ any) {
f.recordedRelease++
}
func (f *fakeWatcherSource) BuildTaskRequest(_ any) (*IssueTaskRequest, error) {
f.recordedBuild++
return f.buildReq, f.buildErr
}
func (f *fakeWatcherSource) AttachTaskID(_ context.Context, _ any, _ string) error {
f.recordedAttach++
return f.attachErr
}
func (f *fakeWatcherSource) AutoStartParams(_ any) AutoStartParams {
p := f.autoStart
f.recordedAutoArgs = &p
return p
}
// fakeTaskCreator captures the request and returns a canned task or error.
type fakeTaskCreator struct {
createErr error
gotReq *IssueTaskRequest
returned *models.Task
}
func (f *fakeTaskCreator) CreateIssueTask(_ context.Context, req *IssueTaskRequest) (*models.Task, error) {
f.gotReq = req
if f.createErr != nil {
return nil, f.createErr
}
if f.returned == nil {
f.returned = &models.Task{ID: "task-1"}
}
return f.returned, nil
}
func newTestCoordinator(t *testing.T, tc *fakeTaskCreator, autoStart bool, starter taskStarter) *WatcherDispatchCoordinator {
t.Helper()
return &WatcherDispatchCoordinator{
taskCreator: tc,
logger: nopLogger(t),
shouldAutoStart: func(_ context.Context, _ string) bool {
return autoStart
},
startTask: starter,
}
}
// taskStarter is the indirection used inside Coordinator so tests can
// inspect whether StartTask was invoked.
type fakeTaskStarter struct {
called int
gotID string
gotStep string
err error
}
func (f *fakeTaskStarter) Start(_ context.Context, taskID, stepID string, _ AutoStartParams) error {
f.called++
f.gotID = taskID
f.gotStep = stepID
return f.err
}
func TestCoordinator_Dispatch_HappyPath(t *testing.T) {
src := &fakeWatcherSource{
name: "linear",
reserveOK: true,
buildReq: &IssueTaskRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf-1",
WorkflowStepID: "step-1",
Title: "[ENG-1] Hello",
Description: "body",
},
autoStart: AutoStartParams{
AgentProfileID: "agent-1",
ExecutorProfileID: "exec-1",
Prompt: "body",
WorkflowStepID: "step-1",
},
}
tc := &fakeTaskCreator{}
starter := &fakeTaskStarter{}
c := newTestCoordinator(t, tc, true, starter)
c.Dispatch(context.Background(), src, "evt")
if src.recordedReserve != 1 || src.recordedBuild != 1 || src.recordedAttach != 1 {
t.Fatalf("expected reserve/build/attach each once, got R=%d B=%d A=%d",
src.recordedReserve, src.recordedBuild, src.recordedAttach)
}
if src.recordedRelease != 0 {
t.Fatalf("expected no release on happy path, got %d", src.recordedRelease)
}
if tc.gotReq == nil || tc.gotReq.Title != "[ENG-1] Hello" {
t.Fatalf("expected task creator to receive built request, got %+v", tc.gotReq)
}
if starter.called != 1 {
t.Fatalf("expected auto-start to fire once, got %d", starter.called)
}
if starter.gotID != "task-1" || starter.gotStep != "step-1" {
t.Fatalf("auto-start received wrong args: id=%s step=%s", starter.gotID, starter.gotStep)
}
}
func TestCoordinator_Dispatch_ReserveLost(t *testing.T) {
src := &fakeWatcherSource{name: "linear", reserveOK: false}
tc := &fakeTaskCreator{}
starter := &fakeTaskStarter{}
c := newTestCoordinator(t, tc, true, starter)
c.Dispatch(context.Background(), src, "evt")
if src.recordedReserve != 1 {
t.Fatalf("expected reserve to be attempted, got %d", src.recordedReserve)
}
if src.recordedBuild != 0 || src.recordedAttach != 0 || starter.called != 0 || tc.gotReq != nil {
t.Fatalf("expected pipeline to stop after reserve=false, got B=%d A=%d Start=%d gotReq=%v",
src.recordedBuild, src.recordedAttach, starter.called, tc.gotReq)
}
}
func TestCoordinator_Dispatch_ReserveError(t *testing.T) {
src := &fakeWatcherSource{name: "linear", reserveOK: false, reserveErr: errors.New("db down")}
tc := &fakeTaskCreator{}
starter := &fakeTaskStarter{}
c := newTestCoordinator(t, tc, true, starter)
c.Dispatch(context.Background(), src, "evt")
if src.recordedBuild != 0 || tc.gotReq != nil {
t.Fatal("expected pipeline to stop after reserve error")
}
}
func TestCoordinator_Dispatch_BuildError_Releases(t *testing.T) {
src := &fakeWatcherSource{name: "linear", reserveOK: true, buildErr: errors.New("bad payload")}
tc := &fakeTaskCreator{}
starter := &fakeTaskStarter{}
c := newTestCoordinator(t, tc, true, starter)
c.Dispatch(context.Background(), src, "evt")
if src.recordedRelease != 1 {
t.Fatalf("expected release after build error, got %d", src.recordedRelease)
}
if tc.gotReq != nil || starter.called != 0 {
t.Fatal("expected pipeline to stop after build error")
}
}
func TestCoordinator_Dispatch_CreateError_Releases(t *testing.T) {
src := &fakeWatcherSource{
name: "linear",
reserveOK: true,
buildReq: &IssueTaskRequest{WorkspaceID: "ws-1"},
}
tc := &fakeTaskCreator{createErr: errors.New("write failed")}
starter := &fakeTaskStarter{}
c := newTestCoordinator(t, tc, true, starter)
c.Dispatch(context.Background(), src, "evt")
if src.recordedRelease != 1 {
t.Fatalf("expected release after create error, got %d", src.recordedRelease)
}
if src.recordedAttach != 0 || starter.called != 0 {
t.Fatal("expected pipeline to stop after create error")
}
}
func TestCoordinator_Dispatch_NoAutoStart(t *testing.T) {
src := &fakeWatcherSource{
name: "linear",
reserveOK: true,
buildReq: &IssueTaskRequest{WorkspaceID: "ws-1", WorkflowStepID: "step-1"},
}
tc := &fakeTaskCreator{}
starter := &fakeTaskStarter{}
c := newTestCoordinator(t, tc, false, starter)
c.Dispatch(context.Background(), src, "evt")
if src.recordedAttach != 1 {
t.Fatalf("expected attach to run, got %d", src.recordedAttach)
}
if starter.called != 0 {
t.Fatalf("expected no auto-start when shouldAutoStart=false, got %d", starter.called)
}
}
func TestCoordinator_Dispatch_AttachError_LogsButContinues(t *testing.T) {
src := &fakeWatcherSource{
name: "linear",
reserveOK: true,
buildReq: &IssueTaskRequest{WorkspaceID: "ws-1", WorkflowStepID: "step-1"},
attachErr: errors.New("attach failed"),
}
tc := &fakeTaskCreator{}
starter := &fakeTaskStarter{}
c := newTestCoordinator(t, tc, true, starter)
c.Dispatch(context.Background(), src, "evt")
// Today's createLinearIssueTask logs the attach failure but still auto-starts;
// preserve that behaviour exactly.
if starter.called != 1 {
t.Fatalf("expected auto-start to still run after attach error, got %d", starter.called)
}
// Attach failure is best-effort: the task was already created, so we
// MUST NOT Release the dedup row — that would orphan the task.
if src.recordedRelease != 0 {
t.Fatalf("expected no Release after attach error, got %d", src.recordedRelease)
}
}
func TestCoordinator_Dispatch_AutoStartError_LogsAndReturns(t *testing.T) {
src := &fakeWatcherSource{
name: "linear",
reserveOK: true,
buildReq: &IssueTaskRequest{WorkspaceID: "ws-1", WorkflowStepID: "step-1"},
}
tc := &fakeTaskCreator{}
starter := &fakeTaskStarter{err: errors.New("start failed")}
c := newTestCoordinator(t, tc, true, starter)
c.Dispatch(context.Background(), src, "evt")
if starter.called != 1 {
t.Fatalf("expected auto-start to be attempted, got %d", starter.called)
}
// Auto-start failure is terminal: the task exists and the dedup row is
// already attached. We must not Release.
if src.recordedRelease != 0 {
t.Fatalf("expected no Release after auto-start error, got %d", src.recordedRelease)
}
}
@@ -0,0 +1,69 @@
package orchestrator
import (
"context"
"fmt"
"go.uber.org/zap"
)
// serviceTaskStarter adapts Service.StartTask to the coordinator's
// taskStarter interface. Lives in its own file so the wiring stays close
// to the Service definition without polluting watcher_dispatch.go with
// orchestrator-internal types.
type serviceTaskStarter struct{ svc *Service }
func (s serviceTaskStarter) Start(ctx context.Context, taskID, workflowStepID string, p AutoStartParams) error {
_, err := s.svc.StartTask(
ctx, taskID, p.AgentProfileID, "", p.ExecutorProfileID,
"", p.Prompt, workflowStepID, false, true, nil,
)
return err
}
// initWatcherCoordinator builds the coordinator (once) and (always) refreshes
// the mutable taskCreator dependency. Called from SetIssueTaskCreator, which
// can be invoked multiple times — tests in particular may swap creators
// between scenarios. Re-running the setter MUST update the coordinator,
// otherwise Dispatch silently keeps the original creator.
func (s *Service) initWatcherCoordinator() {
if s.watcherCoordinator == nil {
s.watcherCoordinator = &WatcherDispatchCoordinator{
startTask: serviceTaskStarter{svc: s},
shouldAutoStart: func(ctx context.Context, stepID string) bool {
return s.shouldAutoStartStep(ctx, stepID)
},
logger: s.logger,
}
}
// Always refresh: SetIssueTaskCreator may be called more than once.
s.watcherCoordinator.taskCreator = s.issueTaskCreator
}
// dispatchWatcherEvent runs the wiring guards every per-integration bus
// handler shares — issueTaskCreator check, coordinator check, and the
// final goroutine dispatch with cancellation detached. integration is used
// in log message templating ("new linear issue ...", "skipping jira task ...").
// fields are the structured log fields that identify the event in operator
// logs; pass at least the issue_watch_id and an integration-specific
// identifier field so a deferred / dropped event is diagnosable.
//
// Lives in its own helper so per-integration handlers stay below dupl's
// duplicate-block threshold without copy-pasting the same guards.
func (s *Service) dispatchWatcherEvent(ctx context.Context, integration string, src WatcherSource, evt any, fields ...zap.Field) {
s.logger.Info(fmt.Sprintf("new %s issue detected from watch", integration), fields...)
if s.issueTaskCreator == nil {
s.logger.Warn(fmt.Sprintf("issue task creator not configured, skipping %s task creation", integration))
return
}
if s.watcherCoordinator == nil {
// Defensive: coordinator is wired by SetIssueTaskCreator. If we got
// here without the creator we already returned above; this is just
// a belt-and-braces guard for tests that wire pieces individually.
s.logger.Warn(fmt.Sprintf("watcher coordinator not configured, skipping %s task dispatch", integration), fields...)
return
}
// Detach from cancellation but keep request-scoped values (tracing, etc.):
// the bus delivery context may be cancelled before task creation finishes.
go s.watcherCoordinator.Dispatch(context.WithoutCancel(ctx), src, evt)
}