Repository navigation
fix(adk): retry TaskStore calls through a controller restart - #3391
Conversation
EItanya
left a comment
There was a problem hiding this comment.
🤖 Includes AI-generated review comments.
| executor := adka2a.NewExecutor(execConfig) | ||
|
|
||
| kagentApp, err := app.New(app.AppConfig{ControlPlaneURL: runtime.ControlPlaneUrl, MaxContentLength: runtime.MaxContentLength, | ||
| TaskStoreRetry: taskstore.RetryPolicyFromConfig(runtime.GetTaskStore()), |
There was a problem hiding this comment.
Should we passthrough the TaskStore as the Dependency here rather than just the retry logic?
There was a problem hiding this comment.
This change is gone. taskstore.New reads KAGENT_TASKSTORE_OPERATION_BUDGET and KAGENT_TASKSTORE_ATTEMPT_TIMEOUT itself, so main.go matches main and a Harness sets the limits in spec.env.
| // Tests shorten the waits. Production leaves both zero. | ||
| firstWait, maxWait time.Duration |
There was a problem hiding this comment.
private test seam fields seem weird to me
There was a problem hiding this comment.
Removed. The tests set the limits through the same environment variables an operator uses.
There was a problem hiding this comment.
What's the fundamental difference between taskstore/retry.go and a2a/taskstore_retry.go. Feels like a bunch of potential duplication?
There was a problem hiding this comment.
api/a2a/taskstore_retry.go went away with the controller-side configuration. taskstore/retry.go is the only copy.
There was a problem hiding this comment.
This feels like the type of test i'd rather run as some sort of fancy integration where we run the server and client rather than FULL e2e, it's bound to take a long time and add unecessary complexity
There was a problem hiding this comment.
Replaced with TestRuntimeTaskStoreSaveOutlastsAControllerRestart in core/internal/grpcserver. It runs the real controller gRPC server on Postgres and stops it while a save is pending. It restarts the server 2 s later and requires the save to land once, with the committed version. It takes about 25 s and needs only Docker.
| // settlement starts, so it can outlast a controller restart. A shared | ||
| // cleanup deadline would end it early, and without the exhaustion log. The | ||
| // telemetry flushes around it keep their own caps. | ||
| finish := context.WithoutCancel(ctx) |
There was a problem hiding this comment.
Can we give each flush its own timeout here? AppConfig.Flush accepts arbitrary callbacks, and context.WithoutCancel(ctx) removes the deadline the callback previously received. A custom context-aware flush that relies on that deadline can now block before SettleTask indefinitely, leaving the task unpublished. The built-in telemetry provider supplies its own timeout, so that path is covered. Separate flush deadlines would keep this bounded without consuming settlement's retry budget.
There was a problem hiding this comment.
Done. Each flush gets its own telemetry.FlushTimeout deadline, separate from settlement's budget. In a new test, a flush that blocks on its context ends at that deadline, and settlement still retries for its whole budget.
| deadline, _ := operationCtx.Deadline() | ||
| if time.Until(deadline) <= wait { | ||
| if callerDeadline, ok := ctx.Deadline(); ok && !callerDeadline.After(deadline) { | ||
| return context.DeadlineExceeded |
There was a problem hiding this comment.
This can return context.DeadlineExceeded while the caller context is still live. For example, with an 80 ms caller deadline, an immediate Unavailable response returns a timeout after only a few milliseconds because the next backoff won't fit. That reports controller unavailability as a caller timeout. Can we wait for the caller deadline to actually expire, or preserve the last RPC error if we choose to stop early?
There was a problem hiding this comment.
Done. The loop keeps waiting when the deadline falls inside a backoff, so a caller gets DeadlineExceeded only after its deadline has passed. Tests cover 80 ms and 200 ms deadlines against an immediate Unavailable. When the budget runs out first, the caller still gets the last RPC error.
|
|
||
| // WithRetryPolicy applies policy to every later operation on s, including the | ||
| // settlement its executor sends after native cleanup. | ||
| func (s *Store) WithRetryPolicy(policy RetryPolicy) *Store { |
There was a problem hiding this comment.
Optional: can we pass the retry policy when constructing the store instead of adding a setter? Every current caller sets it immediately after New, so mutable configuration adds an extra initialization step without a current need.
There was a problem hiding this comment.
Done. There is no setter. New reads the limits when it builds the store.
01a06e8 to
dcf068b
Compare
764eb79 to
f2b3421
Compare
Before and after: a turn's final save through a 30 s controller outageI replayed the controller-restart scenario on a local kind cluster with gVisor workers. Both runs use one controller and UI, built from this PR's head ( A scheduled run sends "What is 2+2?", and a mock model holds the turn. The controller scales to zero. Once its Pod is gone, the model answers, so the turn's final save meets no controller. After 30 s the controller scales back to one replica. The driver then watches for 3 minutes and sends a follow-up on the same session.
The runtime's own log for each turn: The restarted controller agrees. Before, it served no save for the stuck turn; that session's only call was the refused follow-up. After, it served the retried Screenshots, JSON reports, full logs and the driver are on |
| defer cancel() | ||
| for attempt := 1; ; attempt++ { | ||
| attemptCtx, cancelAttempt := context.WithTimeout(operationCtx, s.attemptTimeout) | ||
| rpcCtx, cancelRPC := s.callContext(attemptCtx) |
f2b3421 to
86718c9
Compare
EItanya
left a comment
There was a problem hiding this comment.
Why are these added as env vars and not config vars?
Fixed! Good callout. Thank you. |
86718c9 to
7a2638f
Compare
|
Moved to config in 7a2638f, following #3434; the env vars are gone.
Claude and Codex keep the defaults for now. |
|
Could gRPC's I tried a smaller change on I ran this PR's
4 attempts × 30 s covers about 2 minutes, more than the 90 s default here. I scoped If this direction works, would it make sense to keep only these parts of this PR?
Then Things I haven't covered:
Is there a case |
Queue TaskStore RPCs through channel outages with WaitForReady while retaining the existing four attempts and per-call deadlines. Give telemetry flushes independent deadlines so an expired flush cannot consume settlement time. Add readiness coverage and preserve the controller/Postgres restart regression, allowing recovery within one RPC. Validated taskstore and controllerclient with focused race tests. Signed-off-by: Ada Lundhe <adalundhe@lundhe.audio>
7a2638f to
7f2d5f8
Compare
|
Switched this PR to the scoped All five TaskStore RPCs now use The evidence bundle contains raw logs, exported spans, hashes, and a rerunnable driver pinned to this commit. These fresh adversarial checks use controlled gRPC services and
Two replay runs failed timing assertions in the evidence driver: exhaustion returned after 124.071344s, and cancellation/deadline results arrived after 851ms. Attempt counts and error codes remained correct. Both failures remain in the bundle. The revised driver checks configured deadlines, preservation of earlier caller deadlines, cancellation and attempt counts. The final full replay matched all five expected outcomes, including the baseline failure; exhaustion returned after 120.702011s. The READY-server case confirms a boundary: Current-head CI passed Go race tests, Go lint, and both gVisor and microVM E2E. CI tested merge The bundle retains six fresh OTel client spans and an allowlisted extract of current CI traces and actor logs. The original CI artifact includes controller, worker, Substrate and test logs; it expires November 9. Span IDs, timestamps and RPC statuses are retained. The selected CI trace's association with the scheduled-restart test is a timing inference, stated in the report. Fresh deployed final-save recovery during an outage remains unverified. Docker Desktop's required sign-in blocked local PostgreSQL and Kind runs before container creation. The prepared 2/10/45/100-second controller matrix did not run. CI's scheduled-restart test releases the model after controller readiness, so it does not force the final save into the outage. The earlier before/after deployment evidence covers |
|
🤖 AI-generated review. The READY-server case in the latest evidence is the remaining concern. When the runtime's connection to AgentGateway stays healthy but the controller upstream is down, the proxy can return Can we keep func (s *Store) retry(ctx context.Context, call func(context.Context) error) error {
- for attempt := range 4 {
+ // Bound the whole operation, including immediate failures returned by a
+ // healthy proxy while its upstream is down. Keep the former four 30-second
+ // attempts' overall allowance without limiting the number of fast failures.
+ ctx, cancelOperation := context.WithTimeout(ctx, 2*time.Minute)
+ defer cancelOperation()
+ delay := 100 * time.Millisecond
+ for attempt := 1; ; attempt++ {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
rpcCtx, cancel := s.callContext(ctx)
err := call(rpcCtx)
cancel()
if status.Code(err) != codes.Unavailable && status.Code(err) != codes.DeadlineExceeded {
return err
}
- if attempt == 3 {
- logging.FromContext(ctx).ErrorContext(ctx, "taskstore retries exhausted", "attempts", attempt+1, "error", err)
- return err
- }
- timer := time.NewTimer(time.Duration(1<<attempt) * 100 * time.Millisecond)
+ timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
+ logging.FromContext(ctx).ErrorContext(ctx, "taskstore retries exhausted", "attempts", attempt, "error", err)
return ctx.Err()
case <-timer.C:
}
+ delay = min(2*delay, time.Second)
}
- panic("unreachable")
}With a two-second simulated upstream outage over a healthy gRPC connection, the current head failed 3/3 runs after about 704 ms. With this patch, all three runs recovered after about 2.5 seconds and six attempts, and subsequent saves in the same execution succeeded. Caller deadline/cancellation and terminal-error checks, the TaskStore/controller-client tests, and the PostgreSQL controller-restart test also passed under This was a local test using real gRPC with simulated proxy responses; it wasn't a live AgentGateway/Kind run. The regression should also cover a reachable proxy returning |
A controller restart reaches a deployed runtime through the egress proxy. The proxy stays reachable and answers each TaskStore call at once with Unavailable, so the runtime's connection stays READY and WaitForReady never waits. Four attempts ran out in about 0.7s. On a kind cluster the previous head left the task WORKING after a 30s outage, exactly as main does. Each TaskStore operation now retries Unavailable and DeadlineExceeded within a two-minute budget, as suggested in review. Each attempt keeps the client's 30s call timeout, within what remains of the budget. Waits start at 100ms, double up to 1s, and are jittered between half and all of the delay, so runtimes that lost the controller together do not retry in step. WaitForReady stays for a broken connection. When the budget runs out, the call returns the last RPC error, which names the cause, and logs the exhaustion once. An attempt that the budget cuts short does not replace that error, and no attempt starts after the budget has passed. A caller that cancels or runs out of time gets its own error back, and nothing is logged. Tests reject more calls than the former limit through a reachable fake proxy, for a save and for settlement. They also cover the budget's error and log, an attempt the budget cuts short, the caller's own end, and the jitter bounds. The controller/PostgreSQL restart test gains a variant through a forwarding proxy that answers Unavailable while the controller is down. Verified: go test -race ./adk/pkg/taskstore ./adk/pkg/controllerclient passes three runs, and the retry tests pass under CPU saturation. The restart test passes both variants on Linux, and its proxy variant fails against the previous store.go. make lint reports 0 issues. A kind run with Substrate v1.0.0-rc1 completed the turn when the controller returned from a 34s outage, and the Session answered the next message. Signed-off-by: Ada Lundhe <adalundhe@lundhe.audio> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… at 8f89362 Live kind runs with Substrate v1.0.0-rc1 and an OpenTelemetry collector: logs, traces and screenshots for 7f2d5f8 and 8f89362. Adds the adversarial probe results, the review finding before and after its fix, and the controller/PostgreSQL results. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Ada Lundhe <adalundhe@lundhe.audio>
|
A deployed runtime reaches the controller through the egress proxy, AgentGateway. During a restart the proxy answers each TaskStore call at once with
The evidence bundle has the logs, traces, screenshots, drivers and checksums. Live kind runsA local kind cluster with gVisor workers, Substrate v1.0.0-rc1, AgentGateway v1.7.0 and an OpenTelemetry collector ran both arms. The arms shared the cluster and controller; only the runtime image differed. The driver released a turn's final save into a 30 s controller outage.
All failed attempts in each arm went over one runtime connection to the proxy. Screenshots: chat after each run
Traces: the runtime's TaskStore client spans
Adversarial passProbes at
An independent review of the change found one bug, which The PR's unit tests cover the proxy outage, settlement, exhaustion, the caller's own end, a cut-short attempt and the jitter bounds. They pass 20 runs, and 10 more with every CPU core saturated. Controller and PostgreSQL
Where the evidence stops
|








Summary
A single-replica controller restart leaves the controller unreachable for 30–45 s. The Go runtime's TaskStore client gave each call four attempts within about 0.7 s, so the turn's final save failed. The task then stayed
WORKING, and the Session refused new input until someone cancelled it.A deployed runtime reaches the controller through Substrate's egress proxy, AgentGateway. During a restart the proxy stays reachable and answers each call at once with
Unavailable(upstream call failed: Connect: Connection refused). The runtime's connection stays READY, so the outage never appears as a broken connection.The Go runtime's TaskStore client now:
UnavailableandDeadlineExceededwithin a two-minute budget per operation, for saves, reads and settlement;grpc.WaitForReady(true)on the five TaskStore calls, so a broken connection waits for reconnection instead of failing fast;taskstore retries exhaustedonce when the budget runs out. An attempt that the budget cuts short does not replace that error. A caller that cancels or runs out of time gets its own error, without that log.Settlement no longer shares a 30 s cleanup deadline. Each telemetry flush around it keeps its own
telemetry.FlushTimeout. The change adds no configuration, and Memory calls keep failing fast.How the design changed
The first revision added a configurable budget. Review asked whether
WaitForReadyalone could cover a restart, and an earlier head of this PR switched to it. A kind run showed that it cannot, because the proxy keeps the connection READY. This head follows the review suggestion to keepWaitForReadyand bound the existing loop by elapsed time, without new settings.Where the guarantee stops
WORKINGuntil someone cancels it.Validation
go test -race ./adk/pkg/taskstore ./adk/pkg/controllerclientpasses three runs. The new tests:TestRuntimeTaskStoreSaveOutlastsAControllerRestartruns the controller's gRPC server on PostgreSQL in two variants: direct, and through a forwarding proxy that answersUnavailablewhile the controller is down.store.go, the proxy variant fails within 0.75 s.make lintreports 0 issues.mainWORKINGFailedPrecondition7f2d5f83,WaitForReadyonlyWORKINGFailedPreconditionCOMPLETED🤖 Generated with Claude Code