Skip to content

feat(tasks): add ctx.subscribe for race-safe event waits - #74

Merged
psteinroe merged 1 commit into
mainfrom
feat/subscribe-wait
Oct 2, 2026
Merged

psteinroe merged 1 commit into
mainfrom
feat/subscribe-wait

Conversation

@psteinroe

Copy link
Copy Markdown
Owner

Add ctx.subscribe() so a task can register an event subscription, run the side effect that causes the event, and then wait for it. Today ctx.waitForEvent() registers and suspends in one call, so a reply to something the task did first (post an approval card, then wait for the click) can be emitted before the wait exists and be lost. waitForEvent keeps its API.

const subscription = await ctx.subscribe("approve", { event: approvalDecided, filter: { approvalId: [id] } });
await ctx.step("post-card", () => postCard(id));
const decision = await subscription.wait({ timeout: "24h" }); // throws WaitForEventTimeoutError on timeout

Semantics

  • The subscription is durable once subscribe() resolves, and the execution keeps its claim. It is memoized by the step key, so retries and resumes reuse it.
  • An event that matches while the execution is running or waiting to retry is stored as the step result (the same shape waitForEvent uses). The execution is not woken or unlocked.
  • wait() returns the stored event at once. Otherwise it suspends exactly like waitForEvent with the same key. The timeout starts at the first wait(), not at subscribe().
  • Events emitted before subscribe() never match. Each subscription has exactly one outcome: whichever of event or timeout locks the execution first is recorded under the step key and is final.

Implementation

  • _private_register_event_wait gains p_suspend and returns the settled step result. It locks the execution, returns an existing result, creates or arms the subscription, and releases the execution only when suspending. It also sets waiting_step_key as a "suspended at this wait" marker.
  • Dispatch no longer skips claimed waiters. It stores the result for every matched wait and wakes only executions suspended at that step. That flag is read from the locked row, not the statement snapshot, so a registration that races dispatch cannot leave a stored result unwoken.
  • Claiming an execution clears an event-wait waiting_step_key (child-invocation keys are kept). This stops a delayed event from waking a retry that is still in backoff.
  • Side fix for waitForEvent: an event dispatched while a timed-out waiter was already claimed used to be dropped, and the timeout won. Registration now returns that stored event instead.

Verification

New integration tests in wait-for-event.test.ts cover: an event emitted inside the step between subscribe and wait (buffered while claimed), an event emitted before subscribe, an event after the wait suspended, a timeout measured from wait(), 10 executions racing an event against the timeout (one outcome each, consistent with the stored step), retries after subscribe that keep one subscription and buffer an event without cutting the backoff short, an event dispatched to a claimed timed-out waiter, and a delayed event after a failed retry. Type tests cover ctx.subscribe/EventSubscription. just lint, just format, bun run typecheck and the full bun test (343 pass) are green. Docs: api/task-context.md (ctx.subscribe()) and a "Waiting for Events in a Task" section in crafting-tasks/triggers.md.

Follow-ups

  • The buffer holds one event per subscription. Use a fresh key per wait if a flow needs several responses.
  • The claim query gains one case expression. Expect a small conflict with the other claim-query branches in flight.

@psteinroe psteinroe added the ready label Oct 2, 2026
@psteinroe
psteinroe force-pushed the feat/subscribe-wait branch 3 times, most recently from dd31024 to b00579b Compare October 2, 2026 23:11
@psteinroe
psteinroe force-pushed the feat/subscribe-wait branch from b00579b to 7b4f2cc Compare October 2, 2026 23:17
@psteinroe
psteinroe merged commit 3d0b55c into main Oct 2, 2026
9 checks passed
@psteinroe
psteinroe deleted the feat/subscribe-wait branch October 2, 2026 23:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant