Forgejo: session routing per issue / pull request #31

Closed
opened 2026-10-16 14:49:35 +02:00 by omp-deck · 0 comments
Collaborator

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) calls bridge.createSession for 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_id is recorded per trigger, but nothing looks it up again.

Requirements:

  • One session per (repo, number), whatever the trigger kind. Issues and PRs share the number space, so isPull is informational only.
  • If a usable mapped session exists, resume it and send the new event as the next turn. Otherwise create a session and record the mapping.
  • Keep per-repo FIFO ordering. Never run two concurrent turns in one session.
  • Keep disposing after unattended turns. Resume must work from the persisted JSONL after a dispose or a server restart.
  • Open sessions are full blockers. A session open in the deck can do anything, including changing files. So while any deck session whose cwd is the repo's workspace is open (subscribed by at least one deck WebSocket connection, background tabs included), no unattended trigger for that repo runs.
    • If the trigger's mapped session is the one open, it shows a persistent banner with an Inject button. Clicking it runs the waiting triggers for that artifact in that session, one turn each in FIFO order. Injecting while the session is streaming queues each prompt as followUp. There is no Dismiss.
    • Other open sessions in the workspace show a banner that lists the held triggers for information only (no button).
    • Held triggers run unattended automatically once no session in the workspace is open.
    • While an unattended turn runs, every open session in that workspace shows the warning "Forgejo agent is working in this workspace". Opening sessions is still allowed.
  • Invalidation, checked lazily at dispatch: if the mapped session is archived, not found (deleted or JSONL missing), fails to resume, has a cwd different from the workspace path, or has a session_triggers row 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)

  • Add apps/server/src/db/migrations/013-forgejo-sessions.sql:
    CREATE TABLE forgejo_sessions (
      repo_key TEXT NOT NULL,      -- lowercased owner/repo
      number INTEGER NOT NULL,
      session_id TEXT NOT NULL,
      created_at TEXT NOT NULL,
      updated_at TEXT NOT NULL,
      PRIMARY KEY (repo_key, number)
    );
    
  • Add apps/server/src/db/forgejo-sessions.ts, modelled on db/session-triggers.ts (getDb, nowIso, typed prepare):
    • getForgejoSession(repo: string, number: number): string | null. Lowercases repo.
    • setForgejoSession(repo: string, number: number, sessionId: string): void. Upsert with INSERT … 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.
  • Don't add hooks to the delete or archive routes; invalidation is lazy (step 3).

2. Presence port on WsHub (independent)

  • In apps/server/src/ws.ts, add to WsHub:
    • openSessionIds(): Set<string>. The union of ws.data.subscriptions.keys() over this.connections. Pending subscribes don't count.
    • onPresenceChange(cb: () => void): () => void. Fire cb after a subscription is added in handleSubscribe (right after ws.data.subscriptions.set), after handleUnsubscribe deletes one, and in onClose once a connection that had subscriptions is torn down.
    • sendToSession(sessionId: string, frame: ServerFrame): void. Calls send on every connection whose subscriptions has sessionId.
    • In handleSubscribe, after the existing replays, also send each frame from a new optional provider forgejoReplay?: (sessionId: string) => ServerFrame[]. Set it with setForgejoReplay(fn).
  • Define in apps/server/src/forgejo/dispatcher.ts:
    export interface DeckPresence {
      openSessionIds(): Set<string>;
      onPresenceChange(cb: () => void): () => void;
      sendToSession(sessionId: string, frame: ServerFrame): void;
    }
    
    WsHub satisfies it structurally.
  • Wiring in apps/server/src/index.ts: the dispatcher is built at line ~99, before new WsHub(bridge) at ~121. Add forgejoDispatcher.attachPresence(ws) and ws.setForgejoReplay((id) => forgejoDispatcher.replayFrames(id)) right after new WsHub, before forgejoDispatcher.start() is reached. If start() is currently called before line 121, move the start() call to after the wiring. Without attached presence, the dispatcher treats no session as open.

