feat(forgejo): run trigger sessions in per-artifact clones #35

Merged
lucas merged 10 commits from 33-forgejo-run-trigger-sessions-in-isolated-per-artifact-clones into main 2026-10-19 14:09:37 +02:00
Collaborator

A new Forgejo trigger session now runs in its own git clone --local of the workspace, at <data dir>/checkouts/<owner>/<repo>/<issue|pr>-<n>. The clone is detached at the PR head or the remote's default branch and has no local branches. The session stores links to its workspace and checkout in session_triggers, so it is still listed and counted under its workspace and keeps that workspace's permission rules. The dispatcher queue is FIFO per artifact, so different issues and PRs run in parallel.

An artifact keeps a single conversation: a trigger on an archived mapped session unarchives and continues it instead of replacing it. Archiving a session deletes its clone, deleting a session removes it, and resuming a session whose clone is missing re-creates it.

Decisions

  • Presence hold removed entirely: forgejo_held/forgejo_activity frames, forgejo_inject, ForgejoBanners, and the hold logic are gone. Legacy mapped sessions that have no links and run in the workspace dir are still reused, but no longer held.
  • DeckPresence is reduced to openSessionIds(). It only decides whether a session the dispatcher brought live gets disposed after its turn.
  • The migration is 015-session-trigger-links.sql, because 013 and 014 were taken.
  • reuseMapped expects the session's linked checkoutPath as its cwd, falling back to the workspace path for legacy sessions.
  • Archived mapped sessions are continued: the dispatcher unarchives the session before resuming it, and re-archives it if it turns out unusable (gone, unresumable, wrong cwd) and gets replaced.
  • Archive deletes the clone unconditionally, including uncommitted or unpushed work in it. A live session is disposed first so it doesn't keep running in a deleted directory.
  • Resume re-creates a missing clone at the session's recorded path (restoreCheckout). The new clone starts fresh at the PR head or default branch, not where the session left off. Resume fails if the linked workspace no longer exists.
  • A checkout failure becomes the trigger's internal failure, reason could not prepare a checkout: …. No session is created.
  • ForgejoDispatcherDeps.createCheckout is an optional test seam; it defaults to the real implementation.
  • listSessions({ cwd }) now filters SessionManager.listAll() by workspacePath ?? cwd instead of calling SessionManager.list(cwd).
  • findForgejoSessionArtifact was removed, since only inject used it.
  • Checkout file operations use async fs.promises calls; removeCheckout is now async.

Verification

  • bun run typecheck: protocol, server and web all pass.
  • bun test: 1036 pass, 0 fail. New tests: checkout.test.ts runs against real git (PR ref, issue default branch, origin URL, no local branches, the same branch checked out in clone and workspace, leftover replacement, partial clone cleanup, refusal outside the root, re-creation of a missing clone, an existing clone left untouched, refusal of a mismatched path). Also link round-trips in session-triggers.test.ts, and dispatcher tests for the clone cwd and links, checkout failure, per-artifact FIFO, parallel artifacts of one repo, no hold for open workspace sessions, an open mapped session staying live, an archived mapped session being unarchived and continued, and an archived session that is gone staying archived and being replaced.
  • Not tested: the archive route's clone deletion (no route test harness), and the end-to-end check on the real Forgejo instance, including whether it serves refs/pull/<n>/head.

Closes #33

A new Forgejo trigger session now runs in its own `git clone --local` of the workspace, at `<data dir>/checkouts/<owner>/<repo>/<issue|pr>-<n>`. The clone is detached at the PR head or the remote's default branch and has no local branches. The session stores links to its workspace and checkout in `session_triggers`, so it is still listed and counted under its workspace and keeps that workspace's permission rules. The dispatcher queue is FIFO per artifact, so different issues and PRs run in parallel. An artifact keeps a single conversation: a trigger on an archived mapped session unarchives and continues it instead of replacing it. Archiving a session deletes its clone, deleting a session removes it, and resuming a session whose clone is missing re-creates it. ## Decisions - **Presence hold removed entirely**: `forgejo_held`/`forgejo_activity` frames, `forgejo_inject`, `ForgejoBanners`, and the hold logic are gone. Legacy mapped sessions that have no links and run in the workspace dir are still reused, but no longer held. - `DeckPresence` is reduced to `openSessionIds()`. It only decides whether a session the dispatcher brought live gets disposed after its turn. - The migration is `015-session-trigger-links.sql`, because 013 and 014 were taken. - `reuseMapped` expects the session's linked `checkoutPath` as its cwd, falling back to the workspace path for legacy sessions. - **Archived mapped sessions are continued**: the dispatcher unarchives the session before resuming it, and re-archives it if it turns out unusable (gone, unresumable, wrong cwd) and gets replaced. - **Archive deletes the clone unconditionally**, including uncommitted or unpushed work in it. A live session is disposed first so it doesn't keep running in a deleted directory. - **Resume re-creates a missing clone** at the session's recorded path (`restoreCheckout`). The new clone starts fresh at the PR head or default branch, not where the session left off. Resume fails if the linked workspace no longer exists. - A checkout failure becomes the trigger's internal failure, reason `could not prepare a checkout: …`. No session is created. - `ForgejoDispatcherDeps.createCheckout` is an optional test seam; it defaults to the real implementation. - `listSessions({ cwd })` now filters `SessionManager.listAll()` by `workspacePath ?? cwd` instead of calling `SessionManager.list(cwd)`. - `findForgejoSessionArtifact` was removed, since only inject used it. - Checkout file operations use async `fs.promises` calls; `removeCheckout` is now async. ## Verification - `bun run typecheck`: protocol, server and web all pass. - `bun test`: 1036 pass, 0 fail. New tests: `checkout.test.ts` runs against real git (PR ref, issue default branch, origin URL, no local branches, the same branch checked out in clone and workspace, leftover replacement, partial clone cleanup, refusal outside the root, re-creation of a missing clone, an existing clone left untouched, refusal of a mismatched path). Also link round-trips in `session-triggers.test.ts`, and dispatcher tests for the clone cwd and links, checkout failure, per-artifact FIFO, parallel artifacts of one repo, no hold for open workspace sessions, an open mapped session staying live, an archived mapped session being unarchived and continued, and an archived session that is gone staying archived and being replaced. - Not tested: the archive route's clone deletion (no route test harness), and the end-to-end check on the real Forgejo instance, including whether it serves `refs/pull/<n>/head`. Closes #33
Refs #33

