CI wait tool blocks session #264
Labels
No labels
Agent
Ignore
Agent
In Progress
Agent
Planned
Agent
Planning
Agent
Ready
Agent
Triage
Compat
Breaking
Kind
Bug
Kind
Documentation
Kind
Enhancement
Kind
Feature
Kind
Security
Kind
Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
lucas/omp-deck#264
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
The "wait for CI" tool completely stalls the session. Long running CI therefore
blocks the session from responding to events like follow up comments or reviews.
The cause, read out of the code:
forgejo_wait_commit_status(apps/server/src/forgejo/tools/ci.ts:318-371)runs
waitForChecks(ci.ts:256-279), afor (;;)loop that exits only whenevery check is terminal, the commit still has no checks after
EMPTY_GRACE_MS = 30_000, ortimeout_secondspasses (default 600, max 3600).Between fetches it sleeps in
webhookWake(ci.ts:282-302) for up toFALLBACK_POLL_MS = 60_000, waking early only on a commit-status webhookfor its own sha.
runTurn(
apps/server/src/forgejo/dispatcher.ts:2353-2384) settles only onagent_end.routes-forgejo-webhook.tsinserts a
pendingrow intoforgejo_triggersand callsdispatcher.wake().But
pump(dispatcher.ts:550-568) skips any artifact key already inthis.running, and that key'sdrain(dispatcher.ts:571-589) is parkedinside
runReady→runTurn. The row therefore waits in FIFO order until thewait returns.
streamingBehavior: "followUp"(dispatcher.ts:2375), and the SDK readssteering at tool-call boundaries anyway, so a blocking tool swallows it.
handle.abort()after 30 min for conversation/PR/watchdog sessions(
bridge/session-policy.ts:245) or 2 h for implementer sessions (:536).The same stall hits a message queued onto the session from the cockpit: it sits
in the SDK queue until the turn ends.
Plan
End the wait early when the session has input it cannot see. The wait stays
blocking — this is not a park-and-resume redesign — but it gains an interrupt
predicate, so the turn ends, the dispatcher's
drainloop advances, and thepending comment or review is delivered as the next turn.
1. In-process hint bus for queued triggers
New file
apps/server/src/forgejo/trigger-events.ts, deliberately modelled onthe existing
apps/server/src/forgejo/ci-status-events.ts(read it first; copyits structure, its doc comment style and its swallow-on-throw discipline). It
carries no payload — it is a hint, and the listener re-reads the database:
Fire it from one place:
ForgejoDispatcher.pump()(dispatcher.ts:550), rightafter the
keysset is built, guarded byif (keys.size > 0) notifyTriggerQueued();.pump()is the single funnel every insert path reaches — the webhook route viabridge.onForgejoTrigger→wake()(apps/server/src/index.ts:110,:125),and the dispatcher's own synthetic rows via the
this.pump()calls in the statusfan-out and sweeps (
dispatcher.ts:1706,:1718,:1952,queueBranchat:1721,queuePostMergeFixat:1866). Over-firing is harmless: the bus onlyends a sleep, and the predicate in step 4 is the authority.
Do not add the bus to
db/forgejo-triggers.ts:db/must not importforgejo/.2. Narrow query for interrupting rows
Add to
apps/server/src/db/forgejo-triggers.ts:SQL:
SELECT COUNT(*) AS n FROM forgejo_triggers WHERE status = 'pending' AND lower(repo) = ? AND number IN (<placeholders>) AND lower(comment_author) <> ?,with one
?per number. Return0without touching the database whennumbersis empty.
Use this rather than the existing
listPendingForgejoTriggers()(
forgejo-triggers.ts:182): that one loads every pending row'spayload_json,which the predicate never reads.
The author filter is load-bearing. An implementer pushes a commit and then
calls the wait tool; that push has already produced a
pushed/synchronizedrow for the same artifact with
comment_authorset to the agent's own login(
packages/protocol/src/index.ts:2231-2237). Without the filter the wait wouldreturn instantly on every call and the fix would become a spin loop.
3. Session identity reaches the tool
The tool must know which artifacts belong to its session.
listForgejoSessionNumbers(repo, sessionId)(
apps/server/src/db/forgejo-sessions.ts:31) answers that: the issue plus everyPR the session opened, because the bridge maps a new PR to the session as soon
as
forgejo_create_prsucceeds (bridge/in-process.ts:889).Do not use
ctx.sessionManager.getSessionId(). The deck's session id issession.sessionId, which resolves to#activeProviderSessionId()(
node_modules/@oh-my-pi/pi-coding-agent/src/session/agent-session.ts:6084) andis not guaranteed to equal the session manager's id. Thread it explicitly,
following the
filterRef/uiBridgeRefpattern already in this file:apps/server/src/forgejo/tools/shared.ts: add an optional field toForgejoToolContext(optional so the existing stubs inbridge/git-tools/repo-info.test.ts:41andforgejo/tools/*.test.tskeepcompiling):
apps/server/src/forgejo/tools/index.ts: add a trailing optional parametersessionId?: () => string | nullto bothbuildForgejoTools(cwd, signal?)(
index.ts:67) andbuildForgejoToolsForRepo(repo, cwd, signal?)(:91), andput it on the
ForgejoToolContextbuilt atindex.ts:102.apps/server/src/bridge/in-process.ts: declareconst sessionIdRef: { current: string | null } = { current: null };besidefilterRefin bothcreateSession(:291) and the resume path (:454), pass() => sessionIdRef.currentas the new argument at the two build sites(
:298-300,:454-456), addsessionIdRefas a parameter ofattach()(
:929-940), and setsessionIdRef.current = sessionIdimmediately afterconst sessionId = (session as any).sessionId as string;(:942).attach()is the shared wiring point for create and resume, so one assignment covers both.
4. Interrupt predicate in the wait loop
apps/server/src/forgejo/tools/ci.ts:WaitOutcome(ci.ts:239) with"interrupted".WaitOptions(ci.ts:241-249):waitForChecks(ci.ts:256-279), evaluate it after thedonecheck andbefore the
no_checkscheck, so an all-terminal commit still reports"done"but a comment arriving at second 5 is not held for the 30 s empty grace: Carry the reason on the success branch's return type as
reason?: string.webhookWake(ci.ts:282-302) must also resolve on the new bus. Subscribe toonTriggerQueuedalongsideonCommitStatusand unsubscribe both infinish.Worst-case latency without the bus would be 60 s; with it the wait ends within
milliseconds, and the per-iteration predicate still catches anything the bus
missed.
In
waitCommitStatus.run(ci.ts:336-370), build the predicate from two sourcesand pass it as
interrupt:An interactive cockpit session needs no special case: it has no
forgejo_sessionsmapping, sonumbersis empty and only the queued-messagesource is live there — which is the correct behaviour for a human-driven session.
On
outcome === "interrupted", return an error result so a re-call isdiscouraged, with the check report still attached as
details:The reason goes first because
errorResultprefixeserror:to the wholemessage. The predicate is checked on entry as well (it runs before the first
wake), so a re-call while the same rows are pending returns the same errorimmediately instead of spinning — the rows stay
pendinguntil the turn ends,because
drainis parked inrunTurn.Update the tool description (
ci.ts:321) to state that the wait also ends assoon as the session has an unhandled Forgejo event or a queued message.
5. Result plumbing for the error case
apps/server/src/forgejo/tools/shared.ts:139: giveerrorResultan optionalsecond parameter, mirroring
textResult(:128): No existing caller passes a second argument, so this is purely additive.apps/server/src/forgejo/tools/shared.ts:55-59: add toForgejoCallContext: and forward it indefineForgejoTool'sexecute(shared.ts:100-110), whichtoday keeps only
localProtocolOptionsfromsdkCtx:CustomToolContext.hasQueuedMessages()is real and wired(
node_modules/@oh-my-pi/pi-coding-agent/src/sdk.ts:1442→session/session-tools.ts:448→queuedMessageCount() > 0).packages/protocol/src/forgejo-tools.ts:166-170: add toForgejoWaitCommitStatusResult: Set it at both construction sites inci.ts— theonPollpartial (ci.ts:354,interrupted: false) and the finaldetails(ci.ts:368).apps/web/src/components/tools/forgejo/CommitStatusView.tsx:20: add a tagbeside the existing
timedOutone:6. Stale prompt wording
apps/server/src/bridge/session-policy.ts:156(CI_BULLET) currently tells everytrigger session that
forgejo_wait_commit_status"blocks until a commit's checksfinish". Reword that clause to say the wait ends when the checks finish or as
soon as the session has an unhandled comment, review or queued message, in which
case the turn must end and the wait be re-issued in the next turn. Keep the
rest of the bullet and its tool list unchanged.
Critical files & anchors
apps/server/src/forgejo/tools/ci.ts—waitForChecks(256-279),webhookWake(282-302),
waitCommitStatus(318-371). The whole behavioural change lands here.apps/server/src/forgejo/ci-status-events.ts— the template the newtrigger-events.tsmust follow; read before writing it.apps/server/src/forgejo/dispatcher.ts—pump(550-568), the one site thatfires the bus;
drain(571-589) andrunTurn(2353-2384) explain why aninterrupting row stays
pendingfor the whole turn.apps/server/src/bridge/in-process.ts—createSession(290-352), resume path(454-456),
attach(929-942). The three places the session-id ref is threaded.apps/server/src/forgejo/tools/shared.ts—ForgejoToolContext(30-41),ForgejoCallContext(55-59),defineForgejoTool(86-115),errorResult(139).Verification
Working directory: repository root. Runner:
bun test.Unit — the loop. Extend
apps/server/src/forgejo/tools/ci.test.ts, whoseharness(lines 20-37) already injectsfetch,wake,nowandonPollover a fake clock; add
interruptto the overrides. New cases:nullthen"ended early: 1 unhandled Forgejo event(s) …"on the second iteration, over perpetually-pending checks, yields
{ ok: true, outcome: "interrupted", waitedMs: 60_000 }with the reason carried.terminal, still yields
outcome: "done"—donewins overinterrupted.yields
outcome: "interrupted"atwaitedMs: 0, i.e. it does not wait outthe 30 s empty grace.
Run:
bun test apps/server/src/forgejo/tools/ci.test.tsUnit — the query. Add cases to the existing
apps/server/src/db/forgejo-triggers.test.ts: rows in another repo, rows on anunlisted number, rows authored by the agent (mixed casing on both
repoandcomment_author), and rows in a non-pendingstatus are all excluded; anempty
numbersarray returns0and touches nothing.Run:
bun test apps/server/src/db/forgejo-triggers.test.tsEnd-to-end — new behaviour, concrete input to observable output. Throwaway
script (delete afterwards; do not leave a permanent test). Open an isolated
SQLite via the
openDbhelper the db tests use, then:setForgejoSession("acme/widgets", 7, "sess-1");pendingtrigger row withrepo: "Acme/Widgets",number: 7,action: "created",comment_author: "alice";forgejo_wait_commit_statusfrom theexported
CI_TOOLS(ci.ts:579) and call.build(ctx)with a stubForgejoToolContextwhoseclient.jsonalways reports onependingcheck(the
stubForgejoshape inbridge/git-tools/repo-info.test.ts:39-42),agentUsername: "bot", andsessionId: () => "sess-1";await tool.execute("id", { ref: "a".repeat(40), timeout_seconds: 600 }, undefined, {} as never).Expected: the call returns in well under a second (not 600 s) with
isError === true, text startingerror: ended early: 1 unhandled Forgejo event(s) on this session's issue or pull requestfollowed by the normal check report, and
details.interrupted === true.Then flip
comment_authorto"bot"and re-run: the call must now block (giveit
timeout_seconds: 1and assertoutcometexttimed out after 1s), provingthe agent's own events do not self-interrupt.
Whole suite.
bun test—dispatcher.test.tsandsession-policy.test.tsexercise the files touched in steps 1, 3 and 6.
Assumptions & contingencies
attach()(in-process.ts:929) is assumed to be the single wiring point forboth session creation and resume. If a third construction path turns out to
bypass it, set
sessionIdRef.currentat that path too rather than moving theassignment — the ref must never stay
nullfor a trigger session.CustomToolContext.hasQueuedMessages()is assumed to count the dispatcher'sfollowUpseeds as well as cockpit messages. If it turns out to count only oneof them, keep the call as is: the trigger-row predicate is the load-bearing
source for #264, and the queued-message source is an additional safety net.
session-policy.ts(30 min / 2 h) are deliberately unchanged.