Files
kandev/apps/backend/internal/orchestrator/watcher_dispatch.go
T

435 lines
17 KiB
Go

package orchestrator
import (
"context"
"errors"
"sync"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/steptelemetry"
"github.com/kandev/kandev/internal/task/service"
workflowmodels "github.com/kandev/kandev/internal/workflow/models"
)
// 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 {
// mu guards taskCreator. SetTaskCreator may be called more than once at
// boot (tests in particular swap creators between scenarios), and
// Dispatch reads from background goroutines spawned by
// dispatchWatcherEvent — so the field needs synchronisation.
mu sync.RWMutex
taskCreator IssueTaskCreator
startTask taskStarter
shouldAutoStart func(ctx context.Context, workflowStepID string) bool
// profileLookup pre-flight checks the watcher's bound agent profile.
// When the profile has been soft-deleted (reconciler-driven cleanup of an
// agent type that fell off the registry), the coordinator short-circuits
// before creating any task and asks the source to self-heal the watcher
// row. nil means "skip the check" — production wires this in; tests can
// leave it unset.
profileLookup ProfileLookup
// repoChecker pre-flight checks the watcher's bound repository (when the
// built request carries one). A repository soft-deleted after the watch was
// bound would otherwise let CreateTask insert the task row before repository
// association fails — leaving an orphan row and a reservation that a later
// poll repeats. When the repo is gone the coordinator self-heals the watch
// and skips. nil means "skip the check".
repoChecker RepositoryChecker
logger *logger.Logger
}
// RepositoryChecker answers "does this repository still exist in the workspace?"
// — used by the watcher dispatch self-heal flow to detect a binding whose
// repository was soft-deleted after the watch was configured. Membership-based
// (a workspace listing) so a definitive "absent" is distinguishable from a
// transient error: (false, nil) = gone → self-heal; a non-nil err = "couldn't
// tell" → fail open (the existing pipeline runs).
type RepositoryChecker interface {
RepositoryExists(ctx context.Context, workspaceID, repositoryID string) (bool, error)
}
// SetRepositoryChecker wires the repository pre-flight check. nil-ok; the
// check becomes a no-op until a real checker is provided.
func (c *WatcherDispatchCoordinator) SetRepositoryChecker(r RepositoryChecker) {
c.mu.Lock()
defer c.mu.Unlock()
c.repoChecker = r
}
func (c *WatcherDispatchCoordinator) getRepositoryChecker() RepositoryChecker {
c.mu.RLock()
defer c.mu.RUnlock()
return c.repoChecker
}
// ProfileLookup answers "is this agent profile still live, and what was its
// display name?" — used by the watcher dispatch self-heal flow to detect
// orphaned watchers (their agent profile was removed by the orchestrator's
// reconciler when its agent type left the enabled registry).
//
// Returning (true, name, nil) means the row exists but has DeletedAt set;
// (false, _, nil) means the row is live; a non-nil err is treated as
// "couldn't tell" and the dispatch falls through (fail-open).
type ProfileLookup interface {
LookupProfile(ctx context.Context, profileID string) (deleted bool, name string, err error)
}
// SetProfileLookup wires the pre-flight check into the coordinator. Safe to
// call before or after task-creator wiring; nil-ok and pre-flight just
// becomes a no-op until a real lookup is provided.
func (c *WatcherDispatchCoordinator) SetProfileLookup(p ProfileLookup) {
c.mu.Lock()
defer c.mu.Unlock()
c.profileLookup = p
}
func (c *WatcherDispatchCoordinator) getProfileLookup() ProfileLookup {
c.mu.RLock()
defer c.mu.RUnlock()
return c.profileLookup
}
// SetTaskCreator atomically updates the task creator the coordinator
// dispatches to. Safe to call concurrently with Dispatch.
func (c *WatcherDispatchCoordinator) SetTaskCreator(tc IssueTaskCreator) {
c.mu.Lock()
defer c.mu.Unlock()
c.taskCreator = tc
}
func (c *WatcherDispatchCoordinator) getTaskCreator() IssueTaskCreator {
c.mu.RLock()
defer c.mu.RUnlock()
return c.taskCreator
}
// 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, prompt string, params AutoStartParams) error
}
// AutoStartParams is the data a source contributes when the resulting task's
// workflow step has auto-start enabled. The prompt is intentionally NOT
// included here — the coordinator passes the created task's Description
// directly so auto-start always uses the persisted task body, matching the
// legacy createXIssueTask call sites byte-for-byte.
type AutoStartParams struct {
AgentProfileID string
ExecutorProfileID 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
// AgentProfileID returns the agent profile bound to the watcher that
// produced this event. The coordinator uses it for the soft-deleted-
// profile pre-flight check; empty means "no profile bound" (legacy
// rows) and the check is skipped.
AgentProfileID(evt any) 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
// WatchID extracts the per-integration watch identifier from the event
// payload. Used by the throttle gate to key the per-watch pending
// counter and look up the per-watch cap. Returns "" if the event has
// no watch (the gate then treats it as unthrottled).
WatchID(evt any) string
// MaxInflightTasks returns the per-watch cap on open watcher-created
// tasks, or nil when the watch is uncapped. The orchestrator gate uses
// this to decide whether to defer the event.
MaxInflightTasks(evt any) *int
// WatchMetadataKey returns the task-metadata key under which this
// integration records the originating watch id (the same key written in
// BuildTaskRequest's Metadata map). The throttle gate passes it to the
// task counter so the repository can tally open tasks for a watch WITHOUT
// hard-coding which integrations exist — the integration owns its own key.
WatchMetadataKey() string
// SelfHeal disables the watcher row that produced this event and stamps
// a human-readable cause so the settings UI can show "disabled because
// the bound agent profile was removed". Called by the coordinator
// (and the legacy GitHub createXTask paths) when the pre-flight check
// detects a soft-deleted profile.
SelfHeal(ctx context.Context, evt any, cause string) error
}
// terminalAttachErrorClassifier marks attachment failures where the task no
// longer belongs to the event's watch generation. The source has already
// performed any required cleanup, so dispatch must stop before auto-start.
type terminalAttachErrorClassifier interface {
IsTerminalAttachError(error) bool
}
// preflightDeletedProfile returns true when the watcher's bound profile has
// been soft-deleted (the production bug that orphans watchers via the
// reconciler's cleanup of disabled agent types). On a true return the
// coordinator MUST stop — SelfHeal has already been invoked.
func (c *WatcherDispatchCoordinator) preflightDeletedProfile(ctx context.Context, src WatcherSource, evt any) bool {
lookup := c.getProfileLookup()
if lookup == nil {
return false
}
profileID := src.AgentProfileID(evt)
if profileID == "" {
return false
}
deleted, name, err := lookup.LookupProfile(ctx, profileID)
if err != nil {
if c.logger != nil {
c.logger.Warn("watcher dispatch: profile lookup failed, falling through",
zap.String("source", src.Name()),
zap.String("profile_id", profileID),
zap.Error(err))
}
return false
}
if !deleted {
return false
}
cause := formatDeletedProfileCause(profileID, name)
if c.logger != nil {
c.logger.Warn("watcher dispatch: agent profile soft-deleted, self-healing",
zap.String("source", src.Name()),
zap.String("profile_id", profileID),
zap.String("profile_name", name))
}
if err := src.SelfHeal(ctx, evt, cause); err != nil && c.logger != nil {
c.logger.Error("watcher dispatch: self-heal failed",
zap.String("source", src.Name()),
zap.String("profile_id", profileID),
zap.Error(err))
}
return true
}
// preflightDeletedRepository checks the bound repository of an already-built
// request. When the repo was soft-deleted after binding, it self-heals the
// watch (so a later poll doesn't repeat) and returns true so the caller skips
// task creation — avoiding an orphan task row from CreateTask's non-atomic
// task-then-repository insert. A lookup error fails open (returns false).
func (c *WatcherDispatchCoordinator) preflightDeletedRepository(ctx context.Context, src WatcherSource, evt any, req *IssueTaskRequest) bool {
checker := c.getRepositoryChecker()
if checker == nil || req == nil {
return false
}
for _, r := range req.Repositories {
if r.RepositoryID == "" {
continue
}
exists, err := checker.RepositoryExists(ctx, req.WorkspaceID, r.RepositoryID)
if err != nil {
if c.logger != nil {
c.logger.Warn("watcher dispatch: repository check failed, falling through",
zap.String("source", src.Name()),
zap.String("repository_id", r.RepositoryID),
zap.Error(err))
}
return false
}
if exists {
continue
}
cause := "bound repository " + r.RepositoryID + " was removed"
if c.logger != nil {
c.logger.Warn("watcher dispatch: bound repository removed, self-healing",
zap.String("source", src.Name()),
zap.String("repository_id", r.RepositoryID))
}
if err := src.SelfHeal(ctx, evt, cause); err != nil && c.logger != nil {
c.logger.Error("watcher dispatch: self-heal failed",
zap.String("source", src.Name()),
zap.String("repository_id", r.RepositoryID),
zap.Error(err))
}
return true
}
return false
}
// formatDeletedProfileCause renders the human-readable string stamped onto
// the watcher's last_error column. Centralised so every integration uses
// the same phrasing.
//
// profileName is user-typed in the settings UI with no DB-level length
// constraint; truncate at the producer so an arbitrarily-long name does
// not pollute last_error / the settings banner.
func formatDeletedProfileCause(profileID, profileName string) string {
name := truncateProfileNameForCause(profileName)
if name != "" {
return "agent profile \"" + name + "\" (" + profileID + ") was removed"
}
return "agent profile " + profileID + " was removed"
}
// profileNameCauseMaxLen caps the rendered profile name in
// formatDeletedProfileCause. 80 runes matches the watcher-label cap in
// cmd/kandev — both end up in the same settings UI surface.
const profileNameCauseMaxLen = 80
func truncateProfileNameForCause(s string) string {
runes := []rune(s)
if len(runes) <= profileNameCauseMaxLen {
return s
}
return string(runes[:profileNameCauseMaxLen-1]) + "…"
}
// Dispatch runs one event through the full pipeline. Safe to call from a
// goroutine; callers typically do so in the bus subscriber.
//
// Pre-flight: when a ProfileLookup is wired AND the source exposes a
// non-empty AgentProfileID, the coordinator first checks that the profile is
// not soft-deleted. A deleted profile short-circuits to SelfHeal — no task
// is created, no dedup reservation is taken. A lookup error fails open: the
// existing pipeline runs and any genuine error surfaces downstream.
func (c *WatcherDispatchCoordinator) Dispatch(ctx context.Context, src WatcherSource, evt any) {
if c.preflightDeletedProfile(ctx, src, evt) {
return
}
reserved, err := src.Reserve(ctx, evt)
if err != nil {
c.logger.Error("watcher dispatch: reserve failed",
zap.String("source", src.Name()), zap.Error(err))
return
}
if !reserved {
c.logger.Debug("watcher dispatch: already reserved by concurrent handler",
zap.String("source", src.Name()))
return
}
req, err := src.BuildTaskRequest(evt)
if err != nil {
c.logger.Error("watcher dispatch: build task request failed",
zap.String("source", src.Name()), zap.Error(err))
src.Release(ctx, evt)
return
}
// Bound the generated title once, here, so every watcher source (Linear,
// Jira, GitLab, future) inherits the task-title limit without each
// BuildTaskRequest re-implementing truncation. A remote issue title that
// would otherwise exceed the limit is shortened rather than rejected by
// backend title validation. GitHub's review/issue path builds its tasks
// through a separate creator and truncates at its own builders.
if req != nil {
req.Title = service.TruncateTaskTitle(req.Title)
}
// A bound repository deleted after the watch was configured would let
// CreateTask insert a task row before repository association fails. Self-heal
// and release the reservation instead of creating an orphan task.
if c.preflightDeletedRepository(ctx, src, evt, req) {
src.Release(ctx, evt)
return
}
// The genesis ledger row's actor is the watch that caused the create —
// external automation, not a human or a bare system default. Trigger is
// left unset: the repository's genesis writer always hard-codes
// task_created, so only the actor needs to travel on ctx here.
createCtx := steptelemetry.WithAttribution(ctx, steptelemetry.Attribution{
ActorKind: steptelemetry.ActorIntegration,
ActorID: src.WatchID(evt),
})
task, err := c.getTaskCreator().CreateIssueTask(createCtx, req)
if err != nil {
if errors.Is(err, workflowmodels.ErrWIPLimitExceeded) {
c.logger.Info("watcher dispatch: workflow step at capacity; deferring issue task",
zap.String("source", src.Name()), zap.String("workflow_step_id", req.WorkflowStepID), zap.Error(err))
} else {
c.logger.Error("watcher dispatch: create issue task failed",
zap.String("source", 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("source", src.Name()),
zap.String("task_id", 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.
if classifier, ok := src.(terminalAttachErrorClassifier); ok && classifier.IsTerminalAttachError(err) {
return
}
}
c.logger.Info("watcher dispatch: created issue task",
zap.String("source", src.Name()),
zap.String("task_id", task.ID))
stepID := task.WorkflowStepID
if stepID == "" {
stepID = req.WorkflowStepID
}
if task.QueuedForStepID != "" || !c.shouldAutoStart(ctx, stepID) {
return
}
params := src.AutoStartParams(evt)
if err := c.startTask.Start(ctx, task.ID, stepID, task.Description, params); err != nil {
if errors.Is(err, ErrCeilingLaunchDeferred) {
c.logger.Info("watcher dispatch: auto-start deferred by session ceiling; will replay once capacity frees up",
zap.String("source", src.Name()),
zap.String("task_id", task.ID))
return
}
c.logger.Error("watcher dispatch: auto-start failed",
zap.String("source", src.Name()),
zap.String("task_id", task.ID),
zap.Error(err))
return
}
c.logger.Info("watcher dispatch: auto-started issue task",
zap.String("source", src.Name()),
zap.String("task_id", task.ID))
}