-
Notifications
You must be signed in to change notification settings - Fork 349
feat: switch runAgent from polling to SSE streaming #5280
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
66184ae
29d0774
2df3395
8bcbbea
4665f40
93469a8
7c47036
7091576
4dcf989
94e7ee4
0872c09
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| package runagent | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "net/http" | ||
| "time" | ||
|
|
@@ -15,17 +16,82 @@ func (a *RunAgent) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.Web | |
| } | ||
|
|
||
| func (a *RunAgent) Hooks() []core.Hook { | ||
| return []core.Hook{{ | ||
| Name: "poll", | ||
| Type: core.HookTypeInternal, | ||
| }} | ||
| return []core.Hook{ | ||
| {Name: "poll", Type: core.HookTypeInternal}, | ||
| {Name: "stream", Type: core.HookTypeInternal}, | ||
| } | ||
| } | ||
|
|
||
| func (a *RunAgent) HandleHook(ctx core.ActionHookContext) error { | ||
| if ctx.Name == "poll" { | ||
| switch ctx.Name { | ||
| case "stream": | ||
| return a.stream(ctx) | ||
| case "poll": | ||
| return a.poll(ctx) | ||
| default: | ||
| return fmt.Errorf("unknown hook: %s", ctx.Name) | ||
| } | ||
| } | ||
|
|
||
| func (a *RunAgent) stream(ctx core.ActionHookContext) error { | ||
| // Guard against duplicate/retried stream actions | ||
| if ctx.ExecutionState.IsFinished() { | ||
| return nil | ||
| } | ||
|
|
||
| metadata := ExecutionMetadata{} | ||
| if err := mapstructure.Decode(ctx.Metadata.Get(), &metadata); err != nil { | ||
| return fmt.Errorf("failed to decode metadata: %w", err) | ||
| } | ||
| if metadata.Session.ID == "" { | ||
| return fmt.Errorf("missing session id in metadata") | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| client, err := NewClient(ctx.HTTP, ctx.Integration) | ||
| if err != nil { | ||
| ctx.Logger.Warnf("Stream hook: failed to create client: %v. Falling back to poll.", err) | ||
| return ctx.Requests.ScheduleActionCall("poll", map[string]any{"attempt": 1, "errors": 0}, initialPoll) | ||
| } | ||
| return fmt.Errorf("unknown hook: %s", ctx.Name) | ||
|
|
||
| streamCtx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) | ||
| defer cancel() | ||
|
|
||
| status, lastMessage, messages, streamErr := client.StreamSessionUntilIdle(streamCtx, metadata.Session.ID) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Stream holds DB transactionHigh Severity The new Reviewed by Cursor Bugbot for commit 0872c09. Configure here. |
||
| if streamErr != nil { | ||
| ctx.Logger.Warnf("Stream failed for session %s: %v. Falling back to poll.", metadata.Session.ID, streamErr) | ||
| return ctx.Requests.ScheduleActionCall("poll", map[string]any{"attempt": 1, "errors": 0}, initialPoll) | ||
| } | ||
|
|
||
| // If the stream completed but didn't capture any agent messages, | ||
| // fall back to the events list API as a backfill. | ||
| if lastMessage == "" { | ||
| backfill, _, backfillErr := client.GetLastManagedSessionAgentMessageWithRetry(metadata.Session.ID, finalMessageReads, finalMessageDelay) | ||
| if backfillErr != nil { | ||
| ctx.Logger.Warnf("Backfill fetch failed for session %s: %v", metadata.Session.ID, backfillErr) | ||
| } | ||
| if backfill != "" { | ||
| lastMessage = backfill | ||
| messages = append(messages, backfill) | ||
| } | ||
| } | ||
|
|
||
| // Emit output BEFORE updating metadata or deleting session. | ||
| // If emit fails, the poll fallback can still access the session. | ||
| out := buildOutput(status, metadata.Session.ID, lastMessage, messages) | ||
| if err := ctx.ExecutionState.Emit(defaultChannel, payloadType, []any{out}); err != nil { | ||
| ctx.Logger.Warnf("Emit failed for session %s: %v. Falling back to poll.", metadata.Session.ID, err) | ||
| return ctx.Requests.ScheduleActionCall("poll", map[string]any{"attempt": 1, "errors": 0}, initialPoll) | ||
| } | ||
|
|
||
| metadata.Session.Status = status | ||
| _ = ctx.Metadata.Set(metadata) | ||
|
|
||
| // Clean up session after successful emit | ||
| if err := client.DeleteManagedSession(metadata.Session.ID); err != nil { | ||
| ctx.Logger.Warnf("Failed to delete managed session %s: %v", metadata.Session.ID, err) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| func (a *RunAgent) poll(ctx core.ActionHookContext) error { | ||
|
|
@@ -56,7 +122,7 @@ func (a *RunAgent) poll(ctx core.ActionHookContext) error { | |
|
|
||
| if attempt > maxPollAttempts { | ||
| ctx.Logger.Errorf("Managed session %s exceeded max poll attempts", metadata.Session.ID) | ||
| out := buildOutput("timeout", metadata.Session.ID) | ||
| out := buildOutput("timeout", metadata.Session.ID, "", nil) | ||
| return ctx.ExecutionState.Emit(defaultChannel, payloadType, []any{out}) | ||
| } | ||
|
|
||
|
|
@@ -70,7 +136,7 @@ func (a *RunAgent) poll(ctx core.ActionHookContext) error { | |
| errs++ | ||
| if errs >= maxPollErrors { | ||
| ctx.Logger.Errorf("Managed session %s: polling failed repeatedly: %v", metadata.Session.ID, err) | ||
| out := buildOutput("error", metadata.Session.ID) | ||
| out := buildOutput("error", metadata.Session.ID, "", nil) | ||
| return ctx.ExecutionState.Emit(defaultChannel, payloadType, []any{out}) | ||
| } | ||
| return a.scheduleNextPoll(ctx, attempt+1, errs) | ||
|
|
@@ -90,7 +156,11 @@ func (a *RunAgent) poll(ctx core.ActionHookContext) error { | |
| if err == nil && lastMessage == "" { | ||
| ctx.Logger.Warnf("No final agent message found for managed session %s. Event types: %s", metadata.Session.ID, managedSessionEventTypes(events)) | ||
| } | ||
| out := buildOutput(sess.Status, metadata.Session.ID, lastMessage) | ||
| var msgs []string | ||
| if lastMessage != "" { | ||
| msgs = []string{lastMessage} | ||
| } | ||
| out := buildOutput(sess.Status, metadata.Session.ID, lastMessage, msgs) | ||
| return ctx.ExecutionState.Emit(defaultChannel, payloadType, []any{out}) | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -153,28 +153,10 @@ func (a *RunAgent) Execute(ctx core.ExecutionContext) error { | |
| return fmt.Errorf("failed to send user message: %w", err) | ||
| } | ||
|
|
||
| // Refresh status after work may have already progressed. | ||
| refreshed, err := client.GetManagedSession(session.ID) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to get session: %w", err) | ||
| } | ||
| mergeSessionIntoMetadata(&metadata, refreshed) | ||
| _ = ctx.Metadata.Set(metadata) | ||
|
|
||
| if refreshed != nil && isSessionTerminal(refreshed.Status) { | ||
| lastMessage, events, err := client.GetLastManagedSessionAgentMessageWithRetry(session.ID, finalMessageReads, finalMessageDelay) | ||
| if err != nil { | ||
| ctx.Logger.Warnf("Failed to fetch final message for managed session %s: %v", session.ID, err) | ||
| } | ||
| if err == nil && lastMessage == "" { | ||
| ctx.Logger.Warnf("No final agent message found for managed session %s. Event types: %s", session.ID, managedSessionEventTypes(events)) | ||
| } | ||
| out := buildOutput(refreshed.Status, session.ID, lastMessage) | ||
| return ctx.ExecutionState.Emit(defaultChannel, payloadType, []any{out}) | ||
| } | ||
|
|
||
| ctx.Logger.Infof("Started Managed Agent session %s. Waiting for completion (polling)...", session.ID) | ||
| return ctx.Requests.ScheduleActionCall("poll", map[string]any{"attempt": 1, "errors": 0}, initialPoll) | ||
| // Don't block Execute() — it runs inside a DB transaction. | ||
| // Schedule streaming as an action call that runs outside the transaction. | ||
| ctx.Logger.Infof("Started Managed Agent session %s. Scheduling stream...", session.ID) | ||
| return ctx.Requests.ScheduleActionCall("stream", map[string]any{"attempt": 1, "errors": 0}, 0) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Zero stream schedule intervalHigh Severity
Reviewed by Cursor Bugbot for commit 0872c09. Configure here. |
||
| } | ||
|
|
||
| func (a *RunAgent) Cleanup(ctx core.SetupContext) error { return nil } | ||
|
|
||


Uh oh!
There was an error while loading. Please reload this page.