Add the TaskRouter CRD, controller, and TaskSpawner triggerMode

Draft
KN
15d ago
+19004-28

What type of PR is this?

/kind api

What this PR does / why we need it:

Adds a TaskRouter CRD and its controller, so one Slack front door can pick
which of several agents handles a request.

Dispatch today fans out rather than arbitrates. Every TaskSpawner with a Slack
source is listed for every message (internal/slack/handler.go:335) and each
match creates its own Task, where matching is channel ID + mention + RE2
triggers. Two spawners matching one message produce two Tasks, and users have to
know which agent to mention. A TaskRouter replaces that with one prompt and a
list of named routes.

How it works. The Slack server creates a short-lived decision Task for
every matching router. The decision sees the routing prompt, the route
descriptions, and the request. It emits one route name through the agent image's
KELOS_OUTPUTS block, and the controller builds the Task the selected route's
TaskSpawner would have built.

Why the decision runs as a Task rather than as a provider call from the
controller: no new credential surface in the control plane, and the decision
inherits TaskBudget accounting, TaskRecord, kelos logs and cost metrics.
decision.workerPoolRef removes the per-request pod start when latency matters.

Output returns to the initiator with no new posting path. The decision Task
carries the initiator's Slack reporting annotations and the dispatched Task
inherits them, so internal/reporting answers in the originating thread.
fallback.action: Reply falls out of the same wiring: the decision's own
explanation is already rendered into the thread from results["response"].

The API is scoped to the first iteration:

  • targetRef.kind accepts TaskSpawner only. TaskPipeline targets need
    pipeline reporting that does not exist yet (internal/reporting has no
    TaskPipeline awareness, and stage Tasks do not inherit reporting
    annotations).
  • spec.when reuses the TaskSpawner source vocabulary but is CEL-restricted to
    exactly one of the slack and webhook sources, so a githubIssues router
    cannot apply cleanly and then do nothing. Widening later is a relaxation.
  • spec.decision reuses WorkerSpec, so the decision pod gets
    podOverrides.env and the rest of the Task execution environment free, with
    the same workerPoolRef exclusivity Tasks have.
  • spec.fallback covers Route, Reply and Fail, with CEL tying
    fallback.route to a listed route.

TaskSpawner.spec.triggerMode. A route target may legitimately keep its own
source, so this is an opt-out rather than a requirement. OnDemand stops a
spawner acting on its own source, leaving it reachable by a TaskRouter or
kelos run --from taskspawner/<name>; Source is the default, so existing
manifests are unaffected. Every site that honors it already checked Suspend —
the polling cycle, Slack matching, webhook matching, and workload creation,
where an OnDemand spawner now gets neither Deployment nor CronJob.
spec.when becomes optional under OnDemand, since it would otherwise be
source config that never fires.

Which issue(s) this PR is related to:

Part of #1760. Phase 2 of that issue — TaskPipeline targets and the remaining
sources — is not included.

Special notes for your reviewer:

Two judgment calls worth a second opinion:

  1. spec.when stays a value type instead of becoming a pointer. A pointer
    is the cleaner schema, but it rewrites 260 call sites across 20 files. The
    user-visible contract holds without it: a manifest that omits when is
    accepted only for OnDemand. The gap is that a Go client still serializes
    when as {}, which the rule sees as present — the same way an explicit
    when: {} is accepted today. The integration specs build unstructured
    objects precisely because a Go client cannot express the omission.
  2. The existing spec-level CEL rule that reaches into self.when is
    re-guarded with !has(self.when) || .... Without that, an absent when
    makes the expression error rather than evaluate false. Worth checking I
    caught every one — there was exactly one
    (api/v1alpha2/taskspawner_types.go).

Prompt injection. Request text is untrusted. Dispatch is driven by a closed
set: the route name selects from spec.routes and nothing else. The decision's
free-text explanation is posted verbatim under Reply, so it is model output
shaped by untrusted input — it never selects a target, and docs/reference.md
says plainly that any listed route is reachable by any request.

Overlapping fires are supported, not prevented. When a target stays on
Source, one message can reach it twice. The dispatched Task is named the way
that spawner would have named it, so the two collapse into one Task via
AlreadyExists.

Known gaps, deliberately left out:

  • Generic-webhook ingress. A webhook-sourced TaskRouter validates but never
    receives requests; only the Slack path is wired.
  • Decision Tasks have no TTL, so they accumulate. Adding one races the
    controller's handling of a decision, which is why it is not here.

Testing. make verify clean, make test green across 35 packages,
make test-integration 191 + 12 specs green. Note that
TestSessionTerminalShellCompletion in internal/consoleserver (untouched by
this PR) failed once under parallel load and passed in isolation and on re-run —
flaky, not a regression.

New coverage: 10 controller tests including the dispatch happy path asserting
the built Task carries both the target's template and the initiator's reporting
annotations; 6 Slack ingress tests; 23 TaskRouter CEL specs; 7 triggerMode CEL
specs including the backward-compatibility case that an existing manifest still
applies and defaults to Source; plus polling, webhook, conversion round-trip
and spawner-controller cases.

Does this PR introduce a user-facing change?

Add the TaskRouter CRD, which uses its own agent and a routing prompt to choose
which of several TaskSpawners should handle an incoming Slack request, and
dispatches the work there with the initiator's reporting metadata so results
return to the originating thread. Add TaskSpawner.spec.triggerMode, where
OnDemand stops a spawner acting on its own source and leaves it reachable only
by explicit dispatch.

Summary by cubic

Adds the TaskRouter CRD and controller so one Slack front door picks which of several agents handles a request, and adds TaskSpawner.spec.triggerMode to opt a spawner out of acting on its own source. Today every TaskSpawner matching a Slack message creates its own Task; a router replaces that fan-out with a routing prompt and a named route menu.

How dispatch works

  • The Slack server creates a short-lived decision Task per matching router; the controller reads the route name it returns (an unlisted name counts as no route) and builds the Task the selected route's TaskSpawner would have built.
  • The decision runs as a Task, so it inherits credentials, budget accounting, logs, and cost metrics without adding a provider client to the control plane.
  • The dispatched Task copies the initiator's reporting annotations, so results return to the originating thread; under fallback Reply the decision posts its own explanation and stays silent under Route and Fail.
  • The API is scoped: spec.when is a dedicated RouterWhen carrying only the slack source, routes target TaskSpawner only, and decision.timeoutSeconds defaults to 300 in code because a schema default would not apply to pooled decision pods.
  • Only the Slack ingress is wired; webhook-sourced routers validate but never receive requests, and decision Tasks have no TTL.

triggerMode on TaskSpawner

  • OnDemand stops a spawner firing its own source, leaving it reachable only by a router or kelos run --from taskspawner/<name>; Source is the default, so existing manifests are unaffected.
  • An OnDemand spawner gets no Deployment or CronJob, and spec.when becomes optional; a non-default value round-trips through v1alpha1 via a preservation annotation.
  • An examples/19-taskrouter setup validates the new API end to end.

Part of #1760; TaskPipeline targets and the remaining sources are left for phase 2.