Tasks flow (write-capable)
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.
- Deployment switch. Set
TASK_API_ENABLED=1on the web app. - 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. - OIDC audience. Callers must mint their OIDC token with the
audvalue serge expects (TASK_OIDC_AUDIENCE, defaultserge).
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_blob→create_tree→create_commit→create_ref→create_pull_request), notgit 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_prmode 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(default5) 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_RESERVEis left, recordingstop_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:
- Original CI failure — the failure group, the failing test, and the
traceback CI actually produced, in a collapsed
<details>block. - 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. - The model’s explanation of the root cause and the patch.
- Verified on GPU — the runs that failed before and passed after, when the
GPU verify gate (
VERIFY_ON_GPU) is on. - Possibly related — existing issues/PRs in the target repo that mention the
failing test, found with one
GET /search/issuescall 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.
test_links — where the dispatcher observes its failures
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). |