- Shell 100%
|
Some checks failed
coordinate.yaml / Merge pull request 'docs: record why the issue/label agent pipeline was abandoned' (#28) from docs/abandonment-rationale into main (push) Failing after 0s
dev.yaml / Merge pull request 'docs: record why the issue/label agent pipeline was abandoned' (#28) from docs/abandonment-rationale into main (push) Failing after 0s
plan.yaml / Merge pull request 'docs: record why the issue/label agent pipeline was abandoned' (#28) from docs/abandonment-rationale into main (push) Failing after 0s
reflect.yaml / Merge pull request 'docs: record why the issue/label agent pipeline was abandoned' (#28) from docs/abandonment-rationale into main (push) Failing after 0s
test.yaml / Merge pull request 'docs: record why the issue/label agent pipeline was abandoned' (#28) from docs/abandonment-rationale into main (push) Failing after 0s
Reviewed-on: #28 |
||
|---|---|---|
| .forgejo/workflows | ||
| agents | ||
| learnings | ||
| lib | ||
| CHANGELOG.md | ||
| cleanup-old-labels.sh | ||
| DECISIONS.md | ||
| labels.json | ||
| README.md | ||
| seed-org-labels.sh | ||
agent-workflows
Abandoned (2026-09-29). The issue/label-driven agent automation this repo implemented is no longer used to automate development work. No repo calls the dispatcher any more: the last frozen callers were removed on 2026-09-29 (this repo's own in #26). The workflows below are kept for reference only and do nothing unless a repo starts calling them again. Why: DECISIONS.md.
This was the central home for the Claude agent automation across the Forgejo orgs on
git.valssi.eu (valssi, valssi-ai, valssi-public). It is a workflow library, not
a deployed service. Each participating repo carried a frozen caller that called back
into this repo's reusable workflows at @main. The workflows ran as Forgejo Actions on
an act_runner labelled devagent.
- What changed and when → CHANGELOG.md
- Why it is built this way → DECISIONS.md
Current state
| Item | State |
|---|---|
Per-repo callers (.forgejo/workflows/agent.yaml / caller-agent.yaml) |
Removed from every repo. The 15-minute (later 30-minute) schedule tick no longer fires. |
dispatch.yaml, tick.yaml and the route workflows |
Still in the tree, but workflow_call only, so nothing triggers them. |
dind-prune.yaml |
Still live. It is this repo's own weekly schedule (Mondays 04:00) and prunes the devagent runner's dind. That runner still serves ordinary CI for other repos (see valssi-ai/forgejo-ai-dev-runner). |
Org labels (labels.json) |
Still seeded in the orgs. Nothing reads them or sets them any more. |
Org variables (AGENTS_ENABLED, APPROVER, …) and route secrets |
Still set where they were. Nothing reads them. |
Do not onboard a repo. Copying a caller back in would re-activate the whole pipeline for that repo, including the scheduled tick.
How it worked (historical)
The rest of this file describes the pipeline as it ran up to 2026-09-29. It is kept so that labels, state comments and PR history in the estate can still be understood. None of it is current behaviour.
What runs
| Path | Role |
|---|---|
.forgejo/workflows/agent.yaml (per repo) |
No longer exists anywhere. Callers were removed estate-wide by 2026-09-29. The frozen caller each participating repo carried; forwards every event and the */15 schedule tick to dispatch.yaml and does nothing else. |
.forgejo/workflows/dispatch.yaml |
Central router. Resolves the tier→model matrix and routes labeled/scheduled events to the matching agent workflow; drains the defer queue and posts the epic digest on the schedule tick. |
.forgejo/workflows/plan.yaml |
Planning agent (workflow_call). |
.forgejo/workflows/dev.yaml |
Implementation agent (workflow_call) — serves both the first build (plan/approved) and dev↔test bounces (agent/dev). |
.forgejo/workflows/test.yaml |
Test/review agent (workflow_call), triggered on a PR. |
.forgejo/workflows/coordinate.yaml |
Coordinator agent (workflow_call) — breaks an epic into child issues. |
.forgejo/workflows/reflect.yaml |
Post-merge reflection agent (workflow_call) — distils lessons into docs PRs. |
.forgejo/workflows/dind-prune.yaml |
Weekly docker system prune on the runner (Mondays 04:00). |
agents/ |
Declarative contract per agent role: summon label, identity, token, model default, handoff. See agents/README.md. |
lib/ |
Shared composite actions every route calls: preflight, state, thread. See lib/README.md. |
learnings/ |
Operational log of gotchas hit while building and running the automation. See learnings/README.md. |
labels.json |
Canonical /-scoped label taxonomy. |
seed-org-labels.sh |
Applies labels.json to one or more Forgejo orgs, idempotently. |
cleanup-old-labels.sh |
Deletes the retired :-scheme labels (agent:*, plan:approved, …) from an org or repo. |
Features
Central dispatch
project repo .forgejo/workflows/agent.yaml ← frozen caller, copied once, never edited
│ (subscribes to issue/PR events + 15-min cron, secrets: inherit)
▼
agent-workflows .forgejo/workflows/dispatch.yaml ← the router: ALL routing lives here
│ routes on the inherited event / schedule
├─▶ plan.yaml (label: agent/plan)
├─▶ dev.yaml (label: plan/approved, by vars.APPROVER — or agent/dev)
├─▶ test.yaml (PR opened/synchronized on an agent/issue-N branch, or agent/test)
├─▶ coordinate.yaml (label: agent/coordinate, by vars.APPROVER)
├─▶ reflect.yaml (a PR merges, or label: agent/reflect)
└─▶ tick (schedule: */15 — drains the defer queue, posts the epic digest)
The caller knows nothing about agents, labels or models — it just forwards every
event to the dispatcher. dispatch.resolve runs on every event that reaches it
(unconditionally, so a skipped job can never leave route jobs' with: block the
whole run) and, for the six command labels, looks up the governing issue's tier/*
label and turns it into a --model and, for the local rung, a LiteLLM gateway base
URL. Route jobs themselves gate on if: — actor, label, event shape — not on inputs.
The dev↔test loop
agent/plan → plan comment + needs/approval → human sets plan/approved
(vars.APPROVER only) → dev builds on branch agent/issue-N, opens a PR, labels it
agent/test → test builds and reviews the PR: pass → needs/review for a human
merge; fail → findings comment + loop/N bump + re-applies agent/dev on the
issue. agent/dev can also be applied directly to an issue that never went through
planning — the issue text alone frames the work. A dev run that produces no new
commit parks straight to needs/human instead of looping.
Tier ladder and escalation
Four model rungs plus one excluded from automation: tier/local (LiteLLM gateway,
no subscription quota), tier/haiku, tier/sonnet, tier/opus. A tier/* label on
the governing issue overrides each route's role default (plan/coordinate/reflect
default opus; dev/test default sonnet). At loop/3 the current tier's three
dev↔test bounces are spent: preflight bumps one rung, resets to loop/1 and retries;
at the top rung (or a non-ladder tier) it parks to needs/human with an attempt
summary instead. tier/fable is recognised but never invoked by a workflow —
resolve falls back to sonnet if it is ever seen on a command-label event.
Safety gates (lib/preflight)
First step of every agent job, in order: kill switches (vars.AGENTS_ENABLED != 'true', or the issue carries ai/off) → night fence (run/night + outside
vars.NIGHT_WINDOW, Europe/Helsinki) → caps (loop/3 escalation, or
max-attempts-24h attempts in the state comment) → actor guard (command label
applied by someone other than vars.APPROVER or an authorized bot PAT) → usage
gate (session/weekly Claude subscription utilization over threshold, skipped for
tier/local). A block always exits the job green with proceed=false — it defers
(blocked/usage, blocked/night, resumable) or parks (needs/human, a human must
clear it) but never fails the job outright.
Conversation-aware prompts (lib/thread)
Every model-using route renders the human side of the issue/PR comment thread into
its prompt, oldest-first, with the instruction that later comments override earlier
ones, the approved plan and the test findings. Agent artifacts (plans, findings,
results) are elided to one-line markers — the routes that need them fetch the full
text directly — and the machine <!-- agent-state --> / <!-- agent-digest -->
comments are dropped entirely.
Shared per-issue state (lib/state)
One <!-- agent-state --> JSON comment per issue: tier, loop, origin_label,
deferred_reason, resume_after, and an attempts[] array (agent, model,
run_url, outcome, ended). Every route appends an attempt on the way out;
preflight reads it for the 24h attempt cap and the loop/tier state.
Queue drain and epic digest (tick)
The 15-minute schedule tick (forwarded from every repo's caller) finds this repo's
open blocked/usage / blocked/night issues and PRs, keeps the ones whose gate has
re-opened, and resumes up to vars.QUEUE_BATCH of them FIFO by deferral time —
dropping the blocked/* label and re-applying the origin_label so the route
re-fires. In vars.EPIC_REPO (default valssi/ops) it also posts one status comment
per open epic per UTC day: children by state/*, queue depth, attempts in the last
24h, and open needs/*.
Coordination and reflection
coordinate (APPROVER-only, on an epic issue) breaks a goal into child issues across
valssi repos, mirrors the epic's milestone into each, and may kick off planning on
the first ready child. reflect fires when a PR merges (or on agent/reflect):
docs-only, it distils durable lessons into a PR against valssi/docs
(reference/*.md + a CHANGELOG line) and optionally valssi-public/agent-workflows
agent-def updates, then ticks the source issue's line in its parent epic's task list.
Both targets are allowlisted in the workflow itself, not left to the model.
Concurrency
Each route declares a concurrency: group: agent-issue-<repo>-<issue-or-PR> (or
agent-reflect-… for reflect), so runs against the same issue or PR serialize —
a bounce cannot start while a build for that issue is still in flight. Different
issues run in parallel, bounded only by the runner's own capacity.
Label taxonomy (labels.json)
All labels are /-scoped; agent/* is exclusive (only one agent summoned per issue
at a time).
| Scope | Labels | Meaning |
|---|---|---|
agent/* |
plan, dev, test, coordinate, reflect, triage |
Summon a route. One-shot: the agent removes it on pickup. |
plan/approved |
— | Human approval gate; honored only when applied by vars.APPROVER. |
needs/* |
approval, review, input, human |
Agent → human: something needs attention. |
state/* |
planning, building, testing, passed, blocked |
Machine-managed, mutually exclusive lifecycle state. |
tier/* |
local, haiku, sonnet, opus, fable |
Model rung. fable is human-applied only. |
loop/* |
1, 2, 3 |
Dev↔test bounce counter at the current tier. |
run/night |
— | Start only inside NIGHT_WINDOW. |
blocked/* |
usage, night |
Queued: the gate will re-open; the tick scheduler resumes it. |
ai/off |
— | Agents ignore this issue entirely. |
seed-org-labels.sh applies labels.json to one or more orgs (create-or-patch,
idempotent — safe to re-run whenever the file changes); it needs an admin PAT with
write:organization, since the bot PATs deliberately cannot manage the label schema.
cleanup-old-labels.sh deletes the superseded :-scheme names
(agent:plan, plan:approved, ai:off, …) from a named org or repo.
Operational learnings
learnings/ is a running log of gotchas from building and
running this automation — distinct from this README (the architecture) and from
valssi/docs (the design rationale). Current entries:
- pushing-files-via-forgejo-api.md — base64-encode straight from disk when pushing a file through the Forgejo contents API; never hand-transcribe the blob.
Configuration
Org-level Forgejo variables (Settings → Actions → Variables), read by dispatch.yaml
and lib/preflight:
| Variable | Meaning |
|---|---|
AGENTS_ENABLED |
Master kill switch. Preflight compares it to the literal string true — anything else, including unset, is a silent no-op. |
APPROVER |
The human login allowed to set plan/approved, agent/coordinate, and to act as an authorized actor on guarded routes. |
NIGHT_WINDOW |
HH:MM-HH:MM in Europe/Helsinki for run/night work. Empty disables the night fence. |
QUEUE_BATCH |
Deferred items the schedule tick resumes per run. Defaults to 2. |
LITELLM_URL / LITELLM_MODEL |
Gateway base URL and model alias for the tier/local rung. Either unset falls back to haiku. |
EPIC_REPO |
Where epics live, for the tick digest and reflect's epic-tick step. Defaults to valssi/ops. |
Secrets, passed down via secrets: inherit from the caller through dispatch.yaml
to each route:
| Secret | Used for |
|---|---|
CLAUDE_CODE_OAUTH_TOKEN |
Claude subscription auth for the claude CLI; also read by preflight's usage gate. |
CLAUDE_PLAN_PAT |
Planner identity (claude-plan) — issue/PR read-write, no code-write. Used by plan, test, coordinate. |
CLAUDE_DEV_PAT |
Dev identity (claude-dev) — code-write. Used by dev, reflect. |
LITELLM_KEY |
Optional virtual key for the LiteLLM gateway; only consumed on the tier/local rung. |
Onboarding a new repo
Historical. Do not follow; see Current state.
- Copy
caller-agent.yamlto the repo as.forgejo/workflows/agent.yaml. - Provide the secrets the agents need (org- or repo-level):
CLAUDE_CODE_OAUTH_TOKEN,CLAUDE_PLAN_PAT,CLAUDE_DEV_PAT, and theAPPROVERvariable (plus the optional vars above). - Make sure the org's labels are current:
ADMIN_PAT=... ./seed-org-labels.sh <org>. - Done — the repo now follows the central dispatcher forever; changes to any route here reach it on the next event, no redeploy needed.
Operating
- Add or change agent behaviour: edit the route's workflow here (or
dispatch.yamlfor routing/gating changes) and merge tomain— every subscribed repo picks it up on its next event or the next*/15tick. - A repo looks like it's ignoring labels: check
vars.AGENTS_ENABLEDis exactlytrueon that org — preflight's kill switch fails silently by design, and the only trace is apreflight: AGENTS_ENABLED != trueline in the run log. - Re-seed labels after editing
labels.json:ADMIN_PAT=... ./seed-org-labels.sh <org> [<org> ...](create-or-patch, idempotent). - Retire old labels:
ADMIN_PAT=... ./cleanup-old-labels.sh org:<name>/repo:<owner>/<name>. - Inspect an issue's agent state: the
<!-- agent-state -->comment (machine- managed — don't hand-edit it) showstier,loop, any deferral, and the attempt history. - Something's stuck at
needs/human: read the parking comment for the reason (attempt cap, loop cap at the top tier, or a hard job failure), clear the blocker, and re-apply the relevantagent/*label to resume.