Skip to content

feat(tasks): signal branches for ctx.waitForAny, metadata for ctx.signalWithStart and ctx.emit - #100

Merged
psteinroe merged 2 commits into
mainfrom
feat/wait-for-any-signal
Oct 20, 2026
Merged

psteinroe merged 2 commits into
mainfrom
feat/wait-for-any-signal

Conversation

@psteinroe

@psteinroe psteinroe commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

Adds a { signal: name } branch to ctx.waitForAny(). A keyed session started with signalWithStart can now suspend until its next input or its tool execution, whichever comes first, instead of ending:

ctx.onSignal("input", (payload, { id }) => saveInput(id, payload));
const winner = await ctx.waitForAny(`idle:${n}`, {
  input: { signal: "input" },            // new
  tool: { execution: toolExecutionId },
});
// { key: "input", signal: { id, name } } | { key: "tool", outcome } | { key: "timeout" }

Registration and wake (SQL)

A signal branch is an execution_wait subscription with a new signal_name column. Its step key is <stepKey>:<branch>, the same as execution branches. _private_register_event_race holds the claimed execution's row lock. While holding it, the race wins at once if the execution has an undelivered signal with that name; otherwise it subscribes. Senders (signal_execution, signal_with_start, so also conductor.signal, ctx.sendSignal and ctx.signalWithStart) already lock that row and bump signal_count. Under that same lock, _private_store_signal now does three things for a new signal: it stores {status: 'resolved', signal: {id, name}} under the step key of every unexpired matching subscription, removes those subscriptions, and wakes the execution if it is suspended at a wait (the same condition dispatch uses). Every signal is ordered against registration by the row lock, so a signal stored between the check and the suspend wakes the execution. No new lock is taken, and the sender still locks only the signalled row, which is documented in the AGENTS.md lock-order section.

Signals for names no waiting branch listens for behave as before: they are kept for the next attempt and do not wake the execution. Branches are registered only on the first call, the same as execution branches in #99. A signal that arrives after the deadline does not win (expires_at >= now, matching #99's rule for result waits). Winner selection, the timeout, closing losing branches and removing their subscriptions all reuse the existing race code, so the one-winner guarantee from #79 holds.

The branch does not consume the signal

The signal stays undelivered. The next attempt primes it at claim and delivers it to ctx.onSignal(). The result carries only the signal's { id, name }.

Deviation from the brief: signals being handled count too

"Undelivered" alone leaves a lost wake-up. A handler can return and acknowledge a signal after the user's loop checked its saved inputs but before the race registers. The wait then suspends with an unprocessed input and nothing to wake it. To close that gap, SignalInbox.hold() stops new handlers from starting between the waitForAny() call and the end of registration. The signal whose handler is still running at the call is passed as p_delivering_signal_id and counts like an undelivered one. The documented rule is: a signal wins unless its handler returned before the call. Docs also say to register a handler for every signal branch. Without one, the signal is never delivered, so it wins every later wait at once.

Ride-along: metadata for ctx.signalWithStart and ctx.emit

Both now take metadata?: Metadata | ((metadata) => Metadata), the same option as ctx.invoke and ctx.invokeAndWait. It is resolved by the same childMetadata() helper, so it is validated against the Conductor's metadata schema and the 8 KB limit, and it defaults to this execution's metadata. ctx.signalWithStart applies it only when the call starts the execution, and the started id stays memoized under the step. A running execution keeps its metadata. For ctx.emit it becomes the event's metadata and flows to the executions the event triggers. ctx.sendSignal starts nothing, so it gets no option, and the docs say so. tests/integration/execution-metadata.test.ts covers an override, the function form, inheritance by default, a signal to a running execution that keeps its metadata, and schema-validation failures for both. execution-metadata-types.test.ts covers the types.

Verification

New tests/integration/wait-for-signal.test.ts:

  • a signal stored before the wait wins without suspending (handler entered once)
  • a signal whose handler is running at the call wins, and so does one whose handler returns and is acknowledged while the step read is held back. The second test fails if the hold starts after loadStep, and the first fails without the delivering id.
  • a signal wakes a suspended wait; replays return the memoized winner; onSignal still receives the signal
  • two deterministic races on separate connections with held transactions: a signal blocked behind an uncommitted registration wakes it, and a registration blocked behind an uncommitted signal wins at once
  • one winner when a signal and an execution branch settle at the same moment (two orchestrators)
  • signalWithStart wakes a keyed session waiting on input or its tool
  • a signal for another name does not wake the wait (run_at stays infinity)
  • under a set test clock: a signal wakes the wait. After advancing past the timeout, a late signal does not win and the timeout does. This test fails without the expiry check.

Unit tests cover SignalInbox.hold() and the WaitForAnyResult type for signal branches. just lint, just format, pnpm typecheck and the full pnpm test (579 tests, plus the new ones) pass.

Follow-ups / interaction with open PRs

@psteinroe psteinroe changed the title feat(tasks): signal branches for ctx.waitForAny feat(tasks): signal branches for ctx.waitForAny, metadata for ctx.signalWithStart and ctx.emit Oct 20, 2026
@psteinroe
psteinroe force-pushed the feat/wait-for-any-signal branch from 8d94cc9 to 847f3e4 Compare October 20, 2026 19:00
@psteinroe
psteinroe merged commit dd0bf14 into main Oct 20, 2026
11 checks passed
@psteinroe
psteinroe deleted the feat/wait-for-any-signal branch October 20, 2026 19:05
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