Skip to content

Commit 357bb11

Browse files
ravygyarolegovich
andauthored
feat: add WithAgentInactivityTimeout option for terminating stalled a… (#315)
Adds a new `RequestHandlerOption` that bounds how long the server will wait for the next event from an agent's producer before terminating its execution. The timer is reset whenever the agent writes an event to the event pipe, so steady producers are unaffected. Approach was discussed and confirmed on the issue: #78 (comment). ## Implementation - New `ErrAgentInactivityTimeout` sentinel in `internal/taskexec`, re-exported from `a2asrv` so callers can detect the condition with `errors.Is`. - New `activityTrackingWriter` wraps the producer's `eventpipe.Writer` and signals on every successful `Write`. Wrapping is a no-op when the option is not configured, so existing users pay no cost. - New watcher goroutine inside `runProducerConsumer`, started only when the inactivity timeout is positive. On firing, it returns `ErrAgentInactivityTimeout` from the errgroup which becomes the cancellation cause on the producer and consumer contexts via `context.Cause`, exactly like the existing `TestRunProducerConsumer_CausePropagation` pattern. - Plumbed through `LocalManagerConfig.AgentInactivityTimeout` and `DistributedManagerConfig.AgentInactivityTimeout` for cluster-mode parity. Heartbeats from `workqueue.Heartbeater` remain independent. ## Usage ```go handler := a2asrv.NewHandler( myExecutor, a2asrv.WithAgentInactivityTimeout(30 * time.Second), ) ``` When the agent stops emitting events for the configured duration, the executor's context is canceled and the task is finalized via the existing failure path. Callers can detect the cause: ```go if errors.Is(err, a2asrv.ErrAgentInactivityTimeout) { // handle inactivity termination } ``` ## Defaults and compatibility - Default `0` disables the watcher, preserving prior behavior. Existing users see no change. - Applies to both `localManager` and the cluster-mode `workQueueHandler` for parity. - `workqueue.Heartbeater` heartbeats are independent — they continue to fire on their own timer, unaffected by the inactivity watcher. ## Tests - **7 unit tests** in `execution_handler_test.go`: - Producer stall → `ErrAgentInactivityTimeout` returned from `runProducerConsumer` and surfaced as `context.Cause` on the producer ctx - Activity signals reset the timer (producer emits at half the timeout interval, watcher never fires) - Zero timeout does not start the watcher goroutine - `activityTrackingWriter`: nil tracker is a no-op, successful write signals, failed write does not signal, non-blocking when signal channel is full - **1 end-to-end test** in `manager_test.go` (`TestManager_AgentInactivityTimeout`) that constructs a `localManager` with the option configured, runs a stalled `AgentExecutor`, and asserts the executor sees `ErrAgentInactivityTimeout` via `context.Cause`. All existing tests in `internal/taskexec/...` and `a2asrv/...` continue to pass. `go vet ./...` clean. Full repo `go test ./...` green. Fixes #78 --------- Co-authored-by: Yaroslav Shevchuk <yarolegovich@gmail.com>
1 parent 02de8b1 commit 357bb11

8 files changed

Lines changed: 406 additions & 42 deletions

File tree

a2asrv/handler.go

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"fmt"
2020
"iter"
2121
"log/slog"
22+
"time"
2223

2324
"github.com/a2aproject/a2a-go/v2/a2a"
2425
"github.com/a2aproject/a2a-go/v2/a2asrv/eventqueue"
@@ -30,6 +31,12 @@ import (
3031
"github.com/a2aproject/a2a-go/v2/log"
3132
)
3233

34+
// ErrAgentInactivityTimeout is the cancellation cause set on an agent's
35+
// execution context when no events are written to the event pipe for the
36+
// duration configured via [WithAgentInactivityTimeout]. Callers can detect
37+
// this condition with errors.Is.
38+
var ErrAgentInactivityTimeout = taskexec.ErrAgentInactivityTimeout
39+
3340
// RequestHandler defines a transport-agnostic interface for handling incoming A2A requests.
3441
type RequestHandler interface {
3542
// GetTask handles the 'GetTask' protocol method.
@@ -81,6 +88,8 @@ type defaultRequestHandler struct {
8188
workQueue workqueue.Queue
8289
reqContextInterceptors []ExecutorContextInterceptor
8390

91+
agentInactivityTimeout time.Duration
92+
8493
authenticatedCardProducer ExtendedAgentCardProducer
8594
capabilities *a2a.AgentCapabilities
8695
}
@@ -122,6 +131,22 @@ func WithExecutionPanicHandler(handler func(r any) error) RequestHandlerOption {
122131
}
123132
}
124133

134+
// WithAgentInactivityTimeout configures the maximum time the server will wait
135+
// for the next event from an agent's producer before terminating its execution.
136+
// The timer is reset whenever the agent writes an event to the event pipe.
137+
// When the timeout fires the producer and consumer contexts are canceled with
138+
// [ErrAgentInactivityTimeout] as the cause, and the task is finalized via the
139+
// existing failure path.
140+
//
141+
// A value of 0 (default) or negative disables the inactivity watcher and
142+
// preserves prior behavior. The timeout applies in both single-process and
143+
// cluster modes.
144+
func WithAgentInactivityTimeout(d time.Duration) RequestHandlerOption {
145+
return func(ih *InterceptedHandler, h *defaultRequestHandler) {
146+
h.agentInactivityTimeout = d
147+
}
148+
}
149+
125150
// WithConcurrencyConfig allows to set limits on the number of concurrent executions.
126151
func WithConcurrencyConfig(config limiter.ConcurrencyConfig) RequestHandlerOption {
127152
return func(ih *InterceptedHandler, h *defaultRequestHandler) {
@@ -186,12 +211,13 @@ func NewHandler(executor AgentExecutor, options ...RequestHandlerOption) Request
186211
panic("TaskStore and QueueManager must be provided for cluster mode")
187212
}
188213
h.execManager = taskexec.NewDistributedManager(taskexec.DistributedManagerConfig{
189-
WorkQueue: h.workQueue,
190-
TaskStore: h.taskStore,
191-
QueueManager: h.queueManager,
192-
ConcurrencyConfig: h.concurrencyConfig,
193-
Factory: execFactory,
194-
PanicHandler: h.panicHandler,
214+
WorkQueue: h.workQueue,
215+
TaskStore: h.taskStore,
216+
QueueManager: h.queueManager,
217+
ConcurrencyConfig: h.concurrencyConfig,
218+
Factory: execFactory,
219+
PanicHandler: h.panicHandler,
220+
AgentInactivityTimeout: h.agentInactivityTimeout,
195221
})
196222
} else {
197223
if h.queueManager == nil {
@@ -204,11 +230,12 @@ func NewHandler(executor AgentExecutor, options ...RequestHandlerOption) Request
204230
execFactory.taskStore = h.taskStore
205231
}
206232
h.execManager = taskexec.NewLocalManager(taskexec.LocalManagerConfig{
207-
QueueManager: h.queueManager,
208-
ConcurrencyConfig: h.concurrencyConfig,
209-
Factory: execFactory,
210-
TaskStore: h.taskStore,
211-
PanicHandler: h.panicHandler,
233+
QueueManager: h.queueManager,
234+
ConcurrencyConfig: h.concurrencyConfig,
235+
Factory: execFactory,
236+
TaskStore: h.taskStore,
237+
PanicHandler: h.panicHandler,
238+
AgentInactivityTimeout: h.agentInactivityTimeout,
212239
})
213240
}
214241

internal/taskexec/api.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,20 @@ package taskexec
1616

1717
import (
1818
"context"
19+
"errors"
1920
"iter"
2021

2122
"github.com/a2aproject/a2a-go/v2/a2a"
2223
"github.com/a2aproject/a2a-go/v2/a2asrv/taskstore"
2324
"github.com/a2aproject/a2a-go/v2/internal/eventpipe"
2425
)
2526

27+
// ErrAgentInactivityTimeout is the cancellation cause set on the producer
28+
// context when an agent fails to write any events to the event pipe within
29+
// the configured inactivity timeout window. Callers can check for this
30+
// condition with errors.Is.
31+
var ErrAgentInactivityTimeout = errors.New("agent inactivity timeout")
32+
2633
// Manager provides an API for executing and canceling tasks.
2734
type Manager interface {
2835
// Resubscribe is used to resubscribe to events of an active execution.

internal/taskexec/distributed_manager.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"context"
1919
"fmt"
2020
"log/slog"
21+
"time"
2122

2223
"github.com/a2aproject/a2a-go/v2/a2a"
2324
"github.com/a2aproject/a2a-go/v2/a2asrv/eventqueue"
@@ -38,6 +39,11 @@ type DistributedManagerConfig struct {
3839
ConcurrencyConfig limiter.ConcurrencyConfig
3940
Logger *slog.Logger
4041
PanicHandler PanicHandlerFn
42+
// AgentInactivityTimeout, if positive, terminates an execution when the
43+
// agent's producer has not written any events to the pipe for the
44+
// configured duration. The terminating cause is [ErrAgentInactivityTimeout].
45+
// A value of 0 disables the watcher, preserving prior behavior.
46+
AgentInactivityTimeout time.Duration
4147
}
4248

4349
type distributedManager struct {

internal/taskexec/execution_handler.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,71 @@ func (h *executionHandler) processEvents(ctx context.Context) (a2a.SendMessageRe
8282
type eventProducerFn func(context.Context) error
8383
type eventConsumerFn func(context.Context) (a2a.SendMessageResult, error)
8484

85+
// inactivityTracker bundles the inactivity watcher's configuration with the
86+
// activity-recording channel. Callers obtain one via [newInactivityTracker]
87+
// (returns nil when disabled), wrap the producer's writer with
88+
// [newActivityTrackingWriter], and pass the tracker to [runProducerConsumer]
89+
// to start the watcher. A nil tracker disables tracking end-to-end: the writer
90+
// wrapper is a no-op and the watcher goroutine is not started.
91+
type inactivityTracker struct {
92+
config inactivityConfig
93+
writeRecorded chan struct{}
94+
}
95+
96+
// newInactivityTracker returns a tracker for the given timeout. A non-positive
97+
// timeout returns nil, disabling inactivity tracking.
98+
func newInactivityTracker(timeout time.Duration) *inactivityTracker {
99+
if timeout <= 0 {
100+
return nil
101+
}
102+
signal := make(chan struct{}, 1)
103+
return &inactivityTracker{
104+
config: inactivityConfig{timeout: timeout},
105+
writeRecorded: signal,
106+
}
107+
}
108+
109+
// record signals that a producer write just succeeded. Non-blocking: the
110+
// watcher only needs an "activity happened" hint, so a full channel is fine.
111+
func (t *inactivityTracker) record() {
112+
if t == nil {
113+
return
114+
}
115+
select {
116+
case t.writeRecorded <- struct{}{}:
117+
default:
118+
}
119+
}
120+
121+
// newActivityTrackingWriter wraps an [eventpipe.Writer] so each successful
122+
// write signals the provided tracker. A nil tracker returns the writer
123+
// unchanged so callers without an inactivity timeout configured pay no cost.
124+
func newActivityTrackingWriter(inner eventpipe.Writer, tracker *inactivityTracker) eventpipe.Writer {
125+
if tracker == nil {
126+
return inner
127+
}
128+
return &activityTrackingWriter{inner: inner, tracker: tracker}
129+
}
130+
131+
type activityTrackingWriter struct {
132+
inner eventpipe.Writer
133+
tracker *inactivityTracker
134+
}
135+
136+
func (w *activityTrackingWriter) Write(ctx context.Context, event a2a.Event) error {
137+
if err := w.inner.Write(ctx, event); err != nil {
138+
return err
139+
}
140+
w.tracker.record()
141+
return nil
142+
}
143+
144+
// inactivityConfig configures the inactivity watcher started by
145+
// [runProducerConsumer]. A zero or negative timeout disables the watcher.
146+
type inactivityConfig struct {
147+
timeout time.Duration
148+
}
149+
85150
// runProducerConsumer starts producer and consumer goroutines in an error group and waits
86151
// for both of them to finish or one of them to fail. If both complete successfuly and consumer produces a result,
87152
// the result is returned, otherwise an error is returned.
@@ -91,9 +156,28 @@ func runProducerConsumer(
91156
consumer eventConsumerFn,
92157
heartbeater workqueue.Heartbeater,
93158
panicHandler PanicHandlerFn,
159+
inactivity *inactivityTracker,
94160
) (a2a.SendMessageResult, error) {
95161
group, ctx := errgroup.WithContext(ctx)
96162

163+
if inactivity != nil && inactivity.config.timeout > 0 && inactivity.writeRecorded != nil {
164+
cfg := inactivity.config
165+
group.Go(func() error {
166+
timer := time.NewTimer(cfg.timeout)
167+
defer timer.Stop()
168+
for {
169+
select {
170+
case <-ctx.Done():
171+
return ctx.Err()
172+
case <-inactivity.writeRecorded:
173+
timer.Reset(cfg.timeout)
174+
case <-timer.C:
175+
return ErrAgentInactivityTimeout
176+
}
177+
}
178+
})
179+
}
180+
97181
if heartbeater != nil {
98182
group.Go(func() error {
99183
timer := time.NewTicker(heartbeater.HeartbeatInterval())

0 commit comments

Comments
 (0)