Add the TaskRouter CRD, controller, and TaskSpawner triggerMode
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.kindacceptsTaskSpawneronly.TaskPipelinetargets need
pipeline reporting that does not exist yet (internal/reportinghas no
TaskPipelineawareness, and stage Tasks do not inherit reporting
annotations).spec.whenreuses the TaskSpawner source vocabulary but is CEL-restricted to
exactly one of theslackandwebhooksources, so agithubIssuesrouter
cannot apply cleanly and then do nothing. Widening later is a relaxation.spec.decisionreusesWorkerSpec, so the decision pod gets
podOverrides.envand the rest of the Task execution environment free, with
the sameworkerPoolRefexclusivity Tasks have.spec.fallbackcoversRoute,ReplyandFail, with CEL tying
fallback.routeto 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:
spec.whenstays 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 omitswhenis
accepted only forOnDemand. The gap is that a Go client still serializes
whenas{}, 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.- The existing spec-level CEL rule that reaches into
self.whenis
re-guarded with!has(self.when) || .... Without that, an absentwhen
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
TaskRoutervalidates 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
TaskSpawnerwould 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
fallbackReplythe decision posts its own explanation and stays silent underRouteandFail. - The API is scoped:
spec.whenis a dedicatedRouterWhencarrying only the slack source, routes targetTaskSpawneronly, anddecision.timeoutSecondsdefaults 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
OnDemandstops a spawner firing its own source, leaving it reachable only by a router orkelos run --from taskspawner/<name>;Sourceis the default, so existing manifests are unaffected.- An
OnDemandspawner gets no Deployment or CronJob, andspec.whenbecomes optional; a non-default value round-trips through v1alpha1 via a preservation annotation. - An
examples/19-taskroutersetup validates the new API end to end.
Part of #1760; TaskPipeline targets and the remaining sources are left for phase 2.