3. Dispatcher restructure (depends on 1, 2)

  • Widen deps: bridge: Pick<AgentBridge, "createSession" | "ensureSessionLive" | "getSession">.
  • Split dispatch(row) into:
    • gate(row): Promise<Ready | null>. This is the current code from the start of dispatch through the workspace match (lines 164-211, unchanged behaviour: drops, notices, failures finish the row). It runs without claiming, so the row stays pending. It returns interface Ready { row; job: Job; reason: TriggerReason; payload; workspace: Workspace }.
    • runReady(ready: Ready, mode: "unattended" | "inject"): Promise<void>. It calls claimForgejoTrigger(row.id) and returns silently if that's false, then resolves the session (below), sets setForgejoTriggerSession, and calls runTurn with buildConversationSeed(...) as today. Afterwards it calls finishForgejoTrigger(done) or this.fail(...). It disposes the handle only when mode === "unattended" and the dispatcher made the session live itself, i.e. bridge.getSession(id) was undefined before ensureSessionLive.
  • Session resolution, resolveSession(ready): Promise<{ handle; broughtLive: boolean }>:
    1. const mapped = getForgejoSession(row.repo, row.number).
    2. If mapped: require getSessionTrigger(mapped) to have kind forgejo_conversation, scope owner/repo case-insensitively equal to the row's, and number === row.number. Wrap it in try/catch, because the getter throws when the row is unreadable, and treat a throw as invalid. Also require getArchivedMap([mapped]) (db/sessions.ts) to report the session not archived. Then wasLive = !!bridge.getSession(mapped); const r = await bridge.ensureSessionLive(mapped). Require r.ok and path.resolve(r.handle.cwd) === path.resolve(workspace.path). If the cwd check fails and !wasLive, dispose r.handle. If everything passes, return { handle: r.handle, broughtLive: !wasLive }. Don't call setName again.
    3. Otherwise, log.info("forgejo session <mapped> for <repo>#<n> unusable: <why>; creating new"). Then create exactly as today (createSession with the forgejo_conversation trigger, setName), call setForgejoSession(row.repo, row.number, handle.sessionId), and return { handle, broughtLive: true }.
  • Per-repo worker, replacing the body of pump()/process():
    • State: running: Set<repoKey> (kept as the worker lock), ready: Map<repoKey, Ready[]> (in memory, FIFO), gated: Set<triggerId>, and active: Map<workspacePath, { repo; number; triggerId }> for the warning banner.
    • pump(): for each repo key that has pending rows not in gated, or a non-empty ready list, and isn't in running, add it to running and track(this.drain(key).finally(() => { running.delete(key); this.publish(); })). Don't call pump() again from finally; drain loops internally, and new rows arrive through wake().
    • drain(key) loop:
      1. Gate every pending row of this repo that isn't in gated yet, in FIFO order. Add each id to gated. Append non-null results to ready[key]. Wrap each gate in the existing process-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.
      2. If ready[key] is empty, return.
      3. Let head = ready[key][0]. If openInWorkspace(head.workspace.path) (step 4) is non-empty, call this.publish() and return. The rows stay pending.
      4. Otherwise shift head, set active[path], publish(), await runReady(head, "unattended"), delete active[path], and loop.
    • Remove gated ids when the row finishes, by deleting the id in runReady after finishing and in gate drop/fail paths, so the set can't grow forever.
    • start(): also presence.onPresenceChange(() => this.pump()), inside attachPresence.
  • inject(sessionId: string): Promise<{ ok: true } | { ok: false; error: string }>:
    1. findForgejoSessionArtifact(sessionId). If there is none: { ok: false, error: "session is not mapped to a Forgejo artifact" }.
    2. If running.has(repoKey): { ok: false, error: "the Forgejo queue for this repository is busy" }.
    3. Collect ready[repoKey] entries with that number, keeping their order. If there are none: { ok: false, error: "nothing to inject" }.
    4. Take the running lock and remove those entries from ready. Then, inside track, run runReady(entry, "inject") sequentially. Because prompt() queues as followUp while the session is streaming, each runTurn waits for a terminal agent_end, which includes any user turn already in flight. Afterwards release the lock, then call publish() and pump().
  • dispose()/whenIdle() are unchanged. Boot-time failInterruptedForgejoTriggers is unchanged. Held rows are pending, so a restart re-gates them. postNotice already dedupes, so repeated NOT_CONNECTED notices are suppressed.

