Forgejo: run trigger sessions in isolated per-artifact clones #33
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#33
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 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:
--ignore-other-worktreessharesrefs/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 --localfrom the workspace path: objects are hardlinked, so it needs little disk and no network. Never--shared/--reference: agcin the project dir could prune objects the clone depends on.<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.repo#number) instead of per repo..env, local overrides,node_modules) are not carried into the clone. A follow-up handles this if needed.forgejo_sessionstable 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 andbridge.createSessionis 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)apps/server/src/db/migrations/013-session-trigger-links.sql(use the next free number if step 14 took 013): Both are nullable: rows written before this change have neither and keep behaving as today (cwd = workspace path).apps/server/src/db/session-triggers.ts:export interface SessionLinks { workspaceId: string | null; checkoutPath: string | null; }.insertSessionTrigger(sessionId, trigger)toinsertSessionTrigger(sessionId: string, trigger: SessionTrigger, links: SessionLinks)and write both columns. Update every caller (grep -rn "insertSessionTrigger(" apps/server/src—bridge/in-process.tscreateSession and tests).export function getSessionLinks(sessionId: string): SessionLinks | null(null when there is no row).export function listSessionWorkspaceLinks(): Map<string, string>: session_id → workspace_id for rows with a non-nullworkspace_id.2. Clone management module (
apps/server/src/forgejo/checkout.ts, new; no equivalent exists)readOriginUrlfromapps/server/src/forgejo/repo.ts(currently private; it returns origin as git reports it afterinsteadOf). Reuse it here.export function checkoutRoot(): string→path.join(getDataDir(), "checkouts")(getDataDirfromapps/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 usesrunGit(apps/server/src/bridge/git-tools/run.ts) withtimeoutMs: NETWORK_TIMEOUT_MSfor network calls, andenv: prepared.ssh?.envfromprepareDeckSsh()(bridge/git-tools/ssh.ts), exactly asgit_fetchdoes inbridge/git-tools/fetch.ts. Any non-zero exit throwsnew Error(gitErrorText(res, "<step> failed")), after the partial dir has been removed withremoveCheckout. Steps:dest = checkoutPath(...). Ifdestexists, 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 }).url = await readOriginUrl(opts.workspacePath); null → throwError("workspace has no origin remote").git clone --local --no-checkout <workspacePath> <dest>, run with cwdpath.dirname(dest).dest:git remote set-url origin <url>(the clone's origin must be the Forgejo remote forresolveRepoFromCwd,assertTriggerRepo, and pushes; the same URL as the workspace means the same credentials).git fetch --prune origin(replaces the workspace's refs copied by the clone with the real remote's).git fetch origin refs/pull/<number>/head, thengit checkout --detach FETCH_HEAD. Issue:git remote set-head origin --auto, thengit checkout --detach refs/remotes/origin/HEAD.git for-each-ref --format=%(refname) refs/heads/, thengit update-ref -d <ref>for each. Branches are created later by the session withgit_branchfrom a detached HEAD.dest.export function removeCheckout(dir: string): void: resolvedir; refuse unless it starts withcheckoutRoot() + path.sep(log a warning and return, so a corrupt row can't delete arbitrary paths); otherwisefs.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;. InInProcessAgentBridge.createSessionpassopts.links ?? { workspaceId: null, checkoutPath: null }toinsertSessionTrigger.dispatch, beforecreateSession: ThencreateSession({ cwd, trigger, links: { workspaceId: ws.id, checkoutPath: cwd } }). Wrap that call: if it throws, callremoveCheckout(cwd)and rethrow (process()already records and surfaces the failure).4. Workspace association: listing, counting, disposal, permissions
packages/protocol/src/index.ts): addworkspacePath?: stringtoSessionSummaryand toSessionSnapshot, 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 loadSessionManager.listAll(), then annotate each summary withworkspacePathfromlistSessionWorkspaceLinks()mapped throughlistWorkspaces()(db/workspaces.ts) id → path (skip unknown ids). Whenopts.cwdis set, return the summaries where(s.workspacePath ?? s.cwd) === opts.cwd. Keep the existing per-key cache.routes-workspaces.tsGET/workspaces: count bys.workspacePath ?? s.cwd. DELETE/workspaces/:idneeds no change:listSessions({ cwd: removed.path })now also returns linked sessions.in-process.ts~1100,cwd: this.cwd): addworkspacePath. PassworkspacePath: string | nullintoattach(...)and the handle constructor, resolved increateSession/resumeSessionfromgetSessionLinks(sessionId)?.workspaceIdthroughlistWorkspaces(). Only set the field when it is non-null.PermissionFilter(bridge/permission-filter.ts~171): add optionalworkspaceId?: string | nullto the constructor options;this.workspaceId = opts.workspaceId ?? resolveWorkspaceId(opts.cwd).attachpasses the linked workspace id, so workspace-scoped permission rules still apply to sessions in a clone.cwd === workspace.pathchecks with(s.workspacePath ?? s.cwd) === workspace.pathinapps/web/src/components/Sidebar.tsx(~40–53 partition),apps/web/src/components/chat/SessionPicker.tsx(~20–41), andComposer.tsxisOrphan(~75–76, using the snapshot'sworkspacePath ?? cwd). AddworkspacePath?: stringtoSessionUi(apps/web/src/lib/types.ts~188) and set it from the snapshot inapps/web/src/lib/reducer.ts(~39–42) next tocwd.ChatHeader.tsxandInspector.tsxalready rendersession.cwd, so they show the clone path without change.5. Remove the clone when the session is deleted (
apps/server/src/routes.tsDELETE/sessions/:id, ~197)const links = getSessionLinks(id);beforedeleteSessionTrigger(id). Afterbridge.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.tspump, ~141)`${row.repo.toLowerCase()}#${row.number}`. Renamerunningonly if its doc mentions repos; update the module doc comment (lines ~4–6) to: "FIFO per artifact (repo#number), at most onerunningtrigger 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 samerepo#numberrun sequentially; triggers on#1and#2of the same repo both reachcreateSessionbefore either turn ends.Critical files & anchors
apps/server/src/forgejo/dispatcher.ts—pump, and the step-14 new-session branch ofdispatch(todaycreateSession~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
bun test apps/server/src/forgejo apps/server/src/db apps/server/src/bridgefrom 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 refrefs/pull/3/headpushed to the bare repo. SetOMP_DECK_DATA_DIRto a tmp dir.createCheckout({ kind: "pr", number: 3, ... })→git rev-parse HEADin the clone equals the pull ref's sha;git remote get-url originequals the workspace's origin;git for-each-ref refs/headsis 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/xin place.session-triggerstests: insert with links →getSessionLinksround-trips; a row without links reads{ workspaceId: null, checkoutPath: null }.cwdunder<data>/checkouts/lucas/omp-deck/pr-9andlinks.workspaceId= the matched workspace id;createCheckoutfailure → rowfailedwithcould not prepare a checkout:and no session; the per-artifact FIFO cases from step 6.bun run typecheck(or the rootpackage.jsontypecheck script).Xof an open PR #N in the project dir. Comment@<agent> hion PR #N →<data dir>/checkouts/lucas/omp-deck/pr-Nexists with a detached HEAD at the PR head; the project dir is still onXand 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.assertTriggerRepopasses in the clone).Assumptions & contingencies
refs/pull/<N>/headfor fetch (Gitea lineage,unverified — confirm firstwithgit ls-remote origin 'refs/pull/*/head'). If it does not, fetch the PR's head branch by name fromforgejo_read_pr's head ref (dispatchalready has aDispatchApi) and check out--detach origin/<head ref>. PRs from forks are then not supported, and fail with the normal failure notice.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.