Repository navigation
feat(tasks): signal branches for ctx.waitForAny, metadata for ctx.signalWithStart and ctx.emit - #100
Merged
Merged
Conversation
psteinroe
force-pushed
the
feat/wait-for-any-signal
branch
from
October 20, 2026 19:00
8d94cc9 to
847f3e4
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a
{ signal: name }branch toctx.waitForAny(). A keyed session started withsignalWithStartcan now suspend until its next input or its tool execution, whichever comes first, instead of ending:Registration and wake (SQL)
A signal branch is an
execution_waitsubscription with a newsignal_namecolumn. Its step key is<stepKey>:<branch>, the same as execution branches._private_register_event_raceholds 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 alsoconductor.signal,ctx.sendSignalandctx.signalWithStart) already lock that row and bumpsignal_count. Under that same lock,_private_store_signalnow 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 thewaitForAny()call and the end of registration. The signal whose handler is still running at the call is passed asp_delivering_signal_idand 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:
metadataforctx.signalWithStartandctx.emitBoth now take
metadata?: Metadata | ((metadata) => Metadata), the same option asctx.invokeandctx.invokeAndWait. It is resolved by the samechildMetadata()helper, so it is validated against the Conductor'smetadataschema and the 8 KB limit, and it defaults to this execution's metadata.ctx.signalWithStartapplies it only when the call starts the execution, and the started id stays memoized under the step. A running execution keeps its metadata. Forctx.emitit becomes the event's metadata and flows to the executions the event triggers.ctx.sendSignalstarts nothing, so it gets no option, and the docs say so.tests/integration/execution-metadata.test.tscovers 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.tscovers the types.Verification
New
tests/integration/wait-for-signal.test.ts:loadStep, and the first fails without the delivering id.onSignalstill receives the signalsignalWithStartwakes a keyed session waiting on input or its toolrun_atstaysinfinity)Unit tests cover
SignalInbox.hold()and theWaitForAnyResulttype for signal branches.just lint,just format,pnpm typecheckand the fullpnpm test(579 tests, plus the new ones) pass.Follow-ups / interaction with open PRs
steps.seq): when it lands, both new step inserts (in_private_store_signaland the race's immediate-signal path) needseqfrom_private_event_seq. Without it, a signal and an execution resolved at the same frozen time fall back to branch order. I'll add that on rebase if fix(testing): keep fake time running so event waits match under a set test clock #98 lands first.