Forgejo: session routing per issue / pull request #31
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#31
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
Webhook-triggered work on the same Forgejo artifact should continue one deck session, not start from scratch every time (plan step 14; design: KB
project/forgejo-integration.md, "Session routing").Today
ForgejoDispatcher.dispatch(apps/server/src/forgejo/dispatcher.ts) callsbridge.createSessionfor every addressed comment and disposes the handle after the first turn. Each reply starts with no memory of the thread except what the agent re-reads from Forgejo.forgejo_triggers.session_idis recorded per trigger, but nothing looks it up again.Requirements:
(repo, number), whatever the trigger kind. Issues and PRs share the number space, soisPullis informational only.session_triggersrow that is missing or doesn't match repo+number, create a new session and overwrite the mapping. This is logged; nothing is posted to Forgejo.Out of scope: archiving on merge/close and lifecycle events (step 15); compaction-aware re-orientation (step 12).
Plan
Context
Replace "new session per trigger" with a persisted artifact→session mapping, plus a presence-aware hold. Triggers for a repo wait while any deck session in its workspace is open. Held triggers on the artifact's own open session can be injected from a banner. The end state is server dispatcher + DB + WS protocol + web banners, all covered by dispatcher tests.
Approach
1. Mapping table (independent of the other steps)
apps/server/src/db/migrations/013-forgejo-sessions.sql:apps/server/src/db/forgejo-sessions.ts, modelled ondb/session-triggers.ts(getDb,nowIso, typedprepare):getForgejoSession(repo: string, number: number): string | null. Lowercasesrepo.setForgejoSession(repo: string, number: number, sessionId: string): void. Upsert withINSERT … ON CONFLICT(repo_key, number) DO UPDATE SET session_id = excluded.session_id, updated_at = excluded.updated_at.findForgejoSessionArtifact(sessionId: string): { repoKey: string; number: number } | null. Used by the banner and Inject paths.2. Presence port on WsHub (independent)
apps/server/src/ws.ts, add toWsHub:openSessionIds(): Set<string>. The union ofws.data.subscriptions.keys()overthis.connections. Pending subscribes don't count.onPresenceChange(cb: () => void): () => void. Firecbafter a subscription is added inhandleSubscribe(right afterws.data.subscriptions.set), afterhandleUnsubscribedeletes one, and inonCloseonce a connection that had subscriptions is torn down.sendToSession(sessionId: string, frame: ServerFrame): void. Callssendon every connection whosesubscriptionshassessionId.handleSubscribe, after the existing replays, also send each frame from a new optional providerforgejoReplay?: (sessionId: string) => ServerFrame[]. Set it withsetForgejoReplay(fn).apps/server/src/forgejo/dispatcher.ts:WsHubsatisfies it structurally.apps/server/src/index.ts: the dispatcher is built at line ~99, beforenew WsHub(bridge)at ~121. AddforgejoDispatcher.attachPresence(ws)andws.setForgejoReplay((id) => forgejoDispatcher.replayFrames(id))right afternew WsHub, beforeforgejoDispatcher.start()is reached. Ifstart()is currently called before line 121, move thestart()call to after the wiring. Without attached presence, the dispatcher treats no session as open.3. Dispatcher restructure (depends on 1, 2)
bridge: Pick<AgentBridge, "createSession" | "ensureSessionLive" | "getSession">.dispatch(row)into:gate(row): Promise<Ready | null>. This is the current code from the start ofdispatchthrough the workspace match (lines 164-211, unchanged behaviour: drops, notices, failures finish the row). It runs without claiming, so the row stayspending. It returnsinterface Ready { row; job: Job; reason: TriggerReason; payload; workspace: Workspace }.runReady(ready: Ready, mode: "unattended" | "inject"): Promise<void>. It callsclaimForgejoTrigger(row.id)and returns silently if that's false, then resolves the session (below), setssetForgejoTriggerSession, and callsrunTurnwithbuildConversationSeed(...)as today. Afterwards it callsfinishForgejoTrigger(done)orthis.fail(...). It disposes the handle only whenmode === "unattended"and the dispatcher made the session live itself, i.e.bridge.getSession(id)was undefined beforeensureSessionLive.resolveSession(ready): Promise<{ handle; broughtLive: boolean }>:const mapped = getForgejoSession(row.repo, row.number).getSessionTrigger(mapped)to have kindforgejo_conversation, scope owner/repo case-insensitively equal to the row's, andnumber === row.number. Wrap it in try/catch, because the getter throws when the row is unreadable, and treat a throw as invalid. Also requiregetArchivedMap([mapped])(db/sessions.ts) to report the session not archived. ThenwasLive = !!bridge.getSession(mapped);const r = await bridge.ensureSessionLive(mapped). Requirer.okandpath.resolve(r.handle.cwd) === path.resolve(workspace.path). If the cwd check fails and!wasLive, disposer.handle. If everything passes, return{ handle: r.handle, broughtLive: !wasLive }. Don't callsetNameagain.log.info("forgejo session <mapped> for <repo>#<n> unusable: <why>; creating new"). Then create exactly as today (createSessionwith theforgejo_conversationtrigger,setName), callsetForgejoSession(row.repo, row.number, handle.sessionId), and return{ handle, broughtLive: true }.pump()/process():running: Set<repoKey>(kept as the worker lock),ready: Map<repoKey, Ready[]>(in memory, FIFO),gated: Set<triggerId>, andactive: Map<workspacePath, { repo; number; triggerId }>for the warning banner.pump(): for each repo key that has pending rows not ingated, or a non-emptyreadylist, and isn't inrunning, add it torunningandtrack(this.drain(key).finally(() => { running.delete(key); this.publish(); })). Don't callpump()again fromfinally;drainloops internally, and new rows arrive throughwake().drain(key)loop:gatedyet, in FIFO order. Add each id togated. Append non-null results toready[key]. Wrap each gate in the existingprocess-style catch, which finishes the row as failed and surfaces it. Gating is safe while the repo is held because it neither touches the working tree nor runs the agent.ready[key]is empty, return.head = ready[key][0]. IfopenInWorkspace(head.workspace.path)(step 4) is non-empty, callthis.publish()and return. The rows staypending.head, setactive[path],publish(),await runReady(head, "unattended"), deleteactive[path], and loop.gatedids when the row finishes, by deleting the id inrunReadyafter finishing and in gate drop/fail paths, so the set can't grow forever.start(): alsopresence.onPresenceChange(() => this.pump()), insideattachPresence.inject(sessionId: string): Promise<{ ok: true } | { ok: false; error: string }>:findForgejoSessionArtifact(sessionId). If there is none:{ ok: false, error: "session is not mapped to a Forgejo artifact" }.running.has(repoKey):{ ok: false, error: "the Forgejo queue for this repository is busy" }.ready[repoKey]entries with thatnumber, keeping their order. If there are none:{ ok: false, error: "nothing to inject" }.runninglock and remove those entries fromready. Then, insidetrack, runrunReady(entry, "inject")sequentially. Becauseprompt()queues as followUp while the session is streaming, eachrunTurnwaits for a terminalagent_end, which includes any user turn already in flight. Afterwards release the lock, then callpublish()andpump().dispose()/whenIdle()are unchanged. Boot-timefailInterruptedForgejoTriggersis unchanged. Held rows arepending, so a restart re-gates them.postNoticealready dedupes, so repeatedNOT_CONNECTEDnotices are suppressed.4. Hold evaluation and banner frames (depends on 2, 3)
openInWorkspace(path): string[]returns the ids inpresence.openSessionIds()whosebridge.getSession(id)?.cwdresolves (path.resolve) topath. Subscribed sessions are always live, sogetSessionis enough.packages/protocol/src/index.ts:replayFrames(sessionId): ServerFrame[]returns two frames for this session: aforgejo_heldframe with everyreadyentry whoseworkspace.pathequals the session's cwd, and aforgejo_activityframe fromactive. An entry isinjectableiffgetForgejoSession(entry.row.repo, entry.row.number) === sessionId. The frames are sent even when empty, so stale UI clears.publish(): for every id inpresence.openSessionIds(),sendToSession(id, …)each frame fromreplayFrames(id). It's called on hold, on run start and end, after inject, and on presence change (viapump's workerfinally, plus directly in theonPresenceChangecallback).ws.ts'sonMessageswitch, handleforgejo_injectby calling a new optionalforgejoInject?: (sessionId) => Promise<{ok:true}|{ok:false;error:string}>, set together withsetForgejoReplay. Onok:false, send the existing error frame pattern used by other session actions (unverified — confirm first: reuse whatever error framehandlePromptsends; if there is none, add| { type: "forgejo_inject_failed"; sessionId: string; error: string }and show it as a toast).5. Web banners (depends on 4)
apps/web/src/lib/store.ts: add slicesforgejoHeld: Record<string, ForgejoHeldTriggerWire[]>andforgejoActivity: Record<string, { repo: string; number: number } | null>, filled from the two frames in the frame switch, followingpendingDialogs. Add actioninjectForgejo(sessionId)→get().ws?.send({ type: "forgejo_inject", sessionId }), followingcancelQueued.apps/web/src/components/chat/ForgejoBanners.tsx, styled likeAskDialog(<section aria-label>, accent border,Buttonfrom@/components/ui/Button):Forgejo agent is working in this workspace (<repo>#<n>).<repo>#<n> · <author> · <receivedAt>. If any trigger isinjectable, show the headingForgejo triggers are waiting for this sessionand an Inject button that callsinjectForgejo. It's always enabled; there is no Dismiss. Otherwise showForgejo triggers for this workspace are waiting until all its sessions are closed, with no button.apps/web/src/components/Chat.tsxnext to<PlanApproval>/<AskDialog>(lines ~105-110).Critical files & anchors
apps/server/src/forgejo/dispatcher.ts:dispatch163-240 (split into gate and run),pump136-150 (rewritten as the worker),runTurn(reused unchanged).apps/server/src/ws.ts:handleSubscribe~180-260,handleUnsubscribe~281-289,onClose~152-163,connectionsL30.apps/server/src/bridge/ensure-live.ts: result codessession_not_found/session_archived/resume_failed, all treated as invalid.apps/server/src/index.ts~99-121: construction order of the dispatcher andWsHub.apps/server/src/forgejo/dispatcher.test.ts:FakeHandle/harness. Extend the fake bridge withensureSessionLiveandgetSessionbacked by aMap, giveFakeHandleacwd, and add a fakeDeckPresence.Verification
Run in
apps/server:bun test src/forgejo/dispatcher.test.ts src/db, thenbun run typecheck. Inapps/web:bun run typecheck. Add these dispatcher tests:o/r#5with no presence → onecreateSession, then oneensureSessionLiveof the same id. Both rows aredonewith the samesession_id, andforgejo_sessionshas one row.setArchived) /ensureSessionLivereturnssession_not_found/ its cwd differs / itssession_triggersrow was deleted → a new session is created and the mapping is overwritten.pending, no session is created, and that session getsforgejo_heldwithinjectable:false. When presence clears and the change fires, it runs and gets disposed.forgejo_heldwithinjectable:true. A second trigger on #5 and a trigger on #6 arrive.inject(mapped)runs both #5 triggers in order in that handle,disposeis NOT called, and #6 stays held while the session is still open.manualmode), opening a session in the workspace makes it receiveforgejo_activitywithworking: {repo, number}. Afterfinish, it getsworking: null.injectreturnsnothing to inject/ not mapped).Manual check: run the deck, open the mapped session, and post an @mention on the issue. The banner shows Inject; clicking it streams the reply into the open session, and it's posted on Forgejo.
Assumptions & contingencies
SessionHandle.cwdisn't populated for resumed sessions, compare againstSessionSummary.cwdfrombridge.listSessions({})instead.handleSubscribe's teardown makes callingonPresenceChangesynchronously duringonCloseunsafe, defer the callback withqueueMicrotask.