The tasks flow lets a GitHub Actions job ask serge to produce a change on the repository — open a pull request with a fix, or push a follow-up commit onto an existing serge-authored fix branch. It is the write-capable counterpart to the read-only reviewer.

The canonical use case: CI runs a test suite, the tests fail, and the workflow sends the failure report to serge, which opens a PR with a proposed fix. CI then runs again on that PR. serge never runs the test suite itself — it is a stateless patch producer; verification stays in the caller’s CI.

CI runs tests  ──fail──▶  POST /tasks { instruction, context: <report> }
                              │
                              ▼
        serge: checkout base → agentic loop → patch → open PR  serge/fix-<id>
                              │
                              ▼
        CI runs on the PR  ──still failing?──▶  POST /tasks { output.mode: existing_pr, pr_number }
                              │
                              ▼
        serge: amend the fix branch with another commit

This endpoint is served by the web app deployment (reviewbot-web) and is off by default. See Security and Security architecture for the trust model.

Enabling it

The tasks flow is a privilege escalation over the read-only reviewer: it requires the backing GitHub App to hold Contents: write and Pull Requests: write, and it is gated three ways.

  1. Deployment switch. Set TASK_API_ENABLED=1 on the web app.
  2. Per-repo opt-in. In /admin, the repo’s provider config must have “Enable write-capable tasks” checked. A config without it authorizes read-only reviews only.
  3. OIDC audience. Callers must mint their OIDC token with the aud value serge expects (TASK_OIDC_AUDIENCE, default serge).

Authentication: GitHub Actions OIDC

Authentication is GitHub Actions OIDC — there is no shared secret. The calling workflow mints a short-lived, GitHub-signed JWT and serge verifies it against GitHub’s JWKS (iss / aud / exp / signature). serge authorizes the task on the token’s repository claim and will only ever act on that repo. A leaked token is useless within minutes and is scoped to a single repository.

Calling workflow

name: Auto-fix failing tests
on:
  workflow_run:
    workflows: [CI]
    types: [completed]

permissions:
  id-token: write   # required to mint the OIDC token

