When the controller cannot reach a worker directly, it polls readiness through atenet-router. It sends ate-target-actor, but leaves the router Service as the HTTP Host. The router rejects that request, so WorkspaceReady remains false.
The patch sets the actor DNS Host on the fallback, keeps the target header, and updates the networking examples. The regression test fails without the Host change and passes with it; make test passes.
diff --git a/docs/networking.md b/docs/networking.md
index 2e57e6e..368c55c 100644
--- a/docs/networking.md
+++ b/docs/networking.md
@@ -1,15 +1,16 @@
# Networking
-Tasks do not get a Kubernetes Service or Ingress of their own. Every request to a task goes through Agent Substrate's **atenet router**, the `atenet-router` Service in the `ate-system` namespace. The router reads a single header, `ate-target-actor`, resolves the actor to the worker it is running on, resumes it first if it was suspended, and proxies the request there. `Host` and `:authority` are left alone for your application; the header alone selects the target.
+Tasks do not get a Kubernetes Service or Ingress of their own. Every request to a task goes through Agent Substrate's **atenet router**, the `atenet-router` Service in the `ate-system` namespace. The router requires the actor's stable DNS authority and accepts `ate-target-actor` as the explicit target identifier; it resumes the actor first if it was suspended, then proxies the request there.
-The header value is `<atespace>/<task>`. The controller always names a task's actor after the task, so `default/task123` reaches the task `task123` in the `default` atespace.
+The actor authority is `<task>.<atespace>.actors.resources.substrate.ate.dev`, and the target header is `<atespace>/<task>`. The controller always names a task's actor after the task, so both values below identify the task `task123` in the `default` atespace.
## From inside the cluster
-Use the Service DNS name and add the header. This is exactly how the controller polls a task's readiness.
+Use the Service DNS name and set the actor authority. This is exactly how the controller polls a task's readiness.
```bash
-curl -H "ate-target-actor: default/task123" \
+curl -H "Host: task123.default.actors.resources.substrate.ate.dev" \
+ -H "ate-target-actor: default/task123" \
http://atenet-router.ate-system.svc.cluster.local/metadata/v1alpha1/ax/task
```
@@ -19,7 +20,8 @@ Port-forward the router, then talk to it the same way.
```bash
kubectl -n ate-system port-forward svc/atenet-router 8001:80
-curl -H "ate-target-actor: default/task123" http://localhost:8001/readyz
+curl -H "Host: task123.default.actors.resources.substrate.ate.dev" \
+ -H "ate-target-actor: default/task123" http://localhost:8001/readyz
```
## gRPC request routing
diff --git a/internal/controller/reconciler.go b/internal/controller/reconciler.go
index 31c399b..1b16769 100644
--- a/internal/controller/reconciler.go
+++ b/internal/controller/reconciler.go
@@ -265,6 +265,7 @@ func (r *TaskReconciler) Reconcile(ctx context.Context, task *v1alpha1.Task, gat
rReq, _ := http.NewRequestWithContext(pollCtx, http.MethodGet, fmt.Sprintf("http://%s/readyz?check=workspace", routerAddr), nil)
if rReq != nil {
rReq.Header.Set("ate-target-actor", fmt.Sprintf("%s/%s", atespace, actorName))
+ rReq.Host = fmt.Sprintf("%s.%s.actors.resources.substrate.ate.dev", actorName, atespace)
if resp, err := r.httpClient.Do(rReq); err == nil {
_ = resp.Body.Close()
if resp.StatusCode == http.StatusOK {
diff --git a/internal/controller/reconciler_test.go b/internal/controller/reconciler_test.go
index e699ee0..5542d9d 100644
--- a/internal/controller/reconciler_test.go
+++ b/internal/controller/reconciler_test.go
@@ -18,6 +18,8 @@ import (
"context"
"net"
"net/http"
+ "net/http/httptest"
+ "strings"
"testing"
"time"
@@ -348,7 +350,39 @@ func TestTaskReconciler_WorkspaceReady(t *testing.T) {
assertCondition(t, reconciled, "WorkspaceReady", "False", "Initializing")
assertCondition(t, reconciled, "Ready", "False", "WorkspaceInitializing")
- // Case 2: Worker readyz endpoint succeeds -> WorkspaceReady=True and Ready=True.
+ // Case 2: A worker address is not directly reachable, so readiness goes through
+ // atenet-router with the actor DNS authority and explicit target header.
+ router := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if got, want := r.Host, "ready-task.default.actors.resources.substrate.ate.dev"; got != want {
+ http.Error(w, "unexpected host "+got, http.StatusNotFound)
+ return
+ }
+ if got, want := r.Header.Get("ate-target-actor"), "default/ready-task"; got != want {
+ http.Error(w, "unexpected target "+got, http.StatusNotFound)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer router.Close()
+ t.Setenv("ATENET_ROUTER_ADDR", strings.TrimPrefix(router.URL, "http://"))
+ mockSrv.workerIP = "127.0.0.1:1"
+ reconciledViaRouter, err := reconciler.Reconcile(ctx, task, nil)
+ if err != nil {
+ t.Fatalf("Reconcile through atenet-router failed: %v", err)
+ }
+ assertCondition(t, reconciledViaRouter, "WorkspaceReady", "True", "SetupComplete")
+ assertCondition(t, reconciledViaRouter, "Ready", "True", "TaskRunning")
+
+ // Case 3: Worker readyz endpoint succeeds directly -> WorkspaceReady=True and Ready=True.
+ task = &v1alpha1.Task{
+ ApiVersion: v1alpha1.APIVersion,
+ Kind: v1alpha1.KindTask,
+ Metadata: &v1alpha1.ObjectMeta{
+ Name: "ready-task",
+ Atespace: "default",
+ },
+ Spec: &v1alpha1.TaskSpec{},
+ }
mockSrv.workerIP = httpLis.Addr().String()
reconciledReady, err := reconciler.Reconcile(ctx, task, nil)
if err != nil {
@@ -357,7 +391,7 @@ func TestTaskReconciler_WorkspaceReady(t *testing.T) {
assertCondition(t, reconciledReady, "WorkspaceReady", "True", "SetupComplete")
assertCondition(t, reconciledReady, "Ready", "True", "TaskRunning")
- // Case 3: Suspending the task -> Ready=False (TaskSuspended), but the workspace was
+ // Case 4: Suspending the task -> Ready=False (TaskSuspended), but the workspace was
// already initialized so WorkspaceReady stays True.
task = reconciledReady
task.Spec.Suspend = true
@@ -371,7 +405,7 @@ func TestTaskReconciler_WorkspaceReady(t *testing.T) {
assertCondition(t, reconciledSuspended, "Ready", "False", "TaskSuspended")
assertCondition(t, reconciledSuspended, "WorkspaceReady", "True", "SetupComplete")
- // Case 4: Resuming with the worker unreachable -> the reconciler trusts the recorded
+ // Case 5: Resuming with the worker unreachable -> the reconciler trusts the recorded
// WorkspaceReady instead of re-polling, so the task is Ready again immediately.
mockSrv.workerIP = "127.0.0.1:1"
task = reconciledSuspended
When the controller cannot reach a worker directly, it polls readiness through atenet-router. It sends
ate-target-actor, but leaves the router Service as the HTTP Host. The router rejects that request, soWorkspaceReadyremains false.With
default/task123running, compare the responses:The patch sets the actor DNS Host on the fallback, keeps the target header, and updates the networking examples. The regression test fails without the Host change and passes with it;
make testpasses.Branch: https://github.com/gambtho/ax/tree/fix/readiness-actor-authority