Co-Authored-By: Claude Opus 5.5 via omp-deck
Refs #33

Co-Authored-By: Claude Opus 5.5 via omp-deck
New trigger sessions get a fresh clone of the workspace as cwd, so they
no longer share the project dir's working tree and branches. The queue
is now FIFO per artifact instead of per repo, and the presence hold
(held-trigger banners, inject, activity frames) is removed: it only
existed to keep unattended turns out of the shared working tree.

Refs #33

Co-Authored-By: Claude Opus 5.5 via omp-deck
Sessions and snapshots carry `workspacePath` when the session runs in a
Forgejo checkout linked to a workspace. Listing, workspace session
counts, sidebar/picker grouping and the orphan check use it, and the
permission filter applies the linked workspace's rules.

Refs #33

Co-Authored-By: Claude Opus 5.5 via omp-deck
Refs #33

Co-Authored-By: Claude Opus 5.5 via omp-deck
@ -0,0 +53,4 @@
// The caller only gets here when no session owns this artifact, so an
// existing directory is a leftover of a crashed creation.
fs.rmSync(dest, { recursive: true, force: true });
fs.mkdirSync(path.dirname(dest), { recursive: true });
Owner

This is an async function, why the two sync calls?

This is an async function, why the two sync calls?
Author
Collaborator

Fixed in 48a2b87: createCheckout and removeCheckout now use fs.promises.rm/mkdir, and removeCheckout is async and awaited at every call site.

Fixed in 48a2b87: `createCheckout` and `removeCheckout` now use `fs.promises.rm`/`mkdir`, and `removeCheckout` is async and awaited at every call site.
Refs #33

Co-Authored-By: Claude Opus 5.5 via omp-deck
Refs #33

Co-Authored-By: Claude Opus 5.5 via omp-deck
A live session is disposed first so it doesn't run in a deleted
directory; resuming re-creates the clone.

Refs #33

Co-Authored-By: Claude Opus 5.5 via omp-deck
An artifact keeps one conversation: a trigger on an archived mapped
session unarchives and resumes it. It is re-archived if it turns out
unusable and gets replaced.

Refs #33

Co-Authored-By: Claude Opus 5.5 via omp-deck
lucas requested changes 2026-10-19 11:14:14 +02:00
Dismissed
lucas left a comment

I don't think "link" is a great word for here. It is really generic and only carries meaning if you consistently mention both sides, e.g. "link A to B". Throughout the new code, the word "link" never manages to give me any meaningful info.

I don't think "link" is a great word for here. It is really generic and only carries meaning if you consistently mention both sides, e.g. "link A to B". Throughout the new code, the word "link" never manages to give me any meaningful info.
Author
Collaborator

Re review 57: agreed, "links" said nothing on its own. Fixed in 7a137b3. The pair (owning workspace, checkout the session runs in) is now a SessionPlacement:

  • SessionLinks → SessionPlacement
  • getSessionLinks → getSessionPlacement
  • listSessionWorkspaceLinks → listSessionWorkspaces
  • CreateSessionOpts.links → placement

The comments were reworded to match ("owning workspace", "no checkout").

I kept the migration's filename 015-session-trigger-links.sql and only reworded its comment. The runner records applied migrations by filename, so renaming the file would re-run the ADD COLUMNs on databases that already have them, and those would fail.

Re review 57: agreed, "links" said nothing on its own. Fixed in 7a137b3. The pair (owning workspace, checkout the session runs in) is now a `SessionPlacement`: - `SessionLinks` → `SessionPlacement` - `getSessionLinks` → `getSessionPlacement` - `listSessionWorkspaceLinks` → `listSessionWorkspaces` - `CreateSessionOpts.links` → `placement` The comments were reworded to match ("owning workspace", "no checkout"). I kept the migration's filename `015-session-trigger-links.sql` and only reworded its comment. The runner records applied migrations by filename, so renaming the file would re-run the `ADD COLUMN`s on databases that already have them, and those would fail.
`SessionLinks` → `SessionPlacement`, `getSessionLinks` → `getSessionPlacement`,
`listSessionWorkspaceLinks` → `listSessionWorkspaces`, `CreateSessionOpts.links`
→ `placement`. The migration file keeps its name: renaming it would re-run it
on databases that already applied it.

Co-Authored-By: Claude Opus 5.5 via omp-deck
lucas approved these changes 2026-10-19 14:09:30 +02:00
lucas merged commit 800c407826 into main 2026-10-19 14:09:37 +02:00
lucas deleted branch 33-forgejo-run-trigger-sessions-in-isolated-per-artifact-clones 2026-10-19 14:09:37 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
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!35
No description provided.