Files
kandev/apps/backend/internal/office/scheduler/run.go
T

521 lines
21 KiB
Go

// Package scheduler orchestrates run processing for the office domain.
// It wraps service.Service and owns retry logic and dispatch routing.
package scheduler
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/office/models"
"github.com/kandev/kandev/internal/office/pause"
"github.com/kandev/kandev/internal/office/repository/sqlite"
"github.com/kandev/kandev/internal/office/routing"
"github.com/kandev/kandev/internal/office/service"
"github.com/kandev/kandev/internal/office/shared"
runssqlite "github.com/kandev/kandev/internal/runs/repository/sqlite"
runsservice "github.com/kandev/kandev/internal/runs/service"
"github.com/kandev/kandev/internal/workflow/engine"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
)
// ErrRoutingNotSupported is returned by TaskStarter.StartTaskWithRoute
// when the implementing starter cannot honour a routing override. The
// scheduler falls back to the legacy concrete-profile launch path on
// this sentinel.
var ErrRoutingNotSupported = errors.New("routing not supported by task starter")
// RouteOverride carries a fully resolved provider profile for one launch.
// It is intentionally richer than (providerID, model) because cross-
// provider routing means CLI mode, flags, and env are provider-scoped
// — they cannot safely be inherited from a base AgentProfile authored
// against a different provider.
//
// Permission knobs are NOT in this struct: the launch path's permission
// model is the base profile's CLIFlags + AutoApprove booleans, not a
// preset-by-name. Per-provider permission overrides would require
// re-modelling that surface and are deferred.
type RouteOverride = service.RouteOverride
// LaunchContext is an alias for service.LaunchContext so dispatch
// callsites inside this package can spell the type without re-imports.
// The scheduler-side dispatcher takes a service.LaunchContext directly.
// See service.LaunchContext for field semantics.
type LaunchContext = service.LaunchContext
// Run reason constants.
const (
RunReasonTaskAssigned = "task_assigned"
RunReasonTaskComment = "task_comment"
RunReasonTaskBlockersResolved = "task_blockers_resolved"
RunReasonTaskChildrenCompleted = "task_children_completed"
RunReasonApprovalResolved = "approval_resolved"
RunReasonRoutineTrigger = "routine_trigger"
// RunReasonHeartbeat aliases shared.RunReasonHeartbeat so this package's
// local constant and the shared idle-skip classifier cannot drift apart
// the way the un-aliased pair did before WO-46 (Review round 1, S2).
RunReasonHeartbeat = shared.RunReasonHeartbeat
RunReasonBudgetAlert = "budget_alert"
RunReasonAgentError = "agent_error"
// Reactivity-pipeline reasons.
RunReasonTaskUnblocked = "task_unblocked" // status: blocked → not blocked
RunReasonTaskReopened = "task_reopened" // silent reopen (status only)
RunReasonTaskReopenedComment = "task_reopened_via_comment" // user comment on closed task or resume:true
RunReasonTaskMentioned = "task_mentioned" // @mention in comment, additive to assignee wake
RunReasonStagePending = "stage_pending" // execution policy advanced to a new stage
RunReasonStageChangesRequested = "stage_changes_requested" // reviewer asked for rework
// Approval-flow reactivity reasons (B5).
RunReasonTaskReviewRequested = "task_review_requested" // task entered in_review; ping reviewers/approvers
RunReasonTaskChangesRequested = "task_changes_requested" // a reviewer/approver asked for changes
RunReasonTaskReadyToClose = "task_ready_to_close" // all approvers have approved; assignee may close
)
// RunContext is the structured payload attached to every run the
// reactivity pipeline produces. It is JSON-serialised into the
// Run.Payload column so the agent runtime can pick the right
// system prompt template based on the reason.
type RunContext struct {
Reason string `json:"reason"`
TaskID string `json:"task_id"`
WorkspaceID string `json:"workspace_id,omitempty"`
// WorkflowStepID is the parent's workflow step at wake time. An
// engine-routed producer's request always carries it (runs/service's
// runPayload copies it from the typed field), and
// evaluateRunStaleness reads it to cancel a queued run whose parent
// has since moved to a different step. Left empty, that guard never
// applies — so cascade must set this whenever it can be resolved, or
// the two producers' wakes for the same wave are not equivalent.
WorkflowStepID string `json:"workflow_step_id,omitempty"`
ActorID string `json:"actor_id,omitempty"`
ActorType string `json:"actor_type,omitempty"` // "user" | "agent"
CommentID string `json:"comment_id,omitempty"`
ResolvedBlockerTaskID string `json:"resolved_blocker_task_id,omitempty"`
ChildTaskID string `json:"child_task_id,omitempty"`
StageID string `json:"stage_id,omitempty"`
AllowedActions []string `json:"allowed_actions,omitempty"`
// Role is the participant role the recipient holds for the task
// (reviewer|approver). Set by the approval-flow reactivity hooks
// so the agent's prompt builder can render an appropriate
// "you are the reviewer/approver" framing.
Role string `json:"role,omitempty"`
// DecisionComment carries the comment text supplied with a
// changes_requested decision so the assignee receiving
// task_changes_requested has the context inline.
DecisionComment string `json:"decision_comment,omitempty"`
// IdempotencyKey is the dedup identity QueueRunCtx passes through
// verbatim. Empty means no dedup — the run enqueues keyless — not
// "derive one for me": QueueRunCtx no longer synthesises a
// "{reason}:{taskID}:{agentID}" default, which was permanently unique
// per (reason, task, agent) and silently swallowed every later
// legitimate occurrence for the same triple. Callers that want dedup
// build a key that changes with the thing that makes each occurrence
// distinct. Excluded from the JSON payload: it must never change
// encodeRunContext's output shape, which CoalesceRun compares for
// equality and taskIDFromPayload parses.
IdempotencyKey string `json:"-"`
// WaveKey and WaveString carry a completion-wave identity onto the
// persisted run (models.Run.WakeWaveKey / WakeWaveString), not into
// the JSON payload: they gate admission via idx_run_wake_wave and
// coalescing, not agent-facing content. Empty means "no wave
// identity" — the ordinary idempotency-key path applies instead.
WaveKey string `json:"-"`
WaveString string `json:"-"`
// ExtraPayload, when non-empty, is merged onto the JSON-encoded
// payload by encodeRunContext (workflow-authored keys win over any
// struct field of the same name). Left nil, encodeRunContext's
// output is byte-identical to a plain struct marshal.
ExtraPayload map[string]any `json:"-"`
}
// Run status constants.
const (
RunStatusQueued = "queued"
RunStatusClaimed = "claimed"
RunStatusFinished = "finished"
RunStatusFailed = "failed"
)
// CoalesceWindowSeconds is the default coalescing window.
const CoalesceWindowSeconds = 5
// IdempotencyWindowHours is the deduplication window.
const IdempotencyWindowHours = 24
// TaskStarter launches agent sessions on behalf of the office scheduler.
// Implemented by the orchestrator; the scheduler depends only on this interface.
type TaskStarter interface {
StartTask(
ctx context.Context,
taskID, agentProfileID, executorID, executorProfileID string,
priority string, prompt, workflowStepID string,
planMode bool, attachments []interface{},
) error
// StartTaskWithRoute launches a task with a fully resolved provider
// override. Implementations that cannot apply a route override return
// ErrRoutingNotSupported so the scheduler can fall through to the
// legacy StartTask path. LaunchContext carries the Office-built
// prompt, env, workflow step, attachments, and plan-mode flag so
// routed launches do not lose role framing / AGENTS.md / wake
// context vs the legacy launch path.
StartTaskWithRoute(
ctx context.Context,
taskID, agentProfileID string,
launch LaunchContext,
route RouteOverride,
) error
}
// SchedulerService orchestrates run processing.
// It holds the service layer and the SQLite repository.
// The unexported service helpers (kandevBasePath, resolveAgentType,
// resolveProjectSkillDir) are stored as function callbacks configured via setters.
type SchedulerService struct {
repo *sqlite.Repository
logger *logger.Logger
svc *service.Service
taskStarter TaskStarter
runSessionLauncher service.RunSessionLauncher
resolver *routing.Resolver
eb bus.EventBus
apiBaseURL string
agentctlPath string
kandevBasePathFn func() string
agentTypeResolver func(profileID string) string
projectSkillDirResolver func(agentTypeID string) string
workflowStepGetter WorkflowStepGetter
participantStore engine.ParticipantStore
pauseGate shared.PauseGate
}
// WorkflowStepGetter resolves a workflow step by ID. Implemented by
// workflow/service.Service.GetStep; wired via SetWorkflowStepGetter so the
// cascade producer can resolve the parent's current step without an
// engine dependency, for payload parity with the engine-routed producers.
type WorkflowStepGetter interface {
GetStep(ctx context.Context, stepID string) (*wfmodels.WorkflowStep, error)
}
// SetWorkflowStepGetter wires the workflow step lookup used for payload
// parity. Left nil, cascade wakes queue without a merged action payload.
func (ss *SchedulerService) SetWorkflowStepGetter(g WorkflowStepGetter) {
ss.workflowStepGetter = g
}
// SetParticipantStore wires the participant seat resolution used for
// queue_run_for_each_participant payload parity — the same
// engine.ParticipantStore instance the workflow engine itself uses
// (workflow/adapters.ParticipantAdapter in production). Left nil, cascade
// never attaches a for-each-participant action's payload.
func (ss *SchedulerService) SetParticipantStore(store engine.ParticipantStore) {
ss.participantStore = store
}
// NewSchedulerService creates a new SchedulerService.
func NewSchedulerService(
repo *sqlite.Repository,
log *logger.Logger,
svc *service.Service,
) *SchedulerService {
return &SchedulerService{
repo: repo,
logger: log.WithFields(zap.String("component", "office-scheduler")),
svc: svc,
}
}
// SetTaskStarter wires the orchestrator task starter.
func (ss *SchedulerService) SetTaskStarter(ts TaskStarter) {
ss.taskStarter = ts
}
// SetRunSessionLauncher wires the Office-owned taskless launch seam.
func (ss *SchedulerService) SetRunSessionLauncher(launcher service.RunSessionLauncher) {
ss.runSessionLauncher = launcher
}
// SetResolver wires the routing resolver. When set, dispatch goes through
// dispatchWithRouting; when nil, the legacy concrete-profile path runs.
func (ss *SchedulerService) SetResolver(r *routing.Resolver) {
ss.resolver = r
}
// SetEventBus wires the bus used to publish routing-side WS events
// (provider_health_changed, route_attempt_appended). Optional; nil keeps
// the scheduler silent and tests don't need to stand up a bus.
func (ss *SchedulerService) SetEventBus(eb bus.EventBus) {
ss.eb = eb
}
// Resolver returns the wired routing resolver (may be nil).
func (ss *SchedulerService) Resolver() *routing.Resolver { return ss.resolver }
// Repo returns the underlying repository. Exposed so the service-tier
// SchedulerIntegration can call the routing-specific repo methods without
// holding an independent handle.
func (ss *SchedulerService) Repo() *sqlite.Repository { return ss.repo }
// SetAPIBaseURL sets the base URL injected into KANDEV_API_URL.
func (ss *SchedulerService) SetAPIBaseURL(url string) {
ss.apiBaseURL = url
}
// SetAgentctlBinaryPath sets the host path to the agentctl binary.
func (ss *SchedulerService) SetAgentctlBinaryPath(path string) {
ss.agentctlPath = path
}
// SetKandevBasePathFn sets the function used to resolve the kandev base path.
func (ss *SchedulerService) SetKandevBasePathFn(fn func() string) {
ss.kandevBasePathFn = fn
}
// SetAgentTypeResolver sets the function that maps agent profile IDs to type IDs.
func (ss *SchedulerService) SetAgentTypeResolver(fn func(profileID string) string) {
ss.agentTypeResolver = fn
}
// SetProjectSkillDirResolver sets the function that maps agent type IDs to
// their CWD-relative skill directories.
func (ss *SchedulerService) SetProjectSkillDirResolver(fn func(agentTypeID string) string) {
ss.projectSkillDirResolver = fn
}
// SetPauseGate wires the workspace-pause read used by QueueRun to
// enforce the operator kill switch. Optional — when nil the gate is
// not enforced.
func (ss *SchedulerService) SetPauseGate(g shared.PauseGate) {
ss.pauseGate = g
}
// QueueRun enqueues a run request for an agent instance.
// It checks agent status, idempotency, and attempts coalescing before inserting.
// Implements shared.RunQueuer.
func (ss *SchedulerService) QueueRun(
ctx context.Context,
agentInstanceID, reason, payload, idempotencyKey string,
) (runsservice.QueueOutcome, error) {
return ss.queueRun(ctx, agentInstanceID, reason, payload, idempotencyKey, "", "")
}
// queueRun is QueueRun plus an optional completion-wave identity. A
// non-empty waveKey (a) skips CoalesceRun — a wave-carrying request is
// never coalesced in and a wave-carrying queued run is never coalesced
// into — and (b) classifies CreateRun's idx_run_wake_wave violation as an
// already-delivered wake rather than an error: this call site inserts
// directly (not through runs/service), so ReportInsertResult's own
// idx_run_idempotency-only classification doesn't cover it. A concurrent
// duplicate of this same request can just as well lose the race on
// idx_run_idempotency instead of idx_run_wake_wave — both keys identify the
// identical operation for the identical row, so either violation means the
// wake is already recorded and neither is an error, mirroring
// runs/service.QueueRun's own two-way classification.
func (ss *SchedulerService) queueRun(
ctx context.Context,
agentInstanceID, reason, payload, idempotencyKey, waveKey, waveString string,
) (runsservice.QueueOutcome, error) {
agent, err := ss.guardAgentStatus(ctx, agentInstanceID)
if err != nil {
return runsservice.QueueOutcomeNone, err
}
if err := ss.checkPauseGate(ctx, agent); err != nil {
return runsservice.QueueOutcomeNone, err
}
if idempotencyKey != "" {
dup, err := ss.repo.CheckIdempotencyKey(ctx, idempotencyKey, IdempotencyWindowHours)
if err != nil {
return runsservice.QueueOutcomeNone, fmt.Errorf("idempotency check: %w", err)
}
if dup {
return runsservice.ReportWindowedDedup(runsservice.QueueSourceRuns, reason, idempotencyKey), nil
}
}
if waveKey == "" {
coalesced, err := ss.repo.CoalesceRun(ctx, agentInstanceID, reason, CoalesceWindowSeconds, payload)
if err != nil {
return runsservice.QueueOutcomeNone, fmt.Errorf("coalesce check: %w", err)
}
if coalesced {
ss.logger.Debug("run coalesced",
zap.String("agent", agentInstanceID),
zap.String("reason", reason))
return runsservice.QueueOutcomeCoalesced, nil
}
}
var idemKeyPtr *string
if idempotencyKey != "" {
idemKeyPtr = &idempotencyKey
}
req := &models.Run{
ID: uuid.New().String(),
AgentProfileID: agentInstanceID,
Reason: reason,
Payload: payload,
Status: RunStatusQueued,
CoalescedCount: 1,
IdempotencyKey: idemKeyPtr,
WakeWaveKey: waveKey,
WakeWaveString: waveString,
RequestedAt: time.Now().UTC(),
}
insertErr := ss.repo.CreateRun(ctx, req)
if waveKey != "" && runssqlite.IsWakeWaveUniqueViolation(insertErr) {
runsservice.ParentWakeDedupedTotal.Add(1)
ss.logger.Debug("run skipped (wave already woken)",
zap.String("wave_key", waveKey))
return runsservice.QueueOutcomeDeduped, nil
}
outcome, err := runsservice.ReportInsertResult(runsservice.QueueSourceRuns, reason, idempotencyKey, agentInstanceID, insertErr)
if err != nil {
return runsservice.QueueOutcomeNone, fmt.Errorf("enqueue run: %w", err)
}
if outcome == runsservice.QueueOutcomeDeduped {
return outcome, nil
}
ss.logger.Info("run queued",
zap.String("id", req.ID),
zap.String("agent", agentInstanceID),
zap.String("reason", reason))
return runsservice.QueueOutcomeQueued, nil
}
// QueueRunCtx is the typed variant of QueueRun that takes a structured
// RunContext. The context is JSON-encoded into the payload column so the
// agent runtime can deserialise it. The idempotency key is c.IdempotencyKey
// verbatim — an empty key enqueues with no dedup identity rather than
// falling back to a "{reason}:{taskID}:{agentID}" default that would be
// permanently unique per (reason, task, agent) and silently swallow every
// later legitimate occurrence for the same triple.
func (ss *SchedulerService) QueueRunCtx(
ctx context.Context, agentInstanceID string, c RunContext,
) (runsservice.QueueOutcome, error) {
payload, err := encodeRunContext(c)
if err != nil {
return runsservice.QueueOutcomeNone, fmt.Errorf("encode run context: %w", err)
}
return ss.queueRun(ctx, agentInstanceID, c.Reason, payload, c.IdempotencyKey, c.WaveKey, c.WaveString)
}
// encodeRunContext JSON-encodes c. When c.ExtraPayload is empty the output
// is a plain struct marshal, byte-identical to before ExtraPayload existed.
// Otherwise ExtraPayload's keys are overlaid onto the encoded object, then
// c's own envelope fields are re-applied on top — workflow-authored content
// keys win, but a workflow-authored payload can never redirect the run's
// identity. This mirrors runs/service.runPayload's precedence for task_id
// and workflow_step_id: P1 never goes through that function (it inserts via
// ss.repo.CreateRun directly), so encodeRunContext is the only place that
// guarantee can be enforced for the cascade path. Without it, a queue_run
// action's payload.task_id would silently override task.ParentID and
// misdirect the wake to a foreign task — task_id is what
// SchedulerIntegration.extractTaskID reads to check out and budget the run.
//
// Unlike runPayload, this does not re-assert agent_profile_id: RunContext
// carries no typed recipient field to re-assert from (the run's actual
// AgentProfileID column is set separately, from queueRun's own
// agentInstanceID parameter, never from this payload). A workflow-authored
// ExtraPayload["agent_profile_id"] therefore passes through unfiltered —
// currently inert, since no reader in this codebase consults
// payload["agent_profile_id"] for dispatch or routing (both use the DB
// column instead). See
// TestQueueRunCtx_ExtraPayloadAgentProfileID_PassesThroughUnfiltered, which
// pins this as a known non-guarantee rather than an oversight.
func encodeRunContext(c RunContext) (string, error) {
b, err := json.Marshal(c)
if err != nil {
return "", err
}
if len(c.ExtraPayload) == 0 {
return string(b), nil
}
var m map[string]any
if err := json.Unmarshal(b, &m); err != nil {
return "", err
}
for k, v := range c.ExtraPayload {
m[k] = v
}
m["task_id"] = c.TaskID
m["reason"] = c.Reason
if c.WorkspaceID != "" {
m["workspace_id"] = c.WorkspaceID
} else {
delete(m, "workspace_id")
}
if c.ChildTaskID != "" {
m["child_task_id"] = c.ChildTaskID
} else {
delete(m, "child_task_id")
}
if c.WorkflowStepID != "" {
m["workflow_step_id"] = c.WorkflowStepID
} else {
delete(m, "workflow_step_id")
}
merged, err := json.Marshal(m)
if err != nil {
return "", err
}
return string(merged), nil
}
// guardAgentStatus returns an error if the agent is paused or stopped,
// and otherwise the resolved agent — checkPauseGate reuses this fetch
// instead of looking the agent up a second time.
func (ss *SchedulerService) guardAgentStatus(ctx context.Context, agentInstanceID string) (*models.AgentInstance, error) {
agent, err := ss.svc.GetAgentFromConfig(ctx, agentInstanceID)
if err != nil {
return nil, fmt.Errorf("get agent instance: %w", err)
}
switch agent.Status {
case models.AgentStatusPaused:
return nil, fmt.Errorf("agent %s is paused", agentInstanceID)
case models.AgentStatusStopped:
return nil, fmt.Errorf("agent %s is stopped", agentInstanceID)
case models.AgentStatusPendingApproval:
return nil, fmt.Errorf("agent %s is pending approval", agentInstanceID)
}
return agent, nil
}
// checkPauseGate blocks queuing when agent's workspace is paused (the
// operator kill switch). Takes the already-resolved agent — usually
// guardAgentStatus's return value — rather than re-resolving it, so the
// two checks can never see two different snapshots of the agent's
// workspace. Fails closed on a gate-read error
// (shared.ErrPauseGateUnavailable) — this write hasn't happened yet, so
// failing the call is the whole retry story; the caller's own retry (or
// the next event) tries again.
func (ss *SchedulerService) checkPauseGate(ctx context.Context, agent *models.AgentInstance) error {
if ss.pauseGate == nil {
return nil
}
active, err := ss.pauseGate.PauseState(ctx, agent.WorkspaceID)
if err != nil {
pause.RecordGateError("scheduler_queue_run")
ss.logger.Warn("queue run: pause gate read failed",
zap.String("agent", agent.ID), zap.Error(err))
return shared.ErrPauseGateUnavailable
}
if active != nil {
pause.RecordBlocked("scheduler_queue_run")
return shared.ErrWorkspacePaused
}
return nil
}