jobs:
  fix:
    if: github.event.workflow_run.conclusion == 'failure'
    runs-on: ubuntu-latest
    steps:
      - name: Request a fix from serge
        run: |
          TOKEN=$(curl -sSf \
            -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=serge" | jq -r .value)

          curl -sSf -X POST "$SERGE_URL/tasks" \
            -H "Authorization: Bearer $TOKEN" \
            -H "Content-Type: application/json" \
            -d @- <<JSON
          {
            "repo": "${{ github.repository }}",
            "base_ref": "${{ github.event.workflow_run.head_branch }}",
            "instruction": "Fix the failing tests described below.",
            "context": $(jq -Rs . < test-report.txt),
            "output": { "mode": "new_pr", "branch_prefix": "serge/fix" }
          }
          JSON
        env:
          SERGE_URL: https://serge.example.com

API

POST /tasks
Authorization: Bearer <github-actions-oidc-jwt>
Content-Type: application/json
Field Required Description
repo optional owner/name. If present, must match the OIDC repository claim (the claim is authoritative).
base_ref optional Branch the work starts from in new_pr mode. Default main.
instruction required Trusted intent from the workflow, e.g. “Fix the failing tests below.”
context optional The failure report / logs. Untrusted — treated as data, fed to the prompt, never as instructions.
output.mode optional new_pr (default) or existing_pr.
output.pr_number required for existing_pr The serge-authored fix PR to push onto.
output.title optional PR title. The LLM proposes one if omitted.
output.branch_prefix optional new_pr branch prefix. Must live in the serge/ namespace. Default serge/fix.

Response 202:

{ "id": "<job id>", "repo": "owner/name", "mode": "new_pr", "url": "/tasks/owner/name/<id>" }

Follow the run live at url (SSE console) in a browser, the same machinery the review pages use.

How serge writes safely

  • The LLM only proposes a patch (a unified diff plus a PR title/body). serge applies it, commits, and opens the PR itself — the model never touches push credentials. Same trust pattern as reviews (the LLM proposes comments; serge publishes).
  • Commits go through the GitHub Git Data API (create_blobcreate_treecreate_commitcreate_refcreate_pull_request), not git push. The installation token never enters the sandbox or the worktree’s git remote, which stays network-isolated exactly as it is for reviews.
  • Branch-ownership guard. existing_pr mode is valid only for serge-owned fix branches (serge/*). serge never pushes to an arbitrary head branch a caller names.
  • Follow-up loop cap. serge counts its own commits on a fix branch and stops after TASK_MAX_FOLLOWUPS (default 5) so a misconfigured workflow cannot burn tokens forever.
  • Untrusted context. The context/logs are a prompt-injection vector (same class as a PR body). The prompt marks them untrusted, the model can only emit a patch, and the result is a PR a human reviews before merge.

The runner’s wall-clock budget

TASK_RUNNER_TIMEOUT is the task Job’s activeDeadlineSeconds, so it is a hard kill: when it expires Kubernetes stops the pod wherever it happens to be, and whatever the runner was holding goes with it. That is not hypothetical — job d2c24049 (2026-09-16, transformers#48881) finished its agent loop, produced a patch that applied cleanly plus a PR title and body, entered the normalize step at 00:06:03, and was killed at 00:11:22 with 5m19s of a 30-minute normalize still to run. serge recorded task runner exited without reporting (exit code 1), the triage issue showed ⚠️ task failed, and the two qwen3_omni_moe tests stayed unfixed. The work was finished; only the clock was not.

So the runner bounds its own steps against the budget it has left rather than against its own configured timeouts (reviewbot/budget.py):

  • The agent loop stops itself once less than TASK_TAIL_RESERVE is left, recording stop_reason="deadline", and asks for a final answer without tools — the same shape as the input-token cap. It stops while there is still time to land what it has, which is the whole point.
  • The repo normalizer’s timeout is cut to what the budget covers, and with nothing left it is skipped entirely and the patch is accepted un-normalized. That is the outcome an unavailable normalizer already produced, and CI still catches what it would have. A dead pod produces nothing.
  • The GPU verify and reproduce polls are cut the same way (they already were, for verify). A poll that outlives the runner is not a slow poll — it is a lost job.
  • A fresh cycle is not started if it cannot finish. A GPU-verify retry round or the next candidate group needs the tail reserve plus ten minutes of real loop time; below that the round already in hand is kept instead of being replaced by one whose loop would end on iteration 1.

TASK_TAIL_RESERVE defaults to TASK_NORMALIZE_TIMEOUT + 180, derived rather than written down so raising the normalize timeout cannot silently leave the loop running past the point where its own patch can still be normalized and pushed. The budget is armed only inside a runner pod, so the legacy in-process worker (TASK_EXECUTION=inprocess) is unbounded exactly as before.

What the PR body says

serge wraps the model’s explanation in the evidence a reviewer needs to judge it without opening the CI logs:

  1. Original CI failure — the failure group, the failing test, and the traceback CI actually produced, in a collapsed <details> block.
  2. Where to watch it — the links the dispatcher sent in test_links (see below) for the tests this PR fixes, typically a per-test dashboard view. Omitted when the task carried none.
  3. The model’s explanation of the root cause and the patch.
  4. Verified on GPU — the runs that failed before and passed after, when the GPU verify gate (VERIFY_ON_GPU) is on.
  5. Possibly related — existing issues/PRs in the target repo that mention the failing test, found with one GET /search/issues call at PR-open time. It is a keyword match, labelled as such: serge does not check that they share a root cause, and it drops its own earlier task PRs. A failed or rate-limited search silently renders nothing rather than holding up the PR.

serge does not know which dashboard a caller watches its CI in, and deliberately holds no monitoring config of its own. A task may therefore carry an optional node-id-keyed map of finished links, which serge renders (and only renders):

"test_links": {
  "tests/models/foo/test_modeling_foo.py::FooIntegrationTest::test_bar": [
    {"label": "Dashboard", "url": "https://grafana.example/d/…?var-test_nodeid=…"}
  ]
}

Keyed by node-id because one task can carry several candidate failure groups and the PR fixes only the group serge’s patch matched — links for the others would be noise. Validated on the way in (http(s) only, single-line labels, capped counts); anything malformed is dropped, never fatal. Absent field, no section.

Configuration

Env var Default Description
TASK_API_ENABLED false Master switch for POST /tasks.
TASK_OIDC_ISSUER https://token.actions.githubusercontent.com OIDC issuer (override for GHES).
TASK_OIDC_AUDIENCE serge aud value serge requires on the token.
TASK_LLM_MAX_TOKENS unset Task-only completion-token cap. Unset means tasks use LLM_MAX_TOKENS; normal reviews are unchanged.
TASK_LLM_MAX_INPUT_TOKENS unset Task-only cumulative input-token cap. Unset means tasks use LLM_MAX_INPUT_TOKENS; normal reviews are unchanged.
TASK_TOOL_MAX_ITERATIONS unset Task-only tool-loop cap. Unset means tasks use TOOL_MAX_ITERATIONS; normal reviews are unchanged.
TASK_MAX_FOLLOWUPS 5 Max serge-authored commits per fix branch. Set 0 to disable the cap.
TASK_MAX_WORKERS 2 Concurrent task workers (separate pool from reviews).