Skip to content

feat: Forgejo backend with one-job runners and idle timeout - #38

Merged
jfro merged 18 commits into
mainfrom
feat/forgejo-backend
Oct 20, 2026
Merged

jfro merged 18 commits into
mainfrom
feat/forgejo-backend

Conversation

@jfro

@jfro jfro commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

Steps 3–5 of the forge work (docs/forge-support.md): kuiper-forge can poll a Forgejo 15+ instance and run its jobs on single-use runners on proxmox Linux VMs. Draft for early review.

What's in it

  • Config: [[forges]] entries with type = "forgejo", url, a token (token_env / token_path / token) and scopes (orgs or owner/repo). Polling mode only: startup rejects a Forgejo default forge in fixed-capacity mode and a Forgejo webhook forge.
  • ForgejoClient (kuiper-forge/src/forgejo.rs): lists …/actions/runners/jobs per scope and keeps the waiting ones, keyed by handle, with cached repo names. Registers ephemeral runners, removes runners by name (paged), and maps waiting/running to queued/in_progress for the fleet manager. Treats Forgejo's null empty-list response as empty.
  • Forge trait: create_job_runner replaces generate_jitconfig and returns a RunnerLaunch (GitHub JIT config or Forgejo runner). runner_kind says which runner program a forge needs.
  • Protocol: adds CreateRunnerCommand.forgejo (ForgejoRunner: url, uuid, token, handle; boxed) and AgentStatus.runner_kinds. Forgejo jobs only go to agents that list forgejo, so older agents never get one.
  • Proxmox agent: installs forgejo-runner (checksum-checked; vm.forgejo_runner_version, default latest, at least 13.1.0), writes the token to a mode-600 file, and runs one-job --handle … --wait with label:host labels. Windows VMs fail clearly. The tart agent rejects Forgejo commands.
  • Renames: scan_github → scan_forges, recover_queued_jobs_from_github → recover_queued_jobs, verify_pending_jobs_with_github → verify_pending_jobs, and forge-neutral log messages.

Idle runner timeout (step 4)

Forgejo keeps a job held back by a concurrency group as waiting in the jobs API, so its one-job runner sits idle (and one for a cancelled job waits forever).

  • Setting: [provisioning] idle_runner_timeout_secs (default 600, minimum 120, 0 disables).
  • The check: every tick, Forgejo runners older than the timeout are checked. If the job is running, the runner is left alone. If it's still waiting or gone, the coordinator sends DestroyRunner.
  • Backoff: the job backs off before getting a new runner (the timeout, doubling, at most 1h), without using up a retry. The destroyed VM's events aren't recorded as failures.
  • GitHub JIT runners aren't touched: they can take any matching queued job.
  • Proxmox agent: DestroyRunner accepts the VM name, and the Destroyed event names the runner.

Fix: grace period for missing VMs

An agent drops a finished VM from its status at about the time it reports the outcome. The coordinator reconciles status immediately while the outcome event waits in the fleet queue, so finished runners often looked lost. The result was a synthetic "VM missing" Destroyed event: a RunnerLost failure, a used-up retry, the agent steered away from, and the real Completed event ignored. Affects GitHub runners too. reconcile_agent_vms now waits MISSING_VM_GRACE (20s) before reporting a runner as missing.

Startup token check

Forgejo picks a token's scope level by HTTP method, so a read-only token could list jobs and pass startup, then fail on every runner registration. Startup now also deletes runner id 0, which can't exist. The answer is 404 when the token may manage runners and 403 when it may not; in that case startup stops with the "must own the scope" hint. Same behaviour on 16.0.5 and 17.0.0.

forgejo-runner builds for every platform

Upstream only releases Linux binaries, so .github/workflows/forgejo-runner.yml builds the unmodified upstream tag for linux amd64/arm64, darwin arm64/amd64 and windows amd64, with checksums, plus the upstream source and license (GPLv3). It publishes them as a not-latest forgejo-runner-v<version> release. Agents install a pinned version (13.2.0) from there. Proxmox gets vm.forgejo_runner_url for mirrors, and latest means the pinned version. The install script uses shasum -a 256 on macOS. First release: forgejo-runner-v13.2.0.

macOS (tart) and Windows (proxmox) Forgejo runners: ready for testing on real hosts

  • tart: installs forgejo-runner (the darwin or linux build by guest OS; shasum on macOS), prepares the job, and runs it through a wrapper with the GitHub runner's protocol. On macOS it's launched in Terminal.app, like GitHub jobs. The agent advertises forgejo. Waiting for a runner now stops as soon as its VM is destroyed, instead of polling the gone VM for up to 6 hours (this affects GitHub runners too).
  • proxmox Windows: PowerShell install (Invoke-WebRequest, then Get-FileHash against the release .sha256), a token file and run-job.ps1, and run. Everything goes via -EncodedCommand, so cmd.exe and PowerShell SSH shells both work. Errors print as one line instead of CLIXML.
  • Tested here:
    • Windows scripts under PowerShell 7.6 for Linux: install, a bad checksum, and a real Forgejo 17 job end to end.
    • The real tart agent on Linux, with a fake tart and SSH-reachable "VMs": it installed from the published release, ran jobs, and handled idle teardown and retry.
  • Confirmed on a real host (2026-10-20): a basic Forgejo job ran on a real tart Mac (darwin build from the release, Terminal.app launch), with the coordinator polling a real Forgejo.
  • Not tested yet: a job that needs the macOS GUI session (code signing), idle teardown on a real host, and anything on a real Windows VM (Windows binary, PowerShell 5.1, cmd.exe SSH shell). docs/forge-support.md has a plan for testing on real hosts.

Merged with main (release-plz, packaging move, agent maintenance toggle #40)

  • ForgejoClient implements the new Forge::remove_idle_runner. One-job runners are never idle pool runners, so a registered one is reported Busy and kept.
  • The maintenance drain and the idle-runner teardown are independent: the drain only touches pool runners (no job), the idle teardown only touches job-bound Forgejo runners, and each tracks the runners it destroyed.
  • Fixes a drain bug on main: the drain sends the VM name in DestroyRunner, but main's proxmox agent only accepts a numeric VMID. So a drained proxmox VM isn't destroyed, and stays up until stale-VM cleanup (2h by default). This branch's proxmox agent accepts the name. Tart was unaffected.

Forgejo user and instance scopes

  • scopes = ["@jfro", "lodge"]: a user's own repos plus an org's, with one token (write:user + write:organization + read:repository). scopes = ["*"]: the whole instance, with a site admin's token (write:admin + read:repository). A user scope only covers repos the user owns, not its orgs'.
  • RunnerScope gains User and Instance. GitHub forges refuse them.
  • Startup checks: a @user scope must be the token's own user, and org/user/instance scopes need read:repository.
  • Tested end to end with both setups on Forgejo 16.0.5 (12/12) and 17.0.0 (13/13).

[runner_scope] only in fixed-capacity mode

Only fixed-capacity pools use it, so it's optional now, and fixed-capacity mode without one fails at startup.

Docs

required_labels applies to every forge: GitHub adds self-hosted automatically, Forgejo doesn't, so workflows on both need the required labels.

Testing

  • 245 tests pass (30 new on this branch).
  • End to end on Forgejo 16.0.5 (12/12) and 17.0.0 (13/13) in Docker. Setup: two Forgejo forges on one instance (an org scope with an org-owner token, a repo scope with a repo-limited token), required_labels = ["self-hosted"], and a 120s idle timeout. Checks:
    • a read-only token stops startup
    • a needs: chain succeeds
    • a failing job gets one runner and no retries
    • an unlabelled job isn't taken
    • in one concurrency group: the held run is torn down, backs off and succeeds; the cancelled held run is torn down as gone
    • on 17, a rerun gets a new handle and its own runner
    • one runner per job attempt, no lost runners or errors, no runners left registered Clippy is clean for the SQLite and Postgres builds; fmt is clean.
  • End to end against a local Forgejo 17.0.0 (Docker): the coordinator in polling mode plus a throwaway agent on the real agent protocol, running the agent library's install, prepare and run commands in a local shell. A one-job workflow and a two-job needs: workflow each got exactly one runner per job, all jobs succeeded, and nothing was left over.
  • Idle timeout, end to end: two runs in one concurrency group, with a 120s timeout. The second run's runner was destroyed after 134s with its job still waiting. The job backed off 120s, then got a new runner once the first run finished, and succeeded.
  • Missing-VM grace, end to end: the agent dropped the VM from status before sending Completed. The coordinator waited, and Completed arrived about 10 ms later. Both jobs were plain successes, with no failures recorded.
  • Not yet tested: the proxmox agent on a real Proxmox VM (running a job, and DestroyRunner by name).

Open points

  • required_labels is global and defaults to ["self-hosted"]; Forgejo setups need to set it (e.g. []). Should it be per forge?
  • Idle-timeout bookkeeping is in memory: after a restart, a held-back job may get one extra runner before the timeout catches it.

jfro added 3 commits October 18, 2026 10:30
- [[forges]] type = "forgejo" (url, token_env/token_path/token, scopes), polling mode
- ForgejoClient: polls org/repo job lists, keys jobs by handle, registers ephemeral
  runners, maps waiting/running to queued/in_progress, treats null lists as empty
- Forge::create_job_runner returns RunnerLaunch (GitHub JIT or Forgejo runner);
  Forge::runner_kind picks agents that advertise the runner program
- proto: CreateRunnerCommand.forgejo (ForgejoRunner), AgentStatus.runner_kinds
- proxmox agent installs forgejo-runner (sha256-checked) and runs one-job --handle
  with label:host labels; tart agent rejects Forgejo commands
- rename GitHub-specific scan/verify names, forge-neutral log messages
- docs: forge-support status, config template, example agent config
A job held back by a concurrency group stays "waiting" in Forgejo's jobs API,
so its one-job runner sits idle (and one for a cancelled job waits forever).

- [provisioning] idle_runner_timeout_secs (default 600, min 120, 0 disables)
- fleet checks job-bound Forgejo runners past the timeout each tick: running ->
  leave alone; still waiting or gone -> DestroyRunner, job backs off (timeout,
  doubling, max 1h) without spending a retry; late events aren't failures
- proxmox agent: DestroyRunner accepts the VM name, Destroyed event names the runner
- docs: forge-support step 4
An agent drops a finished VM from its status at about the time it reports the
runner's outcome. The coordinator reconciles status as soon as it arrives while
the outcome event waits in the fleet manager's queue, so a finished runner
often looked lost first: a synthetic Destroyed ("VM missing during
reconciliation") recorded a RunnerLost failure, spent a job retry, steered the
job away from the agent, and the real Completed event was then ignored.

reconcile_agent_vms now reports a runner only after its VM has been missing for
MISSING_VM_GRACE (20s).
@jfro jfro changed the title feat: Forgejo backend with one-job runners on proxmox Linux VMs feat: Forgejo backend with one-job runners and idle timeout Oct 18, 2026
jfro added 15 commits October 18, 2026 12:14
Forgejo picks a token's scope level by HTTP method, so a read-only token
passed the startup check (listing jobs) and only failed later when registering
runners. validate() now also deletes runner id 0, which can't exist: 404 when
the token may manage runners, 403 when it may not.

docs: step 5 results against Forgejo 16.0.5 and 17.0.0
Upstream only releases Linux binaries. A forgejo-runner workflow builds the
unmodified upstream tag for linux amd64/arm64, darwin arm64/amd64 and windows
amd64 with checksums, the upstream source and license (GPLv3), as a not-latest
forgejo-runner-v<version> release.

Agents now install a pinned forgejo-runner (13.2.0) from those releases;
proxmox gets vm.forgejo_runner_url (URL template) for mirrors, and "latest"
means the pinned version. The install script falls back to shasum on macOS.
tart: install forgejo-runner (darwin/linux build by guest OS, shasum on macOS),
prepare the job, and run it through a wrapper with the GitHub runner's protocol,
launched in Terminal.app on macOS like GitHub jobs. Advertise "forgejo".
Waiting for a runner now stops as soon as its VM is destroyed instead of
polling the gone VM until the 6-hour limit.

proxmox Windows: PowerShell install (Invoke-WebRequest + Get-FileHash against
the release .sha256, removed again if --version fails), token file + run-job.ps1,
and run, all via -EncodedCommand so cmd.exe and PowerShell SSH shells both work.
Errors are caught and printed as one line instead of CLIXML.

docs: macOS/Windows runners, what was tested here, and a plan for testing on
real hosts
Brings in release-plz, the packaging move, the russh bump and the agent
maintenance toggle (#40).

Conflicts resolved by keeping both sides:
- forge.rs: RunnerKind/RunnerLaunch/ForgejoLaunch alongside IdleRemoval
- fleet.rs: idle-runner teardown state alongside the drain state; imports
- agent_registry.rs: select_agent_for_kind test alongside the unavailable-agent
  tests (rebuilt whole from each side)

ForgejoClient implements the new Forge::remove_idle_runner: one-job runners are
never idle pool runners, so a registered one is reported Busy and kept.
scopes = ["@jfro", "lodge"] covers a user's own repos plus an org's with one
token (write:user + write:organization + read:repository); scopes = ["*"]
covers the whole instance with a site admin's token (write:admin +
read:repository). A user scope only covers repos the user owns, not its orgs'.

- RunnerScope gains User and Instance (stored as {"type":"user"} /
  {"type":"instance"}); GitHub forges refuse them
- Forgejo uses /user and /admin endpoints for them; job status is asked at the
  owning org/user scope, else the instance, else the repo
- overlapping scopes list a job once
- startup checks: a @user scope must be the token's own user, and org/user/
  instance scopes need read:repository (repo names), probed via repository 0
- docs: scopes and tokens table
It's only read by fixed-capacity pools; on-demand runners register where their
job is. A polling-only config (e.g. just a Forgejo forge) no longer needs a
placeholder, and fixed-capacity mode without one now fails at startup with a
clear error instead of being impossible to express.
An idle teardown (DestroyRunner by name) frees the VMID while the runner's
lifecycle task is still waiting on the job. Its cleanup then destroyed by
bare VMID, which could stop and delete another runner's VM cloned into the
reused VMID.

destroy_vm now takes the VM's name and only goes ahead if this agent still
tracks the VMID under that name and no other destroy has claimed it, and
checks Proxmox's name for the VMID before stopping and deleting it.
… torn down

A runner command (forgejo-runner one-job, or the GitHub runner) blocked on
an SSH session with no keepalive, so after an idle teardown destroyed the VM
it could wait forever and never report Completed/Failed.

Sessions now send a keepalive every 30s and drop after 3 missed, and the
runner command is raced against the VM leaving this agent's tracking (or
starting to be destroyed), like the tart agent's vm_gone.
The proxmox and tart agents debug-logged every SSH command in full, and the
forgejo-runner job-file write, config.sh and the GitHub JIT config write
carry the runner/registration token. Those now go through execute_secret /
ssh_exec_secret, which log what the command is and its size, not its text.
${{ inputs.version }} was expanded straight into the bash script, so a
crafted workflow_dispatch input could run commands with contents: write.
It now comes in through INPUT_VERSION. actions/setup-go is pinned to the
v6.5.0 commit instead of the movable v6 tag.
list_runners stopped at the first page shorter than the 50 it asked for, but
Forgejo caps pages at MAX_RESPONSE_ITEMS, which an admin can set below 50.
Then only the first page was ever read and runners past it were never found
or removed. Stop on an empty page instead.
max_vms is INT4 on Postgres, and reading it as i64 panicked in get_agent and
list_agents (the agents page, test_agent_version_reported_and_cleared).
Read it as i32, which also fits SQLite's INTEGER, with try_get: a bad value
is logged with the agent id and the row skipped instead of panicking.
@jfro
jfro marked this pull request as ready for review October 20, 2026 20:59
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-20T21:04:20.060809Z 5f8acd2 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5f8acd2a07

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +514 to +518
Err(e) => {
warn!(
"Couldn't look up repository {} for Forgejo job {}: {:#}",
job.repo_id, job.handle, e
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip jobs whose repository lookup failed

When /repositories/{id} fails transiently for an organization, user, or instance scope, this branch still queues the job with repository = None. check_idle_runners explicitly skips runners without a repository, so a Forgejo job held in waiting can occupy an agent indefinitely instead of being reclaimed after idle_runner_timeout_secs. Skip this job for the current scan, or fail the scope, so it is retried once its required repository metadata can be resolved.

Useful? React with 👍 / 👎.

@jfro

jfro commented Oct 20, 2026

Copy link
Copy Markdown
Member Author

Follow-ups from the review, filed for after merge:

@jfro
jfro merged commit 9932ccf into main Oct 20, 2026
3 checks passed
@jfro
jfro deleted the feat/forgejo-backend branch October 20, 2026 21:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant