Forgejo: run trigger sessions in isolated per-artifact clones #33

Closed
opened 2026-10-16 15:15:12 +02:00 by omp-deck · 0 comments
Collaborator

Problem

Webhook-triggered Forgejo sessions run with the registered workspace's project dir as cwd (dispatcher.ts: createSession({ cwd: workspaces[0]!.path, trigger })). All trigger sessions of a repo and every manual UI session therefore share one working tree and one set of branches. That is why the dispatcher serializes triggers per repo, and why the design's "fetch and checkout PR branch before session starts" precondition would conflict with whatever the user has checked out.

Goal: each Forgejo artifact (issue or PR) gets its own clone as its session's cwd, so trigger sessions are isolated from each other and from manual sessions. Then triggers on different artifacts can run in parallel.

Decisions taken while researching:

  • Clone, not worktree. Git refuses to check out a branch in two worktrees of one repo, and --ignore-other-worktrees shares refs/heads/<branch> between them, so a commit in one silently dirties the other. The user must be able to check out the same branch in the project dir and work hands-on. A separate clone has its own refs; the user and the agent collaborate through the remote.
  • git clone --local from the workspace path: objects are hardlinked, so it needs little disk and no network. Never --shared/--reference: a gc in the project dir could prune objects the clone depends on.
  • One clone per artifact, at <data dir>/checkouts/<owner>/<repo>/<issue|pr>-<number>, outside the project dir, so it never shows up in the user's git status or searches.
  • Lifetime = the artifact's session. The clone is removed when its session is deleted. There is no sweep for closed artifacts; removal on close/merge comes with plan step 15.
  • Trigger sessions stay listed under their workspace. The UI shows the clone path as the session's cwd, which also marks it as a different kind of session.
  • The dispatcher queue becomes FIFO per artifact (repo#number) instead of per repo.
  • Untracked files of the project dir (.env, local overrides, node_modules) are not carried into the clone. A follow-up handles this if needed.
  • Blocked on plan step 14 (Forgejo session routing: a forgejo_sessions table mapping (repo, artifact_type, artifact_id) → session_id). Clones are keyed per artifact, which only works once there is exactly one session per artifact.

Plan

Context

Once plan step 14 routes every event of an artifact into one session, the step-14 code that creates a new session for an artifact must first create a dedicated clone and use it as the session's cwd. The session keeps an explicit link to its workspace (for listing, counting, workspace removal, and workspace-scoped permission rules) and to its clone (for removal on delete). The dispatcher's per-repo FIFO becomes per-artifact.

Approach

Prerequisite: plan step 14 is merged. Step 14 is not built yet, so where this plan says "the step-14 new-session branch", it means the place in ForgejoDispatcher.dispatch (apps/server/src/forgejo/dispatcher.ts) where no existing session is mapped for the artifact and bridge.createSession is called. Today that is line ~237. Steps 1–2 are independent; 3 depends on 1; 4 depends on 1–3; 5 depends on 3; 6 is independent.

  • New migration apps/server/src/db/migrations/013-session-trigger-links.sql (use the next free number if step 14 took 013):
    ALTER TABLE session_triggers ADD COLUMN workspace_id TEXT;
    ALTER TABLE session_triggers ADD COLUMN checkout_path TEXT;
    
    Both are nullable: rows written before this change have neither and keep behaving as today (cwd = workspace path).
  • In apps/server/src/db/session-triggers.ts:
    • Add export interface SessionLinks { workspaceId: string | null; checkoutPath: string | null; }.
    • Change insertSessionTrigger(sessionId, trigger) to insertSessionTrigger(sessionId: string, trigger: SessionTrigger, links: SessionLinks) and write both columns. Update every caller (grep -rn "insertSessionTrigger(" apps/server/src — bridge/in-process.ts createSession and tests).
    • Add export function getSessionLinks(sessionId: string): SessionLinks | null (null when there is no row).
    • Add export function listSessionWorkspaceLinks(): Map<string, string>: session_id → workspace_id for rows with a non-null workspace_id.

2. Clone management module (apps/server/src/forgejo/checkout.ts, new; no equivalent exists)

  • Export readOriginUrl from apps/server/src/forgejo/repo.ts (currently private; it returns origin as git reports it after insteadOf). Reuse it here.
  • export function checkoutRoot(): string → path.join(getDataDir(), "checkouts") (getDataDir from apps/server/src/env-store.ts).
  • export function checkoutPath(owner: string, repo: string, kind: "issue" | "pr", number: number): string → path.join(checkoutRoot(), owner.toLowerCase(), repo.toLowerCase(), \${kind}-${number}`)`.
  • export async function createCheckout(opts: { workspacePath: string; owner: string; repo: string; kind: "issue" | "pr"; number: number }): Promise<string>. Every git call uses runGit (apps/server/src/bridge/git-tools/run.ts) with timeoutMs: NETWORK_TIMEOUT_MS for network calls, and env: prepared.ssh?.env from prepareDeckSsh() (bridge/git-tools/ssh.ts), exactly as git_fetch does in bridge/git-tools/fetch.ts. Any non-zero exit throws new Error(gitErrorText(res, "<step> failed")), after the partial dir has been removed with removeCheckout. Steps:
    1. dest = checkoutPath(...). If dest exists, it is a leftover of a crashed creation (no session owns it, since step 14 only reaches here when the artifact has no session): fs.rmSync(dest, { recursive: true, force: true }). fs.mkdirSync(path.dirname(dest), { recursive: true }).
    2. url = await readOriginUrl(opts.workspacePath); null → throw Error("workspace has no origin remote").
    3. git clone --local --no-checkout <workspacePath> <dest>, run with cwd path.dirname(dest).
    4. In dest: git remote set-url origin <url> (the clone's origin must be the Forgejo remote for resolveRepoFromCwd, assertTriggerRepo, and pushes; the same URL as the workspace means the same credentials).
    5. git fetch --prune origin (replaces the workspace's refs copied by the clone with the real remote's).
    6. PR: git fetch origin refs/pull/<number>/head, then git checkout --detach FETCH_HEAD. Issue: git remote set-head origin --auto, then git checkout --detach refs/remotes/origin/HEAD.
    7. Delete every local branch the clone inherited (it holds the workspace's checked-out branch, which would be stale): list git for-each-ref --format=%(refname) refs/heads/, then git update-ref -d <ref> for each. Branches are created later by the session with git_branch from a detached HEAD.
    8. Return dest.
  • export function removeCheckout(dir: string): void: resolve dir; refuse unless it starts with checkoutRoot() + path.sep (log a warning and return, so a corrupt row can't delete arbitrary paths); otherwise fs.rmSync(dir, { recursive: true, force: true }). Errors are logged (log.warn), not thrown.

3. Create trigger sessions in the clone (dispatcher.ts, bridge)

  • CreateSessionOpts (apps/server/src/bridge/types.ts): add /** Trigger sessions only: links persisted with the trigger row. */ links?: SessionLinks;. In InProcessAgentBridge.createSession pass opts.links ?? { workspaceId: null, checkoutPath: null } to insertSessionTrigger.
  • In the step-14 new-session branch of dispatch, before createSession:
    const ws = workspaces[0]!;
    let cwd: string;
    try {
    	cwd = await createCheckout({ workspacePath: ws.path, owner, repo, kind: row.isPull ? "pr" : "issue", number: row.number });
    } catch (err) {
    	const msg = `could not prepare a checkout: ${err instanceof Error ? err.message : String(err)}`;
    	finishForgejoTrigger(row.id, "failed", msg);
    	await this.surfaceFailure(row, msg, "internal", null);
    	return;
    }
    
    Then createSession({ cwd, trigger, links: { workspaceId: ws.id, checkoutPath: cwd } }). Wrap that call: if it throws, call removeCheckout(cwd) and rethrow (process() already records and surfaces the failure).
  • Events routed by step 14 into an existing session reuse that session and its cwd unchanged.
  • Workspace removal does not delete clones: their sessions survive as persisted JSONL, like other sessions.

4. Workspace association: listing, counting, disposal, permissions

  • Protocol (packages/protocol/src/index.ts): add workspacePath?: string to SessionSummary and to SessionSnapshot, both documented as /** Workspace the session belongs to when its cwd is a Forgejo checkout outside it. */.
  • InProcessAgentBridge.listSessions (bridge/in-process.ts ~345): always load SessionManager.listAll(), then annotate each summary with workspacePath from listSessionWorkspaceLinks() mapped through listWorkspaces() (db/workspaces.ts) id → path (skip unknown ids). When opts.cwd is set, return the summaries where (s.workspacePath ?? s.cwd) === opts.cwd. Keep the existing per-key cache.
  • routes-workspaces.ts GET /workspaces: count by s.workspacePath ?? s.cwd. DELETE /workspaces/:id needs no change: listSessions({ cwd: removed.path }) now also returns linked sessions.
  • Session handle snapshot (in-process.ts ~1100, cwd: this.cwd): add workspacePath. Pass workspacePath: string | null into attach(...) and the handle constructor, resolved in createSession/resumeSession from getSessionLinks(sessionId)?.workspaceId through listWorkspaces(). Only set the field when it is non-null.
  • PermissionFilter (bridge/permission-filter.ts ~171): add optional workspaceId?: string | null to the constructor options; this.workspaceId = opts.workspaceId ?? resolveWorkspaceId(opts.cwd). attach passes the linked workspace id, so workspace-scoped permission rules still apply to sessions in a clone.
  • Web client: replace the exact cwd === workspace.path checks with (s.workspacePath ?? s.cwd) === workspace.path in apps/web/src/components/Sidebar.tsx (~40–53 partition), apps/web/src/components/chat/SessionPicker.tsx (~20–41), and Composer.tsx isOrphan (~75–76, using the snapshot's workspacePath ?? cwd). Add workspacePath?: string to SessionUi (apps/web/src/lib/types.ts ~188) and set it from the snapshot in apps/web/src/lib/reducer.ts (~39–42) next to cwd. ChatHeader.tsx and Inspector.tsx already render session.cwd, so they show the clone path without change.

5. Remove the clone when the session is deleted (apps/server/src/routes.ts DELETE /sessions/:id, ~197)

  • Read const links = getSessionLinks(id); before deleteSessionTrigger(id). After bridge.deleteSession(id) succeeds and the trigger row is deleted, if (links?.checkoutPath) removeCheckout(links.checkoutPath);. On a 404/500 the clone is kept.

6. FIFO per artifact (dispatcher.ts pump, ~141)

  • Change the key to `${row.repo.toLowerCase()}#${row.number}`. Rename running only if its doc mentions repos; update the module doc comment (lines ~4–6) to: "FIFO per artifact (repo#number), at most one running trigger per artifact, different artifacts in parallel."
  • dispatcher.test.ts: replace any test asserting that two triggers of one repo run sequentially with: two triggers on the same repo#number run sequentially; triggers on #1 and #2 of the same repo both reach createSession before either turn ends.

Critical files & anchors

  • apps/server/src/forgejo/dispatcher.ts — pump, and the step-14 new-session branch of dispatch (today createSession ~237).
  • apps/server/src/bridge/in-process.ts — createSession (~167, trigger insert ~224), resumeSession (~241), listSessions (~345), attach (~605), snapshot (~1100).
  • apps/server/src/db/session-triggers.ts — link columns live on the trigger row, so a session can't be resumed with a trigger but without its links.
  • apps/server/src/forgejo/repo.ts — readOriginUrl (export), resolveRepoFromCwd (must resolve in the clone).
  • apps/web/src/components/Sidebar.tsx — workspace grouping.

Verification

  • Unit tests (bun test apps/server/src/forgejo apps/server/src/db apps/server/src/bridge from the repo root):
    • checkout.test.ts (new): create a bare repo plus a work-tree clone of it in a tmp dir as "Forgejo remote" and "workspace", with a ref refs/pull/3/head pushed to the bare repo. Set OMP_DECK_DATA_DIR to a tmp dir. createCheckout({ kind: "pr", number: 3, ... }) → git rev-parse HEAD in the clone equals the pull ref's sha; git remote get-url origin equals the workspace's origin; git for-each-ref refs/heads is empty; and the workspace can check out the same branch at the same time. kind: "issue" → HEAD is the bare repo's default branch tip. A leftover dir at the target path is replaced. removeCheckout("/tmp/x") outside the root leaves /tmp/x in place.
    • session-triggers tests: insert with links → getSessionLinks round-trips; a row without links reads { workspaceId: null, checkoutPath: null }.
    • Dispatcher tests: a new trigger creates the session with cwd under <data>/checkouts/lucas/omp-deck/pr-9 and links.workspaceId = the matched workspace id; createCheckout failure → row failed with could not prepare a checkout: and no session; the per-artifact FIFO cases from step 6.
  • Typecheck: bun run typecheck (or the root package.json typecheck script).
  • End-to-end on the real instance (deck server running, Forgejo configured, this repo registered as a workspace, webhook active):
    1. Check out branch X of an open PR #N in the project dir. Comment @<agent> hi on PR #N → <data dir>/checkouts/lucas/omp-deck/pr-N exists with a detached HEAD at the PR head; the project dir is still on X and unchanged; the session appears under the omp-deck workspace in the sidebar, and its header shows the checkout path; the workspace's session count includes it.
    2. Comment on PR #N and on issue #M at the same time → both sessions start without waiting for each other.
    3. Restart the server and open the session → it resumes (assertTriggerRepo passes in the clone).
    4. Delete the session in the UI → the checkout dir is gone.

Assumptions & contingencies

  • Plan step 14 introduces exactly one place where a new session is created for an artifact. If step 14 instead keeps several sessions per artifact, stop and re-plan the keying; do not share one clone between sessions.
  • Forgejo serves refs/pull/<N>/head for fetch (Gitea lineage, unverified — confirm first with git ls-remote origin 'refs/pull/*/head'). If it does not, fetch the PR's head branch by name from forgejo_read_pr's head ref (dispatch already has a DispatchApi) and check out --detach origin/<head ref>. PRs from forks are then not supported, and fail with the normal failure notice.
  • If the omp SDK's SessionManager.listAll() turns out to be too slow to call on every filtered listing, keep the existing cache and just widen the key; behavior stays the same.
## Problem Webhook-triggered Forgejo sessions run with the registered workspace's project dir as cwd (`dispatcher.ts`: `createSession({ cwd: workspaces[0]!.path, trigger })`). All trigger sessions of a repo and every manual UI session therefore share one working tree and one set of branches. That is why the dispatcher serializes triggers per repo, and why the design's "fetch and checkout PR branch before session starts" precondition would conflict with whatever the user has checked out. Goal: each Forgejo artifact (issue or PR) gets its own clone as its session's cwd, so trigger sessions are isolated from each other and from manual sessions. Then triggers on different artifacts can run in parallel. Decisions taken while researching: - **Clone, not worktree.** Git refuses to check out a branch in two worktrees of one repo, and `--ignore-other-worktrees` shares `refs/heads/<branch>` between them, so a commit in one silently dirties the other. The user must be able to check out the same branch in the project dir and work hands-on. A separate clone has its own refs; the user and the agent collaborate through the remote. - `git clone --local` from the workspace path: objects are hardlinked, so it needs little disk and no network. Never `--shared`/`--reference`: a `gc` in the project dir could prune objects the clone depends on. - **One clone per artifact**, at `<data dir>/checkouts/<owner>/<repo>/<issue|pr>-<number>`, outside the project dir, so it never shows up in the user's git status or searches. - **Lifetime = the artifact's session.** The clone is removed when its session is deleted. There is no sweep for closed artifacts; removal on close/merge comes with plan step 15. - Trigger sessions stay listed **under their workspace**. The UI shows the clone path as the session's cwd, which also marks it as a different kind of session. - The dispatcher queue becomes **FIFO per artifact** (`repo#number`) instead of per repo. - Untracked files of the project dir (`.env`, local overrides, `node_modules`) are not carried into the clone. A follow-up handles this if needed. - **Blocked on plan step 14** (Forgejo session routing: a `forgejo_sessions` table mapping `(repo, artifact_type, artifact_id) → session_id`). Clones are keyed per artifact, which only works once there is exactly one session per artifact. ## Plan ### Context Once plan step 14 routes every event of an artifact into one session, the step-14 code that *creates* a new session for an artifact must first create a dedicated clone and use it as the session's cwd. The session keeps an explicit link to its workspace (for listing, counting, workspace removal, and workspace-scoped permission rules) and to its clone (for removal on delete). The dispatcher's per-repo FIFO becomes per-artifact. ### Approach Prerequisite: plan step 14 is merged. Step 14 is not built yet, so where this plan says "the step-14 new-session branch", it means the place in `ForgejoDispatcher.dispatch` (`apps/server/src/forgejo/dispatcher.ts`) where no existing session is mapped for the artifact and `bridge.createSession` is called. Today that is line ~237. Steps 1–2 are independent; 3 depends on 1; 4 depends on 1–3; 5 depends on 3; 6 is independent. #### 1. Persist workspace and clone links (`session_triggers`) - New migration `apps/server/src/db/migrations/013-session-trigger-links.sql` (use the next free number if step 14 took 013): ```sql ALTER TABLE session_triggers ADD COLUMN workspace_id TEXT; ALTER TABLE session_triggers ADD COLUMN checkout_path TEXT; ``` Both are nullable: rows written before this change have neither and keep behaving as today (cwd = workspace path). - In `apps/server/src/db/session-triggers.ts`: - Add `export interface SessionLinks { workspaceId: string | null; checkoutPath: string | null; }`. - Change `insertSessionTrigger(sessionId, trigger)` to `insertSessionTrigger(sessionId: string, trigger: SessionTrigger, links: SessionLinks)` and write both columns. Update every caller (`grep -rn "insertSessionTrigger(" apps/server/src` — `bridge/in-process.ts` createSession and tests). - Add `export function getSessionLinks(sessionId: string): SessionLinks | null` (null when there is no row). - Add `export function listSessionWorkspaceLinks(): Map<string, string>`: session_id → workspace_id for rows with a non-null `workspace_id`. #### 2. Clone management module (`apps/server/src/forgejo/checkout.ts`, new; no equivalent exists) - Export `readOriginUrl` from `apps/server/src/forgejo/repo.ts` (currently private; it returns origin as git reports it after `insteadOf`). Reuse it here. - `export function checkoutRoot(): string` → `path.join(getDataDir(), "checkouts")` (`getDataDir` from `apps/server/src/env-store.ts`). - `export function checkoutPath(owner: string, repo: string, kind: "issue" | "pr", number: number): string` → `path.join(checkoutRoot(), owner.toLowerCase(), repo.toLowerCase(), \`${kind}-${number}\`)`. - `export async function createCheckout(opts: { workspacePath: string; owner: string; repo: string; kind: "issue" | "pr"; number: number }): Promise<string>`. Every git call uses `runGit` (`apps/server/src/bridge/git-tools/run.ts`) with `timeoutMs: NETWORK_TIMEOUT_MS` for network calls, and `env: prepared.ssh?.env` from `prepareDeckSsh()` (`bridge/git-tools/ssh.ts`), exactly as `git_fetch` does in `bridge/git-tools/fetch.ts`. Any non-zero exit throws `new Error(gitErrorText(res, "<step> failed"))`, after the partial dir has been removed with `removeCheckout`. Steps: 1. `dest = checkoutPath(...)`. If `dest` exists, it is a leftover of a crashed creation (no session owns it, since step 14 only reaches here when the artifact has no session): `fs.rmSync(dest, { recursive: true, force: true })`. `fs.mkdirSync(path.dirname(dest), { recursive: true })`. 2. `url = await readOriginUrl(opts.workspacePath)`; null → throw `Error("workspace has no origin remote")`. 3. `git clone --local --no-checkout <workspacePath> <dest>`, run with cwd `path.dirname(dest)`. 4. In `dest`: `git remote set-url origin <url>` (the clone's origin must be the Forgejo remote for `resolveRepoFromCwd`, `assertTriggerRepo`, and pushes; the same URL as the workspace means the same credentials). 5. `git fetch --prune origin` (replaces the workspace's refs copied by the clone with the real remote's). 6. PR: `git fetch origin refs/pull/<number>/head`, then `git checkout --detach FETCH_HEAD`. Issue: `git remote set-head origin --auto`, then `git checkout --detach refs/remotes/origin/HEAD`. 7. Delete every local branch the clone inherited (it holds the workspace's checked-out branch, which would be stale): list `git for-each-ref --format=%(refname) refs/heads/`, then `git update-ref -d <ref>` for each. Branches are created later by the session with `git_branch` from a detached HEAD. 8. Return `dest`. - `export function removeCheckout(dir: string): void`: resolve `dir`; refuse unless it starts with `checkoutRoot() + path.sep` (log a warning and return, so a corrupt row can't delete arbitrary paths); otherwise `fs.rmSync(dir, { recursive: true, force: true })`. Errors are logged (`log.warn`), not thrown. #### 3. Create trigger sessions in the clone (`dispatcher.ts`, bridge) - `CreateSessionOpts` (`apps/server/src/bridge/types.ts`): add `/** Trigger sessions only: links persisted with the trigger row. */ links?: SessionLinks;`. In `InProcessAgentBridge.createSession` pass `opts.links ?? { workspaceId: null, checkoutPath: null }` to `insertSessionTrigger`. - In the step-14 new-session branch of `dispatch`, before `createSession`: ```ts const ws = workspaces[0]!; let cwd: string; try { cwd = await createCheckout({ workspacePath: ws.path, owner, repo, kind: row.isPull ? "pr" : "issue", number: row.number }); } catch (err) { const msg = `could not prepare a checkout: ${err instanceof Error ? err.message : String(err)}`; finishForgejoTrigger(row.id, "failed", msg); await this.surfaceFailure(row, msg, "internal", null); return; } ``` Then `createSession({ cwd, trigger, links: { workspaceId: ws.id, checkoutPath: cwd } })`. Wrap that call: if it throws, call `removeCheckout(cwd)` and rethrow (`process()` already records and surfaces the failure). - Events routed by step 14 into an existing session reuse that session and its cwd unchanged. - Workspace removal does not delete clones: their sessions survive as persisted JSONL, like other sessions. #### 4. Workspace association: listing, counting, disposal, permissions - Protocol (`packages/protocol/src/index.ts`): add `workspacePath?: string` to `SessionSummary` and to `SessionSnapshot`, both documented as `/** Workspace the session belongs to when its cwd is a Forgejo checkout outside it. */`. - `InProcessAgentBridge.listSessions` (`bridge/in-process.ts` ~345): always load `SessionManager.listAll()`, then annotate each summary with `workspacePath` from `listSessionWorkspaceLinks()` mapped through `listWorkspaces()` (`db/workspaces.ts`) id → path (skip unknown ids). When `opts.cwd` is set, return the summaries where `(s.workspacePath ?? s.cwd) === opts.cwd`. Keep the existing per-key cache. - `routes-workspaces.ts` GET `/workspaces`: count by `s.workspacePath ?? s.cwd`. DELETE `/workspaces/:id` needs no change: `listSessions({ cwd: removed.path })` now also returns linked sessions. - Session handle snapshot (`in-process.ts` ~1100, `cwd: this.cwd`): add `workspacePath`. Pass `workspacePath: string | null` into `attach(...)` and the handle constructor, resolved in `createSession`/`resumeSession` from `getSessionLinks(sessionId)?.workspaceId` through `listWorkspaces()`. Only set the field when it is non-null. - `PermissionFilter` (`bridge/permission-filter.ts` ~171): add optional `workspaceId?: string | null` to the constructor options; `this.workspaceId = opts.workspaceId ?? resolveWorkspaceId(opts.cwd)`. `attach` passes the linked workspace id, so workspace-scoped permission rules still apply to sessions in a clone. - Web client: replace the exact `cwd === workspace.path` checks with `(s.workspacePath ?? s.cwd) === workspace.path` in `apps/web/src/components/Sidebar.tsx` (~40–53 partition), `apps/web/src/components/chat/SessionPicker.tsx` (~20–41), and `Composer.tsx` `isOrphan` (~75–76, using the snapshot's `workspacePath ?? cwd`). Add `workspacePath?: string` to `SessionUi` (`apps/web/src/lib/types.ts` ~188) and set it from the snapshot in `apps/web/src/lib/reducer.ts` (~39–42) next to `cwd`. `ChatHeader.tsx` and `Inspector.tsx` already render `session.cwd`, so they show the clone path without change. #### 5. Remove the clone when the session is deleted (`apps/server/src/routes.ts` DELETE `/sessions/:id`, ~197) - Read `const links = getSessionLinks(id);` **before** `deleteSessionTrigger(id)`. After `bridge.deleteSession(id)` succeeds and the trigger row is deleted, `if (links?.checkoutPath) removeCheckout(links.checkoutPath);`. On a 404/500 the clone is kept. #### 6. FIFO per artifact (`dispatcher.ts` `pump`, ~141) - Change the key to `` `${row.repo.toLowerCase()}#${row.number}` ``. Rename `running` only if its doc mentions repos; update the module doc comment (lines ~4–6) to: "FIFO per artifact (repo#number), at most one `running` trigger per artifact, different artifacts in parallel." - `dispatcher.test.ts`: replace any test asserting that two triggers of one repo run sequentially with: two triggers on the same `repo#number` run sequentially; triggers on `#1` and `#2` of the same repo both reach `createSession` before either turn ends. ### Critical files & anchors - `apps/server/src/forgejo/dispatcher.ts` — `pump`, and the step-14 new-session branch of `dispatch` (today `createSession` ~237). - `apps/server/src/bridge/in-process.ts` — `createSession` (~167, trigger insert ~224), `resumeSession` (~241), `listSessions` (~345), `attach` (~605), snapshot (~1100). - `apps/server/src/db/session-triggers.ts` — link columns live on the trigger row, so a session can't be resumed with a trigger but without its links. - `apps/server/src/forgejo/repo.ts` — `readOriginUrl` (export), `resolveRepoFromCwd` (must resolve in the clone). - `apps/web/src/components/Sidebar.tsx` — workspace grouping. ### Verification - Unit tests (`bun test apps/server/src/forgejo apps/server/src/db apps/server/src/bridge` from the repo root): - `checkout.test.ts` (new): create a bare repo plus a work-tree clone of it in a tmp dir as "Forgejo remote" and "workspace", with a ref `refs/pull/3/head` pushed to the bare repo. Set `OMP_DECK_DATA_DIR` to a tmp dir. `createCheckout({ kind: "pr", number: 3, ... })` → `git rev-parse HEAD` in the clone equals the pull ref's sha; `git remote get-url origin` equals the workspace's origin; `git for-each-ref refs/heads` is empty; and the workspace can check out the same branch at the same time. `kind: "issue"` → HEAD is the bare repo's default branch tip. A leftover dir at the target path is replaced. `removeCheckout("/tmp/x")` outside the root leaves `/tmp/x` in place. - `session-triggers` tests: insert with links → `getSessionLinks` round-trips; a row without links reads `{ workspaceId: null, checkoutPath: null }`. - Dispatcher tests: a new trigger creates the session with `cwd` under `<data>/checkouts/lucas/omp-deck/pr-9` and `links.workspaceId` = the matched workspace id; `createCheckout` failure → row `failed` with `could not prepare a checkout:` and no session; the per-artifact FIFO cases from step 6. - Typecheck: `bun run typecheck` (or the root `package.json` typecheck script). - End-to-end on the real instance (deck server running, Forgejo configured, this repo registered as a workspace, webhook active): 1. Check out branch `X` of an open PR #N in the project dir. Comment `@<agent> hi` on PR #N → `<data dir>/checkouts/lucas/omp-deck/pr-N` exists with a detached HEAD at the PR head; the project dir is still on `X` and unchanged; the session appears under the omp-deck workspace in the sidebar, and its header shows the checkout path; the workspace's session count includes it. 2. Comment on PR #N and on issue #M at the same time → both sessions start without waiting for each other. 3. Restart the server and open the session → it resumes (`assertTriggerRepo` passes in the clone). 4. Delete the session in the UI → the checkout dir is gone. ### Assumptions & contingencies - Plan step 14 introduces exactly one place where a new session is created for an artifact. If step 14 instead keeps several sessions per artifact, stop and re-plan the keying; do not share one clone between sessions. - Forgejo serves `refs/pull/<N>/head` for fetch (Gitea lineage, `unverified — confirm first` with `git ls-remote origin 'refs/pull/*/head'`). If it does not, fetch the PR's head branch by name from `forgejo_read_pr`'s head ref (`dispatch` already has a `DispatchApi`) and check out `--detach origin/<head ref>`. PRs from forks are then not supported, and fail with the normal failure notice. - If the omp SDK's `SessionManager.listAll()` turns out to be too slow to call on every filtered listing, keep the existing cache and just widen the key; behavior stays the same.
lucas closed this issue 2026-10-19 14:09:37 +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#33
No description provided.