Skip to content

Emit StartedEvent per continuation cycle in the lockstep event stream#733

Open
PratikDhanave wants to merge 2 commits into
microsoft:mainfrom
PratikDhanaveFork:started-event-per-continuation-cycle
Open

Emit StartedEvent per continuation cycle in the lockstep event stream#733
PratikDhanave wants to merge 2 commits into
microsoft:mainfrom
PratikDhanaveFork:started-event-per-continuation-cycle

Conversation

@PratikDhanave

Copy link
Copy Markdown
Contributor

What

In lockstepRunEventStream.TakeEventStream, StartedEvent was enqueued exactly once, immediately before the run loop. But a single blocking WatchStream call (blockOnPendingRequest=true) drives more than one input -> processing -> halt cycle: when the workflow halts with RunStatusPendingRequests the resume branch waits for input and then continues the continuation cycle in place. That branch called startRunActivity() (which only adds a telemetry span event) and looped back to process the continuation without enqueuing a second StartedEvent.

As a result a lockstep consumer saw exactly one StartedEvent no matter how many external responses arrived, while the streaming (OffThread) run loop emits one per cycle.

This change enqueues workflow.StartedEvent{} after the resume branch, guarded by HasUnprocessedMessages() so no-work wakeups (e.g. spurious signals) stay event-free, matching the streaming loop's semantics. The event is drained and yielded before the continuation cycle's supersteps.

Why (parity)

workflow.StartedEvent is documented as firing "once per cycle in which there is actual work to process -- typically once at the start of a run and again whenever new messages or external responses arrive after a halt." The streaming run loop already honors this. This aligns the lockstep execution mode with that contract and with the .NET/Python WorkflowStartedEvent semantics, where the event marks the start of each processing cycle rather than the lifetime of a single stream subscription. Consumers that key off StartedEvent to delimit cycles (progress UIs, per-cycle bookkeeping) now behave identically across execution modes.

Testing

Added TestStartedEvent_EmittedPerContinuationCycle in workflow/inproc/events_test.go. It builds a workflow that posts an external request and halts with RunStatusPendingRequests, runs it under both inproc.Lockstep and inproc.OffThread via RunStreaming, iterates WatchStream, supplies an ExternalResponse after the first halt to trigger the continuation cycle, and asserts exactly two StartedEvents (one per cycle) plus the expected output. Before the fix the lockstep case yielded one; the OffThread case asserts cross-mode parity. Verified the test fails before the change and passes after.

  • go build ./...
  • go vet ./workflow/...
  • go test ./workflow/inproc/... ./workflow/internal/execution/... (StartedEvent tests also run with -race)

Copilot AI review requested due to automatic review settings July 24, 2026 03:44
@PratikDhanave
PratikDhanave requested a review from a team as a code owner July 24, 2026 03:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Aligns Lockstep streaming semantics with OffThread by emitting workflow.StartedEvent{} at the start of each continuation (input → processing → halt) cycle when there is actual work to process, so event consumers can consistently delimit cycles across execution modes.

Changes:

  • Enqueue a StartedEvent for Lockstep continuation cycles after resuming from RunStatusPendingRequests, guarded by HasUnprocessedMessages().
  • Add a new cross-mode test asserting StartedEvent is emitted once per continuation cycle when an external response resumes a halted workflow.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
workflow/internal/execution/eventstream.go Updates Lockstep event stream to emit StartedEvent for continuation cycles after pending-request resumes.
workflow/inproc/events_test.go Adds a test covering per-cycle StartedEvent emission across Lockstep and OffThread streaming runs.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 551 to +559
startRunActivity()
// Emit a StartedEvent for the continuation cycle, mirroring the
// streaming run loop which raises one per input → processing →
// halt cycle. Only when there is actual work to process, so
// no-work wakeups (e.g. spurious signals) stay event-free. The
// event is drained and yielded before the cycle's supersteps.
if l.stepRunner.HasUnprocessedMessages() {
l.eventQueue.Enqueue(workflow.StartedEvent{})
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in d1e7e21: I now call drainAndFilterEvents right after enqueuing the continuation StartedEvent, so it is yielded before the cycle's supersteps run instead of being held in the queue and drained alongside the first superstep's events. This matches the comment and the initial-run behavior.

Comment on lines +187 to +189
resp := msg.(*workflow.ExternalResponse)
data, _ := resp.Data.As(port.Response)
return nil, wctx.YieldOutput(data)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in d1e7e21. As returns (any, bool) rather than an error, so I now check the ok flag and return an explicit error when the response payload isn't the expected type, rather than silently yielding a nil output.

Comment thread workflow/inproc/events_test.go Outdated
t.Fatalf("Build: %v", err)
}

ctx := context.Background()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in d1e7e21: the test now uses a 30s context.WithTimeout (with defer cancel), so respondWhenPending self-terminates via ctx.Err() and can't leak into subsequent tests if the run never reaches PendingRequests.

@github-actions

This comment has been minimized.

The lockstep event stream enqueued StartedEvent once per TakeEventStream
call, but a single blocking WatchStream call drives multiple
input->processing->halt cycles: after halting on pending requests it
waits for input, then resumes the continuation cycle in place. The
resume branch called startRunActivity() (telemetry only) without raising
a StartedEvent, so a lockstep consumer saw exactly one StartedEvent
regardless of how many external responses arrived.

The streaming (OffThread) run loop already raises one StartedEvent per
cycle, and workflow.StartedEvent documents that it fires again whenever
new messages or external responses arrive after a halt. Emit the event
in the lockstep resume branch, guarded by HasUnprocessedMessages so
no-work wakeups stay event-free, restoring cross-mode parity.
@PratikDhanave
PratikDhanave force-pushed the started-event-per-continuation-cycle branch from d1e7e21 to 4a09917 Compare July 24, 2026 09:30
@github-actions

Copy link
Copy Markdown
Contributor

Cross-Repo Parity Review — PR #733

Scope: workflow/internal/execution/eventstream.go (internal) + workflow/inproc/events_test.go (test) — 2 files changed, no exported API surface added or removed.


Summary

This PR fixes a real behavioral inconsistency inside the Go codebase (lockstep vs streaming StartedEvent emission), and the PR description correctly identifies the fix as aligning the two Go execution modes. The change is internally consistent and well-tested.

However, after checking the upstream .NET and Python implementations, I found a cross-repo semantic divergence worth flagging:


Parity Finding: .NET LockstepRunEventStream emits WorkflowStartedEvent once per TakeEventStreamAsync call, not once per continuation cycle

Go after this PR:
Both streamingRunEventStream and lockstepRunEventStream emit workflow.StartedEvent{} once per input→processing→halt cycle, guarded by HasUnprocessedMessages(). A WatchStream(blockOnPendingRequest=true) call that drives multiple continuation cycles will emit multiple StartedEvents.

.NET StreamingRunEventStream (dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs, line ~108):

// Emit WorkflowStartedEvent only when there's actual work to process
await this._eventChannel.Writer.WriteAsync(new WorkflowStartedEvent(), ...);

This is emitted per cycle inside while (!linkedSource.Token.IsCancellationRequested), matching the Go streaming behavior.

.NET LockstepRunEventStream (dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs, line ~65):

this.RunStatus = RunStatus.Running;
runActivity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted));

// Emit WorkflowStartedEvent to the event stream for consumers
this._eventSink.Enqueue(new WorkflowStartedEvent());

This is emitted once at the top of TakeEventStreamAsync, outside the do-while loop. The do-while loop processes continuation cycles (blockOnPendingRequest=true) but does not re-enqueue WorkflowStartedEvent on resumption.

Python (python/packages/core/agent_framework/_workflows/_events.py + _runner.py): Python uses a single WorkflowEvent.started() factory and emits superstep_started per iteration; there is no direct equivalent of a "per-cycle started" event for the lockstep mode comparison.


Impact

Consumers of the lockstep event stream in Go will now observe a StartedEvent for each continuation cycle, whereas .NET lockstep consumers see exactly one WorkflowStartedEvent per TakeEventStreamAsync subscription. This is a behavioral difference that could affect:

  • Progress-tracking UIs that key off StartedEvent/WorkflowStartedEvent to delimit cycles
  • Per-cycle bookkeeping that assumes a 1:1 relationship between a stream subscription and a started event in lockstep mode

Suggested action: Confirm with the upstream .NET team whether LockstepRunEventStream should also be updated to emit WorkflowStartedEvent per-cycle (matching the Go fix and the streaming behavior), or whether the Go lockstep semantics should be documented as intentionally diverging from .NET lockstep for this event. If the .NET fix is also desired, open a companion issue in microsoft/agent-framework.


Label note

No exported Go APIs were added or removed; the change is to unexported lockstepRunEventStream and a test file. The public-api-change label is not warranted.

Warning

Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • awmgmcpg

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "awmgmcpg"

See Network Configuration for more information.

Generated by Go API Consistency Review Agent · 77.4 AIC · ⌖ 5.11 AIC · ⊞ 5.9K ·

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants