Repository navigation
Conversation
Redis ran as a Deployment with no volume, so a pod restart dropped every Task, Workspace and Model. It now runs as a StatefulSet with a persistent volume, an append-only file synced every second, periodic RDB snapshots, and noeviction so a full instance rejects writes instead of dropping records. `make deploy-redis` deletes the old Deployment first, because its pods share the StatefulSet's labels. The e2e setup also deletes the Redis PVC so each run still starts empty. Task status also kept a copy of the actor's state (phase, actor, workerIP, Ready). It was written only when AX called Substrate, so it went stale as soon as Substrate changed the actor on its own, for example after a crash. The store now holds only what AX owns: spec, metadata, task ID, WorkspaceReady, and the Pending, Failed and Terminating phases. The rest is read from Substrate whenever a task is fetched, listed or watched, through the new TaskReconciler.Observe. That costs one GetActor call for a single task and one ListActors call per atespace for a list. A task whose actor is gone reports Failed with reason ActorNotFound. If Substrate can't be reached, the task reports Unknown. WatchTask now also polls Substrate every 2s, so changes made only on Substrate reach `ax watch`. CreateTask stores a task as Pending until its actor exists. Pending and Failed are now constants next to PhaseTerminating.
Replace the Redis store with PostgreSQL, and stop storing the parts of a task's status that belong to its actor on Agent Substrate. Storage: - internal/store/postgres holds resources as protojson JSONB documents. Open creates any missing tables. Locks are leases in a locks table, renewed while held. - Watches and lock waiters poll. There is no LISTEN/NOTIFY, so any connection pooler works. WatchTask is removed from store.Store. - The memory store is now test-only and no longer tracks save order. Newest-first ordering is covered by a Postgres-specific test. Task status: - The store keeps only what AX owns: spec, metadata, the Pending, Failed and Terminating phases, and WorkspaceReady. The actor, worker IP, live phase and Ready condition are read from Substrate on every get, list and watch. - Only a few helpers in server.go (getTask, listTasks, saveTask, updateTask, deleteTask) touch task storage, and a test enforces it. - Phases, condition types and condition reasons are now constants in pkg/apis/v1alpha1. Ready is derived from one rule in the server instead of being written in several places. - Ready reasons visible to clients change: TaskSuspended, ActorResuming, ActorSuspending, ActorCrashed, ActorDeleting and UnknownActorState become the phase name (Suspended, Resuming, Suspending, Failed, Terminating, Unknown). This work revealed major problems in the server, storage and controller implementations. Among them: - Task status was copied from Substrate into storage and could drift from the actor it described. - The controller wrote Ready conditions and phases that the server then discarded, or that disagreed with what clients saw. - Handlers pass the desired action to the reconciler by overwriting status.phase, mixing intent with observed state. - Phase and reason strings were spread across packages as literals. The server and CLI checked for a "Completed" phase that nothing produced. - The shared store tests encoded one backend's list ordering as a contract. This change fixes some of these, but the server, store and controller need further stabilization. @rakyll is taking ownership of that work.
rakyll
marked this pull request as ready for review
October 25, 2026 02:46
This was referenced Oct 25, 2026
rich7420
reviewed
Oct 25, 2026
Comment on lines
+565
to
+585
| // setReady sets the task's Ready condition. Its update time changes only when | ||
| // the condition does, so that repeated reads report a stable timestamp. | ||
| func setReady(task *v1alpha1.Task, condStatus, reason, message string) { | ||
| for _, c := range task.Status.Conditions { | ||
| if c.GetType() != v1alpha1.ConditionReady { | ||
| continue | ||
| } | ||
| if c.Status != condStatus || c.Reason != reason || c.Message != message { | ||
| c.Status, c.Reason, c.Message = condStatus, reason, message | ||
| c.UpdateTime = timestamppb.Now() | ||
| } | ||
| return | ||
| } | ||
| task.Status.Conditions = append(task.Status.Conditions, &v1alpha1.Condition{ | ||
| Type: v1alpha1.ConditionReady, | ||
| Status: condStatus, | ||
| Reason: reason, | ||
| Message: message, | ||
| UpdateTime: timestamppb.Now(), | ||
| }) | ||
| } |
There was a problem hiding this comment.
Fresh timestamps trigger MODIFIED on unchanged reads. Could we omit unknown Ready.updateTime from get/list/watch responses?
Suggested change
| // setReady sets the task's Ready condition. Its update time changes only when | |
| // the condition does, so that repeated reads report a stable timestamp. | |
| func setReady(task *v1alpha1.Task, condStatus, reason, message string) { | |
| for _, c := range task.Status.Conditions { | |
| if c.GetType() != v1alpha1.ConditionReady { | |
| continue | |
| } | |
| if c.Status != condStatus || c.Reason != reason || c.Message != message { | |
| c.Status, c.Reason, c.Message = condStatus, reason, message | |
| c.UpdateTime = timestamppb.Now() | |
| } | |
| return | |
| } | |
| task.Status.Conditions = append(task.Status.Conditions, &v1alpha1.Condition{ | |
| Type: v1alpha1.ConditionReady, | |
| Status: condStatus, | |
| Reason: reason, | |
| Message: message, | |
| UpdateTime: timestamppb.Now(), | |
| }) | |
| } | |
| // setReady sets the derived Ready condition. No condition update time is | |
| // available from its source, so UpdateTime is left unset. | |
| func setReady(task *v1alpha1.Task, condStatus, reason, message string) { | |
| for _, c := range task.Status.Conditions { | |
| if c.GetType() != v1alpha1.ConditionReady { | |
| continue | |
| } | |
| c.Status, c.Reason, c.Message = condStatus, reason, message | |
| c.UpdateTime = nil | |
| return | |
| } | |
| task.Status.Conditions = append(task.Status.Conditions, &v1alpha1.Condition{ | |
| Type: v1alpha1.ConditionReady, | |
| Status: condStatus, | |
| Reason: reason, | |
| Message: message, | |
| }) | |
| } |
Comment on lines
+101
to
+109
| t.Run("stable update time", func(t *testing.T) { | ||
| task := storedTask("suspended", "") | ||
| setActorStatus(task, testActor("suspended", ateapipb.ActorState_ACTOR_STATE_SUSPENDED, ""), nil) | ||
| first := readyCondition(task).GetUpdateTime() | ||
| setActorStatus(task, testActor("suspended", ateapipb.ActorState_ACTOR_STATE_SUSPENDED, ""), nil) | ||
| if got := readyCondition(task).GetUpdateTime(); got != first { | ||
| t.Errorf("Ready update time changed on an unchanged read") | ||
| } | ||
| }) |
There was a problem hiding this comment.
With the setReady change, could we assert that derived Ready.updateTime is unset?
Suggested change
| t.Run("stable update time", func(t *testing.T) { | |
| task := storedTask("suspended", "") | |
| setActorStatus(task, testActor("suspended", ateapipb.ActorState_ACTOR_STATE_SUSPENDED, ""), nil) | |
| first := readyCondition(task).GetUpdateTime() | |
| setActorStatus(task, testActor("suspended", ateapipb.ActorState_ACTOR_STATE_SUSPENDED, ""), nil) | |
| if got := readyCondition(task).GetUpdateTime(); got != first { | |
| t.Errorf("Ready update time changed on an unchanged read") | |
| } | |
| }) | |
| t.Run("derived Ready has no update time", func(t *testing.T) { | |
| task := storedTask("suspended", "") | |
| setActorStatus(task, testActor("suspended", ateapipb.ActorState_ACTOR_STATE_SUSPENDED, ""), nil) | |
| if got := readyCondition(task).GetUpdateTime(); got != nil { | |
| t.Errorf("derived Ready update time = %v, want unset", got) | |
| } | |
| }) |
Comment on lines
+144
to
+151
| // redactURL hides any password in a store URL so that it can be logged. | ||
| func redactURL(raw string) string { | ||
| u, err := url.Parse(raw) | ||
| if err != nil { | ||
| return "<unparseable>" | ||
| } | ||
| return u.Redacted() | ||
| } |
There was a problem hiding this comment.
Redacted() misses query and keyword-DSN passwords. Could we strip URL parameters and hide non-URL DSNs?
Suggested change
| // redactURL hides any password in a store URL so that it can be logged. | |
| func redactURL(raw string) string { | |
| u, err := url.Parse(raw) | |
| if err != nil { | |
| return "<unparseable>" | |
| } | |
| return u.Redacted() | |
| } | |
| // redactURL strips URL parameters and masks the userinfo password for logging. | |
| // Other connection string formats are hidden entirely. | |
| func redactURL(raw string) string { | |
| u, err := url.Parse(raw) | |
| if err != nil { | |
| return "<unparseable>" | |
| } | |
| if (u.Scheme != "postgres" && u.Scheme != "postgresql") || u.Host == "" { | |
| return "<redacted>" | |
| } | |
| u.RawQuery, u.Fragment, u.RawFragment = "", "", "" | |
| u.ForceQuery = false | |
| return u.Redacted() | |
| } |
Comment on lines
+405
to
+428
| // listTasks returns a page of tasks with their Substrate-owned status filled | ||
| // in, using one ListActors call per atespace. | ||
| func (s *Server) listTasks(ctx context.Context, atespace string, limit, offset int64) ([]*v1alpha1.Task, error) { | ||
| tasks, err := s.store.ListTasks(ctx, atespace, limit, offset) | ||
| if err != nil || s.substrate == nil { | ||
| return tasks, err | ||
| } | ||
| byAtespace := make(map[string][]*v1alpha1.Task) | ||
| for _, t := range tasks { | ||
| ns := t.GetMetadata().GetAtespace() | ||
| byAtespace[ns] = append(byAtespace[ns], t) | ||
| } | ||
| for ns, group := range byAtespace { | ||
| actors, err := s.substrate.ListActors(ctx, ns) | ||
| byName := make(map[string]*ateapipb.Actor, len(actors)) | ||
| for _, a := range actors { | ||
| byName[a.GetMetadata().GetName()] = a | ||
| } | ||
| for _, t := range group { | ||
| setActorStatus(t, byName[t.GetMetadata().GetName()], err) | ||
| } | ||
| } | ||
| return tasks, nil | ||
| } |
There was a problem hiding this comment.
Could we fetch only this page’s actors? This avoids scanning the whole atespace, at the cost of one sequential lookup per task.
Suggested change
| // listTasks returns a page of tasks with their Substrate-owned status filled | |
| // in, using one ListActors call per atespace. | |
| func (s *Server) listTasks(ctx context.Context, atespace string, limit, offset int64) ([]*v1alpha1.Task, error) { | |
| tasks, err := s.store.ListTasks(ctx, atespace, limit, offset) | |
| if err != nil || s.substrate == nil { | |
| return tasks, err | |
| } | |
| byAtespace := make(map[string][]*v1alpha1.Task) | |
| for _, t := range tasks { | |
| ns := t.GetMetadata().GetAtespace() | |
| byAtespace[ns] = append(byAtespace[ns], t) | |
| } | |
| for ns, group := range byAtespace { | |
| actors, err := s.substrate.ListActors(ctx, ns) | |
| byName := make(map[string]*ateapipb.Actor, len(actors)) | |
| for _, a := range actors { | |
| byName[a.GetMetadata().GetName()] = a | |
| } | |
| for _, t := range group { | |
| setActorStatus(t, byName[t.GetMetadata().GetName()], err) | |
| } | |
| } | |
| return tasks, nil | |
| } | |
| // listTasks returns a page of tasks with their Substrate-owned status filled in. | |
| func (s *Server) listTasks(ctx context.Context, atespace string, limit, offset int64) ([]*v1alpha1.Task, error) { | |
| tasks, err := s.store.ListTasks(ctx, atespace, limit, offset) | |
| if err != nil || s.substrate == nil { | |
| return tasks, err | |
| } | |
| for _, task := range tasks { | |
| s.applyActor(ctx, task) | |
| } | |
| return tasks, nil | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replace the Redis store with PostgreSQL, and stop storing the parts of a
task's status that belong to its actor on Agent Substrate.
Storage:
Open creates any missing tables. Locks are leases in a locks table,
renewed while held.
connection pooler works. WatchTask is removed from store.Store.
Newest-first ordering is covered by a Postgres-specific test.
Task status:
and Terminating phases, and WorkspaceReady. The actor, worker IP, live
phase and Ready condition are read from Substrate on every get, list
and watch.
updateTask, deleteTask) touch task storage, and a test enforces it.
pkg/apis/v1alpha1. Ready is derived from one rule in the server
instead of being written in several places.
ActorSuspending, ActorCrashed, ActorDeleting and UnknownActorState
become the phase name (Suspended, Resuming, Suspending, Failed,
Terminating, Unknown).
This work revealed major problems in the server, storage and controller
implementations. Among them:
from the actor it described.
discarded, or that disagreed with what clients saw.
status.phase, mixing intent with observed state.
server and CLI checked for a "Completed" phase that nothing produced.
contract.
This change fixes some of these, but the server, store and controller
need further stabilization. @rakyll is taking ownership of that work.