4. Hold evaluation and banner frames (depends on 2, 3)

  • openInWorkspace(path): string[] returns the ids in presence.openSessionIds() whose bridge.getSession(id)?.cwd resolves (path.resolve) to path. Subscribed sessions are always live, so getSession is enough.
  • Protocol, in packages/protocol/src/index.ts:
    export interface ForgejoHeldTriggerWire {
      triggerId: string; repo: string; number: number; isPull: boolean;
      author: string; receivedAt: string; injectable: boolean;
    }
    // ServerFrame members:
    | { type: "forgejo_held"; sessionId: string; triggers: ForgejoHeldTriggerWire[] }
    | { type: "forgejo_activity"; sessionId: string; working: { repo: string; number: number } | null }
    // ClientFrame member:
    | { type: "forgejo_inject"; sessionId: string }
    
  • replayFrames(sessionId): ServerFrame[] returns two frames for this session: a forgejo_held frame with every ready entry whose workspace.path equals the session's cwd, and a forgejo_activity frame from active. An entry is injectable iff getForgejoSession(entry.row.repo, entry.row.number) === sessionId. The frames are sent even when empty, so stale UI clears.
  • publish(): for every id in presence.openSessionIds(), sendToSession(id, …) each frame from replayFrames(id). It's called on hold, on run start and end, after inject, and on presence change (via pump's worker finally, plus directly in the onPresenceChange callback).
  • WS handler: in ws.ts's onMessage switch, handle forgejo_inject by calling a new optional forgejoInject?: (sessionId) => Promise<{ok:true}|{ok:false;error:string}>, set together with setForgejoReplay. On ok:false, send the existing error frame pattern used by other session actions (unverified — confirm first: reuse whatever error frame handlePrompt sends; 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 slices forgejoHeld: Record<string, ForgejoHeldTriggerWire[]> and forgejoActivity: Record<string, { repo: string; number: number } | null>, filled from the two frames in the frame switch, following pendingDialogs. Add action injectForgejo(sessionId) → get().ws?.send({ type: "forgejo_inject", sessionId }), following cancelQueued.
  • New apps/web/src/components/chat/ForgejoBanners.tsx, styled like AskDialog (<section aria-label>, accent border, Button from @/components/ui/Button):
    • Warning row when activity is non-null: Forgejo agent is working in this workspace (<repo>#<n>).
    • Held list: one line per trigger, <repo>#<n> · <author> · <receivedAt>. If any trigger is injectable, show the heading Forgejo triggers are waiting for this session and an Inject button that calls injectForgejo. It's always enabled; there is no Dismiss. Otherwise show Forgejo triggers for this workspace are waiting until all its sessions are closed, with no button.
  • Mount it in apps/web/src/components/Chat.tsx next to <PlanApproval>/<AskDialog> (lines ~105-110).

Critical files & anchors

  • apps/server/src/forgejo/dispatcher.ts: dispatch 163-240 (split into gate and run), pump 136-150 (rewritten as the worker), runTurn (reused unchanged).
  • apps/server/src/ws.ts: handleSubscribe ~180-260, handleUnsubscribe ~281-289, onClose ~152-163, connections L30.
  • apps/server/src/bridge/ensure-live.ts: result codes session_not_found / session_archived / resume_failed, all treated as invalid.
  • apps/server/src/index.ts ~99-121: construction order of the dispatcher and WsHub.
  • apps/server/src/forgejo/dispatcher.test.ts: FakeHandle/harness. Extend the fake bridge with ensureSessionLive and getSession backed by a Map, give FakeHandle a cwd, and add a fake DeckPresence.

Verification

Run in apps/server: bun test src/forgejo/dispatcher.test.ts src/db, then bun run typecheck. In apps/web: bun run typecheck. Add these dispatcher tests:

  1. Two triggers on o/r#5 with no presence → one createSession, then one ensureSessionLive of the same id. Both rows are done with the same session_id, and forgejo_sessions has one row.
  2. The mapped session is archived (setArchived) / ensureSessionLive returns session_not_found / its cwd differs / its session_triggers row was deleted → a new session is created and the mapping is overwritten.
  3. The presence reports another session with the workspace cwd as open → the trigger stays pending, no session is created, and that session gets forgejo_held with injectable:false. When presence clears and the change fires, it runs and gets disposed.
  4. The mapped session is open → forgejo_held with injectable:true. A second trigger on #5 and a trigger on #6 arrive. inject(mapped) runs both #5 triggers in order in that handle, dispose is NOT called, and #6 stays held while the session is still open.
  5. While an unattended turn runs (FakeHandle manual mode), opening a session in the workspace makes it receive forgejo_activity with working: {repo, number}. After finish, it gets working: null.
  6. A trigger on #5 for a session mapped in a different repo → no injection (inject returns nothing 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

  • If SessionHandle.cwd isn't populated for resumed sessions, compare against SessionSummary.cwd from bridge.listSessions({}) instead.
  • If handleSubscribe's teardown makes calling onPresenceChange synchronously during onClose unsafe, defer the callback with queueMicrotask.
## 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`) calls `bridge.createSession` for 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_id` is recorded per trigger, but nothing looks it up again. Requirements: - One session per `(repo, number)`, whatever the trigger kind. Issues and PRs share the number space, so `isPull` is informational only. - If a usable mapped session exists, resume it and send the new event as the next turn. Otherwise create a session and record the mapping. - Keep per-repo FIFO ordering. Never run two concurrent turns in one session. - Keep disposing after unattended turns. Resume must work from the persisted JSONL after a dispose or a server restart. - **Open sessions are full blockers.** A session open in the deck can do anything, including changing files. So while any deck session whose cwd is the repo's workspace is open (subscribed by at least one deck WebSocket connection, background tabs included), no unattended trigger for that repo runs. - If the trigger's mapped session is the one open, it shows a persistent banner with an **Inject** button. Clicking it runs the waiting triggers for that artifact in that session, one turn each in FIFO order. Injecting while the session is streaming queues each prompt as followUp. There is no Dismiss. - Other open sessions in the workspace show a banner that lists the held triggers for information only (no button). - Held triggers run unattended automatically once no session in the workspace is open. - While an unattended turn runs, every open session in that workspace shows the warning "Forgejo agent is working in this workspace". Opening sessions is still allowed. - Invalidation, checked lazily at dispatch: if the mapped session is archived, not found (deleted or JSONL missing), fails to resume, has a cwd different from the workspace path, or has a `session_triggers` row 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) - Add `apps/server/src/db/migrations/013-forgejo-sessions.sql`: ```sql CREATE TABLE forgejo_sessions ( repo_key TEXT NOT NULL, -- lowercased owner/repo number INTEGER NOT NULL, session_id TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (repo_key, number) ); ``` - Add `apps/server/src/db/forgejo-sessions.ts`, modelled on `db/session-triggers.ts` (`getDb`, `nowIso`, typed `prepare`): - `getForgejoSession(repo: string, number: number): string | null`. Lowercases `repo`. - `setForgejoSession(repo: string, number: number, sessionId: string): void`. Upsert with `INSERT … 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. - Don't add hooks to the delete or archive routes; invalidation is lazy (step 3). **2. Presence port on WsHub** (independent) - In `apps/server/src/ws.ts`, add to `WsHub`: - `openSessionIds(): Set<string>`. The union of `ws.data.subscriptions.keys()` over `this.connections`. Pending subscribes don't count. - `onPresenceChange(cb: () => void): () => void`. Fire `cb` after a subscription is added in `handleSubscribe` (right after `ws.data.subscriptions.set`), after `handleUnsubscribe` deletes one, and in `onClose` once a connection that had subscriptions is torn down. - `sendToSession(sessionId: string, frame: ServerFrame): void`. Calls `send` on every connection whose `subscriptions` has `sessionId`. - In `handleSubscribe`, after the existing replays, also send each frame from a new optional provider `forgejoReplay?: (sessionId: string) => ServerFrame[]`. Set it with `setForgejoReplay(fn)`. - Define in `apps/server/src/forgejo/dispatcher.ts`: ```ts export interface DeckPresence { openSessionIds(): Set<string>; onPresenceChange(cb: () => void): () => void; sendToSession(sessionId: string, frame: ServerFrame): void; } ``` `WsHub` satisfies it structurally. - Wiring in `apps/server/src/index.ts`: the dispatcher is built at line ~99, before `new WsHub(bridge)` at ~121. Add `forgejoDispatcher.attachPresence(ws)` and `ws.setForgejoReplay((id) => forgejoDispatcher.replayFrames(id))` right after `new WsHub`, before `forgejoDispatcher.start()` is reached. If `start()` is currently called before line 121, move the `start()` call to after the wiring. Without attached presence, the dispatcher treats no session as open. **3. Dispatcher restructure** (depends on 1, 2) - Widen deps: `bridge: Pick<AgentBridge, "createSession" | "ensureSessionLive" | "getSession">`. - Split `dispatch(row)` into: - `gate(row): Promise<Ready | null>`. This is the current code from the start of `dispatch` through the workspace match (lines 164-211, unchanged behaviour: drops, notices, failures finish the row). It runs **without claiming**, so the row stays `pending`. It returns `interface Ready { row; job: Job; reason: TriggerReason; payload; workspace: Workspace }`. - `runReady(ready: Ready, mode: "unattended" | "inject"): Promise<void>`. It calls `claimForgejoTrigger(row.id)` and returns silently if that's false, then resolves the session (below), sets `setForgejoTriggerSession`, and calls `runTurn` with `buildConversationSeed(...)` as today. Afterwards it calls `finishForgejoTrigger(done)` or `this.fail(...)`. It disposes the handle only when `mode === "unattended"` **and** the dispatcher made the session live itself, i.e. `bridge.getSession(id)` was undefined before `ensureSessionLive`. - Session resolution, `resolveSession(ready): Promise<{ handle; broughtLive: boolean }>`: 1. `const mapped = getForgejoSession(row.repo, row.number)`. 2. If mapped: require `getSessionTrigger(mapped)` to have kind `forgejo_conversation`, scope owner/repo case-insensitively equal to the row's, and `number === row.number`. Wrap it in try/catch, because the getter throws when the row is unreadable, and treat a throw as invalid. Also require `getArchivedMap([mapped])` (db/sessions.ts) to report the session not archived. Then `wasLive = !!bridge.getSession(mapped)`; `const r = await bridge.ensureSessionLive(mapped)`. Require `r.ok` and `path.resolve(r.handle.cwd) === path.resolve(workspace.path)`. If the cwd check fails and `!wasLive`, dispose `r.handle`. If everything passes, return `{ handle: r.handle, broughtLive: !wasLive }`. Don't call `setName` again. 3. Otherwise, `log.info("forgejo session <mapped> for <repo>#<n> unusable: <why>; creating new")`. Then create exactly as today (`createSession` with the `forgejo_conversation` trigger, `setName`), call `setForgejoSession(row.repo, row.number, handle.sessionId)`, and return `{ handle, broughtLive: true }`. - Per-repo worker, replacing the body of `pump()`/`process()`: - State: `running: Set<repoKey>` (kept as the worker lock), `ready: Map<repoKey, Ready[]>` (in memory, FIFO), `gated: Set<triggerId>`, and `active: Map<workspacePath, { repo; number; triggerId }>` for the warning banner. - `pump()`: for each repo key that has pending rows not in `gated`, or a non-empty `ready` list, and isn't in `running`, add it to `running` and `track(this.drain(key).finally(() => { running.delete(key); this.publish(); }))`. Don't call `pump()` again from `finally`; `drain` loops internally, and new rows arrive through `wake()`. - `drain(key)` loop: 1. Gate every pending row of this repo that isn't in `gated` yet, in FIFO order. Add each id to `gated`. Append non-null results to `ready[key]`. Wrap each gate in the existing `process`-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. 2. If `ready[key]` is empty, return. 3. Let `head = ready[key][0]`. If `openInWorkspace(head.workspace.path)` (step 4) is non-empty, call `this.publish()` and return. The rows stay `pending`. 4. Otherwise shift `head`, set `active[path]`, `publish()`, `await runReady(head, "unattended")`, delete `active[path]`, and loop. - Remove `gated` ids when the row finishes, by deleting the id in `runReady` after finishing and in gate drop/fail paths, so the set can't grow forever. - `start()`: also `presence.onPresenceChange(() => this.pump())`, inside `attachPresence`. - `inject(sessionId: string): Promise<{ ok: true } | { ok: false; error: string }>`: 1. `findForgejoSessionArtifact(sessionId)`. If there is none: `{ ok: false, error: "session is not mapped to a Forgejo artifact" }`. 2. If `running.has(repoKey)`: `{ ok: false, error: "the Forgejo queue for this repository is busy" }`. 3. Collect `ready[repoKey]` entries with that `number`, keeping their order. If there are none: `{ ok: false, error: "nothing to inject" }`. 4. Take the `running` lock and remove those entries from `ready`. Then, inside `track`, run `runReady(entry, "inject")` sequentially. Because `prompt()` queues as followUp while the session is streaming, each `runTurn` waits for a terminal `agent_end`, which includes any user turn already in flight. Afterwards release the lock, then call `publish()` and `pump()`. - `dispose()`/`whenIdle()` are unchanged. Boot-time `failInterruptedForgejoTriggers` is unchanged. Held rows are `pending`, so a restart re-gates them. `postNotice` already dedupes, so repeated `NOT_CONNECTED` notices are suppressed. **4. Hold evaluation and banner frames** (depends on 2, 3) - `openInWorkspace(path): string[]` returns the ids in `presence.openSessionIds()` whose `bridge.getSession(id)?.cwd` resolves (`path.resolve`) to `path`. Subscribed sessions are always live, so `getSession` is enough. - Protocol, in `packages/protocol/src/index.ts`: ```ts export interface ForgejoHeldTriggerWire { triggerId: string; repo: string; number: number; isPull: boolean; author: string; receivedAt: string; injectable: boolean; } // ServerFrame members: | { type: "forgejo_held"; sessionId: string; triggers: ForgejoHeldTriggerWire[] } | { type: "forgejo_activity"; sessionId: string; working: { repo: string; number: number } | null } // ClientFrame member: | { type: "forgejo_inject"; sessionId: string } ``` - `replayFrames(sessionId): ServerFrame[]` returns two frames for this session: a `forgejo_held` frame with every `ready` entry whose `workspace.path` equals the session's cwd, and a `forgejo_activity` frame from `active`. An entry is `injectable` iff `getForgejoSession(entry.row.repo, entry.row.number) === sessionId`. The frames are sent even when empty, so stale UI clears. - `publish()`: for every id in `presence.openSessionIds()`, `sendToSession(id, …)` each frame from `replayFrames(id)`. It's called on hold, on run start and end, after inject, and on presence change (via `pump`'s worker `finally`, plus directly in the `onPresenceChange` callback). - WS handler: in `ws.ts`'s `onMessage` switch, handle `forgejo_inject` by calling a new optional `forgejoInject?: (sessionId) => Promise<{ok:true}|{ok:false;error:string}>`, set together with `setForgejoReplay`. On `ok:false`, send the existing error frame pattern used by other session actions (`unverified — confirm first`: reuse whatever error frame `handlePrompt` sends; 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 slices `forgejoHeld: Record<string, ForgejoHeldTriggerWire[]>` and `forgejoActivity: Record<string, { repo: string; number: number } | null>`, filled from the two frames in the frame switch, following `pendingDialogs`. Add action `injectForgejo(sessionId)` → `get().ws?.send({ type: "forgejo_inject", sessionId })`, following `cancelQueued`. - New `apps/web/src/components/chat/ForgejoBanners.tsx`, styled like `AskDialog` (`<section aria-label>`, accent border, `Button` from `@/components/ui/Button`): - Warning row when activity is non-null: `Forgejo agent is working in this workspace (<repo>#<n>)`. - Held list: one line per trigger, `<repo>#<n> · <author> · <receivedAt>`. If any trigger is `injectable`, show the heading `Forgejo triggers are waiting for this session` and an **Inject** button that calls `injectForgejo`. It's always enabled; there is no Dismiss. Otherwise show `Forgejo triggers for this workspace are waiting until all its sessions are closed`, with no button. - Mount it in `apps/web/src/components/Chat.tsx` next to `<PlanApproval>`/`<AskDialog>` (lines ~105-110). ### Critical files & anchors - `apps/server/src/forgejo/dispatcher.ts`: `dispatch` 163-240 (split into gate and run), `pump` 136-150 (rewritten as the worker), `runTurn` (reused unchanged). - `apps/server/src/ws.ts`: `handleSubscribe` ~180-260, `handleUnsubscribe` ~281-289, `onClose` ~152-163, `connections` L30. - `apps/server/src/bridge/ensure-live.ts`: result codes `session_not_found` / `session_archived` / `resume_failed`, all treated as invalid. - `apps/server/src/index.ts` ~99-121: construction order of the dispatcher and `WsHub`. - `apps/server/src/forgejo/dispatcher.test.ts`: `FakeHandle`/`harness`. Extend the fake bridge with `ensureSessionLive` and `getSession` backed by a `Map`, give `FakeHandle` a `cwd`, and add a fake `DeckPresence`. ### Verification Run in `apps/server`: `bun test src/forgejo/dispatcher.test.ts src/db`, then `bun run typecheck`. In `apps/web`: `bun run typecheck`. Add these dispatcher tests: 1. Two triggers on `o/r#5` with no presence → one `createSession`, then one `ensureSessionLive` of the same id. Both rows are `done` with the same `session_id`, and `forgejo_sessions` has one row. 2. The mapped session is archived (`setArchived`) / `ensureSessionLive` returns `session_not_found` / its cwd differs / its `session_triggers` row was deleted → a new session is created and the mapping is overwritten. 3. The presence reports another session with the workspace cwd as open → the trigger stays `pending`, no session is created, and that session gets `forgejo_held` with `injectable:false`. When presence clears and the change fires, it runs and gets disposed. 4. The mapped session is open → `forgejo_held` with `injectable:true`. A second trigger on #5 and a trigger on #6 arrive. `inject(mapped)` runs both #5 triggers in order in that handle, `dispose` is NOT called, and #6 stays held while the session is still open. 5. While an unattended turn runs (FakeHandle `manual` mode), opening a session in the workspace makes it receive `forgejo_activity` with `working: {repo, number}`. After `finish`, it gets `working: null`. 6. A trigger on #5 for a session mapped in a different repo → no injection (`inject` returns `nothing 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 - If `SessionHandle.cwd` isn't populated for resumed sessions, compare against `SessionSummary.cwd` from `bridge.listSessions({})` instead. - If `handleSubscribe`'s teardown makes calling `onPresenceChange` synchronously during `onClose` unsafe, defer the callback with `queueMicrotask`.
lucas closed this issue 2026-10-17 01:14:49 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
lucas/omp-deck#31
No description provided.