Resume a Recessed RFC When a Trigger Fires
An RFC posted as a GitHub Discussion may need to wait. The lead
reads the intake, judges that humans need time to respond (or wants
a fixed window to elapse), and returns a
recessed verdict with a trigger — not a final reply.
The bridge persists that trigger, keeps the RFC open in the
discussion-context store, accumulates every follow-up comment into
history, and re-dispatches the workflow with
resume_context when the trigger condition is met. This
page traces that bounded suspend/resume flow so you can read logs,
debug stuck triggers, and predict bridge behavior.
For the full setup including credentials, App configuration, and tunnel startup, see Bridge GitHub Discussions to the Agent Team.
Prerequisites
-
Completed the
Bridge GitHub Discussions to the Agent Team
guide —
ghbridgeis running, the tunnel is published, the App webhook is configured, and a fresh discussion already triggered a workflow successfully.
Trigger kinds
A recessed callback carries a
trigger object that
ResumeScheduler evaluates via
evaluateTrigger (from
@forwardimpact/libbridge). The kind names the
lead's intent for the recess:
| Kind | Fires when |
|---|---|
missing_input |
At least replies new history entries have accrued
on the dispatching thread since the RFC opened.
|
elapsed |
An ISO-8601 duration (P1D, PT12H,
P1DT6H) has passed since the RFC opened.
|
escalation_needed |
Reserved for future use. The schema accepts
{ kind: "escalation_needed", signal:
"<name>" }, but the scheduler throws until signal-based resume ships.
|
Triggers are evaluated against the caller's clock
(libbridge's
evaluateTrigger(trigger, observed, now) takes
now as a parameter), so the bridge can predict the
resume moment without depending on cron scheduling outside the
service.
The recessed sequence
When the bridge receives a recessed callback, the
libbridge createCallbackHandler skeleton runs
ghbridge's #handleReply:
-
Replies are posted first.
postDiscussionReplies(...)posts eachpayload.replyas a threadedaddDiscussionCommentmutation, and each reply is appended toctx.historyas anassistantturn — same as foradjourned. Thesummaryfield is not posted; on this verdict, it exists only for trace/debug purposes. -
ResumeScheduler.enterRecess(ctx, correlation_id, trigger)recordsopen_rfcs[correlation_id] = { trigger, opened_at, history_index_at_open }. -
For an
elapsedtrigger, the scheduler computesdue_at = opened_at + parseIsoDuration(elapsed), stores it on the rfc, and arms the embeddedElapsedScheduler. When it fires the scheduler re-dispatches without further inbound activity. -
For a
missing_inputtrigger, no timer is armed — every subsequent comment will re-evaluate the trigger insideprocessInbound(ctx). -
The "EYES" reaction is removed by
Acknowledgement.finish(...)before the handler returns, signalling that the workflow run for this correlation id is complete.
The discussion record is flushed to JSONL at the end of the callback so the recess state survives a bridge restart.
The trigger-fires sequence
A trigger fires in one of two places:
-
Inbound comment path —
#handleDiscussionCommentcallsresume.processInbound(ctx)for every comment. The scheduler walksctx.open_rfcs, computesobserved = { replies: history.length - history_index_at_open, opened_at }, and feeds each(trigger, observed, Date.now())triple toevaluateTrigger. Fired RFCs are re-dispatched and cancelled. -
Elapsed timer path —
ElapsedScheduler(embedded inResumeScheduler) fires#fireElapsed(correlationId)on its own schedule. The scheduler looks up the context by walkingstore.index.values(), then re-dispatches and cancels.
Either way, re-dispatch goes through the shared
Dispatcher:
-
resumeContextis built asJSON.stringify({ correlation_id, history_since })wherehistory_since = ctx.history.slice(history_index_at_open). -
Dispatcher.dispatch(...)registers a fresh callback token, starts a new acknowledgement, fires the workflow with the resume payload, appends the prompt to history, and flushes the store. The new correlation id is the one the workflow sees on its next callback; the original correlation id only survives insideresume_context. -
The original RFC is cancelled via
cancelRecess(ctx, correlationId)—open_rfcs[correlationId]is deleted and any elapsed timer for it is cleared. -
The new workflow run produces a fresh verdict.
Usually
adjournedwith final replies, but a secondrecessedis also valid —ResumeSchedulerwill track the new RFC the same way.
Accumulating replies without firing
If an RFC is open and a comment arrives but the trigger does not yet
fire (e.g., replies: 3 and only one comment has
arrived):
-
The inbound comment is appended to
ctx.historyso the next evaluation sees the wider window. -
processInbound(ctx)returnsfreshDispatchAllowed: falsebecausehasOpenRfcis true andfiredis zero, so#handleDiscussionCommentskips the rate-limit +Dispatcher.dispatchbranch. No parallel workflow run is started on the same thread. -
ctx.last_active_atis updated and the store is flushed.
The rate limiter is consulted only when
freshDispatchAllowed is true. Comments that accumulate
toward an open trigger are not rate-limited because they do not
consume workflow runs.
Common failure shapes
| Symptom | Cause |
|---|---|
| Elapsed trigger never fires after bridge restart |
ResumeScheduler.rearm() walks
store.index.values() and re-schedules any rfc
with a persisted due_at; check whether the rfc on
disk has due_at and whether
rearm() ran (called from
service.start())
|
missing_input trigger never fires despite enough
comments
|
The replies count is compared against
history.length - history_index_at_open; check
that webhook delivery is reaching the bridge and that comments
are appearing in ctx.history
|
| Re-dispatch happens but the workflow lacks prior context |
resume_context carries
history_since, the slice from
history_index_at_open onward — not the
full history. The workflow must thread it through its prompt
itself
|
| Two parallel workflow runs on the same thread |
A fresh dispatch fired while an RFC was open; inspect logs
around processInbound to confirm
freshDispatchAllowed was correctly false (and
that no other code path bypassed it)
|
Verify
You have reached the outcome of this guide when:
-
A
recessedverdict posts everyreplyin the callback as a threaded comment, removes the "EYES" reaction, and leaves the discussion open withopen_rfcs[correlation_id]written into the matching JSONL record atdata/bridges/discussions.jsonl(saved through the sharedservices/bridgegRPC service). -
Subsequent comments on the discussion accrue into the bridge's
history without spawning new workflow runs (verify with the
Actions tab — no new run while
hasOpenRfcholds). -
When the trigger condition is met (replies count reached or
elapsed duration passed), a fresh workflow run appears in the
Actions tab with a
resume_contextinput carrying the originalcorrelation_idand thehistory_sinceslice. -
The resumed workflow's lead reads the accumulated comments and
posts a follow-up reply (or another
recessed) back into the same thread.