Details and log output for this agent run.
{"path":".","recursive":true}
{"command":"cat /event | head -50"}
{"id":45,"url":"https://code.stdio.dk/api/v1/repos/abrander/zoo/issues/16","html_url":"https://code.stdio.dk/abrander/zoo/issues/16","number":16,"user":{"id":1,"login":"abrander","login_name":"","source_id":0,"full_name":"Anders Brander","email":"anders@brander.dk","html_url":"https://code.stdio.dk/abrander","avatar_url":"https://code.stdio.dk/avatar/d87ec4c86bb6c9d188d5e32e2424583d","language":"en-US","is_admin":true,"last_login":"2026-08-23T02:40:28+02:00","created":"2025-03-27T21:24:00+01:00","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"Real human :-)\r\n\r\nI manage all this. Talk to me if you get stuck.","visibility":"limited","followers_count":0,"following_count":0,"starred_repos_count":0},"original_author":"","original_author_id":0,"title":"Let us have per agent tokens","body":"This Sudo: shit is driving me insane. Let's have a token for each agent and skip the global Forgejo token. Then we can give the agents the token, and they can just be themselfes.","ref":"","labels":[],"milestone":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-08-23T03:59:43+02:00","updated_at":"2026-08-23T03:59:43+02:00","closed_at":null,"due_date":null,"pull_request":null,"repository":{"id":6,"name":"zoo","owner":"abrander","full_name":"abrander/zoo"}}
{"command":"ls -la /project && git -C /project log --oneline -10 && git -C /project branch -a"}
total 52
drwxrwxr-x 5 1000 1000 4096 Aug 23 01:59 .
drwxr-xr-x 1 root root 4096 Aug 23 01:59 ..
drwxrwxr-x 8 1000 1000 4096 Aug 23 01:59 .git
-rw-rw-r-- 1 1000 1000 22 Aug 23 01:59 .gitignore
drwxrwxr-x 3 1000 1000 4096 Aug 23 01:59 cmd
-rw-rw-r-- 1 1000 1000 2628 Aug 23 01:59 go.mod
-rw-rw-r-- 1 1000 1000 16578 Aug 23 01:59 go.sum
drwxrwxr-x 11 1000 1000 4096 Aug 23 01:59 internal
-rw-rw-r-- 1 1000 1000 1415 Aug 23 01:59 zoo.hcl.example
809faf8 (HEAD -> issue-16-leon, origin/main, origin/HEAD, main) Less agents in example.
c3edf5a Add live streaming of job spying.
7e30176 Merge pull request 'Better container names: zoo-issue-{index}-{agent}' (#7) from issue-6-greg into main
1e3eb24 Add meaningful container names to Docker container creation
17cd966 Try to not let the bash tool get stuck on pager.
387906c Do not put the secret token inside the fricking sandbox.
ec7c296 More job logging.
1fff760 Try to reap jobs if stuck.
d1315ed redesign: modern dark-themed UI for zoo dashboard
9e06288 Init.
* issue-16-leon
main
remotes/origin/HEAD -> origin/main
remotes/origin/main
{"command":"find /project -type f -name \"*.go\" | head -50 && echo \"---\" && cat /project/zoo.hcl.example && echo \"---\" && cat /project/.gitignore"}
/project/cmd/zoo/main.go
/project/internal/config/config.go
/project/internal/config/config_test.go
/project/internal/web/templates.go
/project/internal/web/web_test.go
/project/internal/web/web.go
/project/internal/livelog/hub.go
/project/internal/livelog/hub_test.go
/project/internal/tools/forgejo.go
/project/internal/tools/grep_search.go
/project/internal/tools/context.go
/project/internal/tools/remove_file.go
/project/internal/tools/registry.go
/project/internal/tools/write_file.go
/project/internal/tools/move_file.go
/project/internal/tools/read_file.go
/project/internal/tools/git.go
/project/internal/tools/list_files.go
/project/internal/tools/bash.go
/project/internal/tools/shell.go
/project/internal/tools/tools_test.go
/project/internal/llm/streamer_test.go
/project/internal/llm/client.go
/project/internal/llm/streamer.go
/project/internal/llm/types.go
/project/internal/store/store.go
/project/internal/store/store_test.go
/project/internal/forgejo/poll.go
/project/internal/forgejo/watcher.go
/project/internal/forgejo/webhook_test.go
/project/internal/forgejo/webhook.go
/project/internal/forgejo/client.go
/project/internal/forgejo/event.go
/project/internal/agentrun/forgejo.go
/project/internal/agentrun/roster_test.go
/project/internal/agentrun/context.go
/project/internal/agentrun/loop.go
/project/internal/agentrun/clone.go
/project/internal/agentrun/docker.go
/project/internal/agentrun/seed_test.go
/project/internal/agentrun/docker_integration_test.go
/project/internal/agentrun/run.go
/project/internal/agentrun/roster.go
/project/internal/scheduler/scheduler.go
/project/internal/scheduler/scheduler_test.go
---
# zoo.hcl - Configuration for the zoo daemon.
#
# Copy this file to zoo.hcl (gitignored) and fill in real values.
llm "Qwen 3.6" {
openai = "https://code.stdio.dk"
token = "TOKEN"
model = "Qwen3.6"
}
llm "Qwen 3.8" {
openai = "https://code.stdio.dk"
token = "TOKEN"
model = "Qwen3.8"
}
forgejo {
url = "https://code.stdio.dk"
token = "EXAMPLE_TOKEN"
# Shared secret configured on the Forgejo webhook. Required to accept
# webhook deliveries; polling works without it.
webhook_secret = "EXAMPLE_WEBHOOK_SECRET"
}
max_live_agents = 5
environment {
docker_image = "golang:latest"
}
agent "anna" {
llm = "Qwen 3.6"
}
agent "greg" {
llm = "Qwen 3.8"
}
# "leon" doubles as the expected Forgejo assignee username for
# issue:assigned events - see the event block below.
agent "leon" {
llm = "Qwen 3.8"
}
agent "mika" {
llm = "Qwen 3.6"
}
event "issue:new" {
agent = "leon"
}
event "issue:assigned" {
// No `agent` here: the agent whose name matches the Forgejo
// assignee's username is triggered.
instructions = "Please handle this issue."
}
event "issue:comment" {
agent = "leon"
instructions = "Please review what this comment says and respond appropriately."
}
event "pr:new" {
agent = "greg"
instructions = "Please review this pull request and respond appropriately."
}
---
/zoo
/zoo.hcl
/zoo.db
{"path":"/project/internal/config/config.go","start_line":1,"end_line":400}
ERROR CALLING TOOL: read "/project/internal/config/config.go": command exited with status 1
{"path":"/project/internal/forgejo/client.go","start_line":1,"end_line":200}
ERROR CALLING TOOL: read "/project/internal/forgejo/client.go": command exited with status 1
{"command":"wc -l /project/internal/config/config.go /project/internal/forgejo/client.go /project/internal/tools/forgejo.go /project/internal/agentrun/run.go /project/internal/agentrun/context.go /project/cmd/zoo/main.go /project/internal/scheduler/scheduler.go /project/internal/agentrun/roster.go"}
191 /project/internal/config/config.go 232 /project/internal/forgejo/client.go 175 /project/internal/tools/forgejo.go 308 /project/internal/agentrun/run.go 54 /project/internal/agentrun/context.go 131 /project/cmd/zoo/main.go 240 /project/internal/scheduler/scheduler.go 105 /project/internal/agentrun/roster.go 1436 total
{"end_line":191,"path":"/project/internal/config/config.go","start_line":1}
ERROR CALLING TOOL: read "/project/internal/config/config.go": command exited with status 1
{"command":"cat /project/internal/config/config.go"}
// Package config loads and validates zoo's HCL configuration file.
package config
import (
"fmt"
"github.com/hashicorp/hcl/v2/hclsimple"
)
// Known event kinds. issue:assigned is resolved dynamically (agent name
// must match the Forgejo assignee's username) so it never carries an
// `agent` attribute; the rest map statically to one configured agent.
const (
EventIssueNew = "issue:new"
EventIssueComment = "issue:comment"
EventIssueAssigned = "issue:assigned"
EventPRNew = "pr:new"
)
var staticEventKinds = map[string]bool{
EventIssueNew: true,
EventIssueComment: true,
EventPRNew: true,
}
type Config struct {
LLMs []LLM `hcl:"llm,block"`
Forgejo Forgejo `hcl:"forgejo,block"`
MaxLive int `hcl:"max_live_agents"`
Environment Environment `hcl:"environment,block"`
Agents []Agent `hcl:"agent,block"`
Events []Event `hcl:"event,block"`
Web *Web `hcl:"web,block"`
}
// Web configures the dashboard's optional bearer-token gate. Leave the
// block out of zoo.hcl entirely to run without one (fine on localhost;
// put a real gate or a proxy in front for anything else).
type Web struct {
Token string `hcl:"token,optional"`
}
type LLM struct {
Name string `hcl:"name,label"`
OpenAI string `hcl:"openai"`
Token string `hcl:"token"`
Model string `hcl:"model"`
}
type Forgejo struct {
URL string `hcl:"url"`
Token string `hcl:"token"`
WebhookSecret string `hcl:"webhook_secret,optional"`
}
type Environment struct {
DockerImage string `hcl:"docker_image"`
}
type Agent struct {
Name string `hcl:"name,label"`
LLM string `hcl:"llm"`
}
type Event struct {
Kind string `hcl:"name,label"`
Agent string `hcl:"agent,optional"`
Instructions string `hcl:"instructions,optional"`
}
// Load reads and validates the config file at path.
func Load(path string) (*Config, error) {
var cfg Config
if err := hclsimple.DecodeFile(path, nil, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
return &cfg, nil
}
// Validate checks that the config is internally consistent: every
// reference between blocks resolves, and required values are set.
func (c *Config) Validate() error {
llmNames := make(map[string]bool, len(c.LLMs))
for _, l := range c.LLMs {
if l.OpenAI == "" || l.Token == "" || l.Model == "" {
return fmt.Errorf("llm %q: openai, token, and model are required", l.Name)
}
llmNames[l.Name] = true
}
if c.Forgejo.URL == "" || c.Forgejo.Token == "" {
return fmt.Errorf("forgejo: url and token are required")
}
if c.MaxLive < 1 {
return fmt.Errorf("max_live_agents must be >= 1, got %d", c.MaxLive)
}
if c.Environment.DockerImage == "" {
return fmt.Errorf("environment: docker_image is required")
}
agentNames := make(map[string]bool, len(c.Agents))
for _, a := range c.Agents {
if !llmNames[a.LLM] {
return fmt.Errorf("agent %q: references undeclared llm %q", a.Name, a.LLM)
}
agentNames[a.Name] = true
}
seenEventKinds := make(map[string]bool, len(c.Events))
for _, e := range c.Events {
if seenEventKinds[e.Kind] {
return fmt.Errorf("event %q: declared more than once", e.Kind)
}
seenEventKinds[e.Kind] = true
switch {
case e.Kind == EventIssueAssigned:
if e.Agent != "" {
return fmt.Errorf("event %q: must not set agent; the agent whose name matches the Forgejo assignee's username is triggered dynamically", e.Kind)
}
case staticEventKinds[e.Kind]:
if e.Agent == "" {
return fmt.Errorf("event %q: agent is required", e.Kind)
}
if !agentNames[e.Agent] {
return fmt.Errorf("event %q: references undeclared agent %q", e.Kind, e.Agent)
}
default:
return fmt.Errorf("event %q: unknown event kind", e.Kind)
}
}
return nil
}
// AgentByName returns the agent config with the given name, if declared.
func (c *Config) AgentByName(name string) (Agent, bool) {
for _, a := range c.Agents {
if a.Name == name {
return a, true
}
}
return Agent{}, false
}
// LLMByName returns the llm config with the given name, if declared.
func (c *Config) LLMByName(name string) (LLM, bool) {
for _, l := range c.LLMs {
if l.Name == name {
return l, true
}
}
return LLM{}, false
}
// EventAgent returns the statically configured agent name for the given
// event kind, if one is declared and set.
func (c *Config) EventAgent(kind string) (string, bool) {
for _, e := range c.Events {
if e.Kind == kind {
return e.Agent, e.Agent != ""
}
}
return "", false
}
// EventInstructions returns the configured freeform instructions for the
// given event kind, e.g. "Please review this pull request and respond
// appropriately." Returns "" if the event isn't declared or has none.
func (c *Config) EventInstructions(kind string) string {
for _, e := range c.Events {
if e.Kind == kind {
return e.Instructions
}
}
return ""
}
{"command":"cat /project/internal/forgejo/client.go"}
// Package forgejo wraps the Forgejo SDK and turns webhook deliveries and
// polling results into a common Event stream for the scheduler.
package forgejo
import (
"fmt"
sdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
"github.com/abrander/zoo/internal/config"
)
// Client is zoo's single shared Forgejo identity, used both for the
// event sources (webhook/poll) and for actions agents/scheduler take
// (comments, labels, PRs).
type Client struct {
sdk *sdk.Client
baseURL string
token string
}
func NewClient(cfg config.Forgejo) (*Client, error) {
c, err := sdk.NewClient(cfg.URL, sdk.SetToken(cfg.Token))
if err != nil {
return nil, fmt.Errorf("forgejo client: %w", err)
}
return &Client{sdk: c, baseURL: cfg.URL, token: cfg.Token}, nil
}
// Token returns the shared zoo Forgejo identity's token, e.g. for
// authenticating a host-side git clone/push against Forgejo (see
// internal/agentrun) without ever writing the credential into a working
// tree an agent's container can read.
func (c *Client) Token() string {
return c.token
}
// Sudo returns a new Client that impersonates username (via Forgejo's
// "Sudo:" header) on every API call it makes, using the same underlying
// token. Actions an agent takes through it — comments, labels, PRs,
// assignment — are attributed to that agent's own Forgejo account
// instead of the shared zoo identity. The token must belong to a user
// with sudo scope/admin rights for this to work; Forgejo rejects the
// header otherwise.
//
// The SDK's Sudo setting lives on the *sdk.Client itself and isn't
// safe to flip per-request on a shared client under concurrent agent
// runs, so this constructs a separate client rather than mutating one.
func (c *Client) Sudo(username string) (*Client, error) {
sudoClient, err := sdk.NewClient(c.baseURL, sdk.SetToken(c.token), sdk.SetSudo(username))
if err != nil {
return nil, fmt.Errorf("forgejo client sudo %q: %w", username, err)
}
return &Client{sdk: sudoClient, baseURL: c.baseURL, token: c.token}, nil
}
// CreateIssueComment posts a comment on the given issue or pull request
// (Forgejo/Gitea treat PRs as issues for commenting purposes).
func (c *Client) CreateIssueComment(owner, repo string, index int64, body string) error {
_, _, err := c.sdk.CreateIssueComment(owner, repo, index, sdk.CreateIssueCommentOption{Body: body})
if err != nil {
return fmt.Errorf("comment on %s/%s#%d: %w", owner, repo, index, err)
}
return nil
}
// AddLabel attaches the label with the given name to an issue/PR,
// creating the label (with a default color) on the repo first if it
// doesn't already exist.
func (c *Client) AddLabel(owner, repo string, index int64, name string) error {
id, err := c.labelID(owner, repo, name)
if err != nil {
return err
}
_, _, err = c.sdk.AddIssueLabels(owner, repo, index, sdk.IssueLabelsOption{Labels: []int64{id}})
if err != nil {
return fmt.Errorf("add label %q to %s/%s#%d: %w", name, owner, repo, index, err)
}
return nil
}
// RemoveLabel detaches the label with the given name from an issue/PR, if
// both the label and the attachment exist.
func (c *Client) RemoveLabel(owner, repo string, index int64, name string) error {
labels, _, err := c.sdk.GetIssueLabels(owner, repo, index, sdk.ListLabelsOptions{})
if err != nil {
return fmt.Errorf("list labels on %s/%s#%d: %w", owner, repo, index, err)
}
for _, l := range labels {
if l.Name == name {
_, err := c.sdk.DeleteIssueLabel(owner, repo, index, l.ID)
if err != nil {
return fmt.Errorf("remove label %q from %s/%s#%d: %w", name, owner, repo, index, err)
}
return nil
}
}
return nil
}
func (c *Client) labelID(owner, repo, name string) (int64, error) {
labels, _, err := c.sdk.ListRepoLabels(owner, repo, sdk.ListLabelsOptions{})
if err != nil {
return 0, fmt.Errorf("list labels on %s/%s: %w", owner, repo, err)
}
for _, l := range labels {
if l.Name == name {
return l.ID, nil
}
}
created, _, err := c.sdk.CreateLabel(owner, repo, sdk.CreateLabelOption{
Name: name,
Color: "#ee0000",
})
if err != nil {
return 0, fmt.Errorf("create label %q on %s/%s: %w", name, owner, repo, err)
}
return created.ID, nil
}
// CreatePullRequest opens a PR from head into base.
func (c *Client) CreatePullRequest(owner, repo, head, base, title, body string) error {
_, _, err := c.sdk.CreatePullRequest(owner, repo, sdk.CreatePullRequestOption{
Head: head,
Base: base,
Title: title,
Body: body,
})
if err != nil {
return fmt.Errorf("create pull request %s/%s %s->%s: %w", owner, repo, head, base, err)
}
return nil
}
// RequestReview asks the given users to review the pull request.
func (c *Client) RequestReview(owner, repo string, index int64, reviewers []string) error {
_, err := c.sdk.CreateReviewRequests(owner, repo, index, sdk.PullReviewRequestOptions{Reviewers: reviewers})
if err != nil {
return fmt.Errorf("request review on %s/%s#%d: %w", owner, repo, index, err)
}
return nil
}
// CloseIssue closes the given issue or pull request.
func (c *Client) CloseIssue(owner, repo string, index int64) error {
return c.setIssueState(owner, repo, index, sdk.StateClosed)
}
// ReopenIssue reopens the given issue or pull request.
func (c *Client) ReopenIssue(owner, repo string, index int64) error {
return c.setIssueState(owner, repo, index, sdk.StateOpen)
}
// RepositoryInfo returns the pieces of repo metadata agentrun needs to
// clone and branch off of the right place.
type RepositoryInfo struct {
DefaultBranch string
CloneURL string
}
func (c *Client) RepositoryInfo(owner, repo string) (RepositoryInfo, error) {
r, _, err := c.sdk.GetRepo(owner, repo)
if err != nil {
return RepositoryInfo{}, fmt.Errorf("get repo %s/%s: %w", owner, repo, err)
}
return RepositoryInfo{DefaultBranch: r.DefaultBranch, CloneURL: r.CloneURL}, nil
}
func (c *Client) setIssueState(owner, repo string, index int64, state sdk.StateType) error {
_, _, err := c.sdk.EditIssue(owner, repo, index, sdk.EditIssueOption{State: &state})
if err != nil {
return fmt.Errorf("set state %q on %s/%s#%d: %w", state, owner, repo, index, err)
}
return nil
}
// AgentProfile is what zoo reads off an agent's Forgejo account: its bio
// (surfaced in the system prompt) and the name/email used to set git
// commit authorship inside that agent's container.
type AgentProfile struct {
Description string
FullName string
Email string
}
// AgentProfile fetches the Forgejo profile of the user account named
// username. Agent config labels double as their Forgejo usernames (see
// issue:assigned resolution), so this is how an agent's "what it does"
// text, and its commit identity, are authored: by editing that account's
// profile on Forgejo, not in zoo.hcl.
func (c *Client) AgentProfile(username string) (AgentProfile, error) {
u, _, err := c.sdk.GetUserInfo(username)
if err != nil {
return AgentProfile{}, fmt.Errorf("get user %q: %w", username, err)
}
p := AgentProfile{
Description: u.Description,
FullName: u.FullName,
Email: u.Email,
}
return p, nil
}
// AssignIssue sets the sole assignee of an issue/PR to username. Used by
// the assign_issue tool so agents can hand work off to each other: the
// resulting issue:assigned event (webhook or poll) triggers whichever
// agent's config label matches username.
func (c *Client) AssignIssue(owner, repo string, index int64, username string) error {
_, _, err := c.sdk.EditIssue(owner, repo, index, sdk.EditIssueOption{Assignees: []string{username}})
if err != nil {
return fmt.Errorf("assign %s/%s#%d to %q: %w", owner, repo, index, username, err)
}
return nil
}
{"command":"cat /project/internal/tools/forgejo.go /project/internal/agentrun/context.go"}
package tools
import (
"fmt"
"github.com/abrander/zoo/internal/llm"
)
type commentParams struct {
Body string `json:"body"`
}
func init() {
tool := llm.NewTool(
"comment",
"Post a comment on the issue or pull request that triggered this run.")
tool.AddStringProperty("body", "The comment text, in Markdown", true)
Register(tool, comment)
}
func comment(ctx Context, params commentParams) (string, error) {
if err := ctx.Forgejo().Comment(params.Body); err != nil {
return "", err
}
return "Comment posted", nil
}
type openPullRequestParams struct {
Head string `json:"head"`
Base string `json:"base"`
Title string `json:"title"`
Body string `json:"body"`
}
func init() {
tool := llm.NewTool(
"open_pull_request",
"Open a pull request from a branch you've pushed into the repository's default branch (or another base branch).")
tool.AddStringProperty("head", "The branch containing your changes (usually the run's own branch)", true)
tool.AddStringProperty("base", "The branch to merge into, e.g. the repository's default branch", true)
tool.AddStringProperty("title", "The pull request title", true)
tool.AddStringProperty("body", "The pull request description, in Markdown", true)
Register(tool, openPullRequest)
}
func openPullRequest(ctx Context, params openPullRequestParams) (string, error) {
if err := ctx.Forgejo().OpenPullRequest(params.Head, params.Base, params.Title, params.Body); err != nil {
return "", err
}
return "Pull request opened", nil
}
type requestReviewParams struct {
Reviewers []string `json:"reviewers"`
}
func init() {
tool := llm.NewTool(
"request_review",
"Request review of the current pull request from one or more Forgejo users.")
tool.AddStringArrayProperty("reviewers", "Usernames to request review from", true)
Register(tool, requestReview)
}
func requestReview(ctx Context, params requestReviewParams) (string, error) {
if err := ctx.Forgejo().RequestReview(params.Reviewers); err != nil {
return "", err
}
return "Review requested", nil
}
type labelParams struct {
Name string `json:"name"`
}
func init() {
tool := llm.NewTool(
"add_label",
"Add a label to the issue or pull request that triggered this run, creating it on the repo first if needed.")
tool.AddStringProperty("name", "The label name", true)
Register(tool, addLabel)
}
func addLabel(ctx Context, params labelParams) (string, error) {
if err := ctx.Forgejo().AddLabel(params.Name); err != nil {
return "", err
}
return "Label added", nil
}
func init() {
tool := llm.NewTool(
"remove_label",
"Remove a label from the issue or pull request that triggered this run.")
tool.AddStringProperty("name", "The label name", true)
Register(tool, removeLabel)
}
func removeLabel(ctx Context, params labelParams) (string, error) {
if err := ctx.Forgejo().RemoveLabel(params.Name); err != nil {
return "", err
}
return "Label removed", nil
}
type assignIssueParams struct {
Agent string `json:"agent"`
}
func init() {
tool := llm.NewTool(
"assign_issue",
"Hand the issue or pull request that triggered this run off to another agent, by name. This assigns it to that agent's Forgejo account, which triggers their run. Use this when the task is better suited to another agent's described role than your own.")
tool.AddStringProperty("agent", "The name of the agent to hand this off to, e.g. one listed in the \"Other agents\" section of your system prompt", true)
Register(tool, assignIssue)
}
func assignIssue(ctx Context, params assignIssueParams) (string, error) {
if err := ctx.Forgejo().AssignIssue(params.Agent); err != nil {
return "", err
}
return fmt.Sprintf("Assigned to %s", params.Agent), nil
}
type noParams struct{}
func init() {
tool := llm.NewTool(
"close_issue",
"Close the issue or pull request that triggered this run.")
Register(tool, closeIssue)
}
func closeIssue(ctx Context, _ noParams) (string, error) {
if err := ctx.Forgejo().CloseIssue(); err != nil {
return "", err
}
return "Issue closed", nil
}
func init() {
tool := llm.NewTool(
"reopen_issue",
"Reopen the issue or pull request that triggered this run.")
Register(tool, reopenIssue)
}
func reopenIssue(ctx Context, _ noParams) (string, error) {
if err := ctx.Forgejo().ReopenIssue(); err != nil {
return "", err
}
return "Issue reopened", nil
}
package agentrun
import (
"context"
"fmt"
"github.com/abrander/zoo/internal/tools"
)
// runContext implements tools.Context for a single agent run, executing
// shell commands via docker exec against the run's container, git
// commands as the host process against the run's working tree (see
// Git), and binding Forgejo actions to the triggering issue/PR.
type runContext struct {
docker *dockerRuntime
containerID string
projectDir string
token string
forgejo tools.ForgejoActions
}
func (c *runContext) Exec(ctx context.Context, command string) (string, error) {
output, exitCode, err := c.docker.exec(ctx, c.containerID, command)
if err != nil {
return output, err
}
if exitCode != 0 {
return output, fmt.Errorf("command exited with status %d", exitCode)
}
return output, nil
}
// Git implements tools.Context.Git by running git on the host against
// c.projectDir rather than via docker exec. Only push needs to
// authenticate against Forgejo, so only it carries the token, via a
// per-invocation -c override that never touches .git/config (see
// gitAuthArgs) — the container this same working tree is bind-mounted
// into has no way to read it out.
func (c *runContext) Git(ctx context.Context, subcommand string, args ...string) (string, error) {
cmdArgs := append([]string{subcommand}, args...)
if subcommand == "push" {
return runGitAuthed(ctx, c.projectDir, c.token, cmdArgs...)
}
return runGit(ctx, c.projectDir, cmdArgs...)
}
func (c *runContext) Forgejo() tools.ForgejoActions {
return c.forgejo
}
{"command":"cat /project/internal/tools/context.go /project/internal/agentrun/forgejo.go"}
// Package tools is the registry of functions an agent's tool-calling
// loop can invoke, ported from ../a/tools but retargeted at a Docker
// container (via Exec) and the Forgejo issue/PR that triggered the run
// (via Forgejo()) instead of a local sandboxed filesystem.
package tools
import "context"
// Context is bound to a single agent run (job), unlike ../a's single
// global Context, since multiple agents run concurrently in zoo.
type Context interface {
// Exec runs command inside the run's container via `sh -c` and
// returns combined stdout+stderr. The container never holds a
// Forgejo credential, so this cannot reach the Forgejo API or
// authenticate git operations against it — see Git.
Exec(ctx context.Context, command string) (string, error)
// Git runs a git subcommand against the run's working tree on the
// host, outside the container, so that operations needing a Forgejo
// credential (currently just push) can authenticate without that
// credential ever being written to disk where the container (and
// anything run inside it via Exec) could read it.
Git(ctx context.Context, subcommand string, args ...string) (string, error)
// Forgejo returns the actions bound to the issue/PR that triggered
// this run, so tools don't need to be told which repo/issue to act
// on.
Forgejo() ForgejoActions
}
// ForgejoActions are the actions available to an agent against the
// triggering issue/PR. Implemented by internal/agentrun, backed by
// internal/forgejo.Client plus the run's owner/repo/issue.
type ForgejoActions interface {
Comment(body string) error
OpenPullRequest(head, base, title, body string) error
RequestReview(reviewers []string) error
AddLabel(name string) error
RemoveLabel(name string) error
CloseIssue() error
ReopenIssue() error
// AssignIssue hands the triggering issue/PR off to another agent by
// their name (which doubles as their Forgejo username); the
// resulting issue:assigned event triggers that agent's run.
AssignIssue(agentName string) error
}
package agentrun
import (
"log/slog"
"github.com/abrander/zoo/internal/forgejo"
)
// runForgejoActions implements tools.ForgejoActions, binding the
// generic forgejo.Client to the single issue/PR that triggered this run
// so agent tool calls don't need to specify owner/repo/index themselves.
type runForgejoActions struct {
client *forgejo.Client
owner, repo string
index int64
logger *slog.Logger
}
// logResult logs a forge action once it's known to have succeeded or
// failed, so every comment, PR, label, or issue-state change an agent
// makes is traceable in zoo's own logs, not just in Forgejo's history.
func (a *runForgejoActions) logResult(action string, err error, args ...any) {
fields := append([]any{"owner", a.owner, "repo", a.repo, "index", a.index}, args...)
if err != nil {
a.logger.Warn("forge action failed", append(append([]any{"action", action}, fields...), "error", err)...)
return
}
a.logger.Info("forge action", append([]any{"action", action}, fields...)...)
}
func (a *runForgejoActions) Comment(body string) error {
err := a.client.CreateIssueComment(a.owner, a.repo, a.index, body)
a.logResult("comment", err)
return err
}
func (a *runForgejoActions) OpenPullRequest(head, base, title, body string) error {
err := a.client.CreatePullRequest(a.owner, a.repo, head, base, title, body)
a.logResult("open_pull_request", err, "head", head, "base", base, "title", title)
return err
}
func (a *runForgejoActions) RequestReview(reviewers []string) error {
err := a.client.RequestReview(a.owner, a.repo, a.index, reviewers)
a.logResult("request_review", err, "reviewers", reviewers)
return err
}
func (a *runForgejoActions) AddLabel(name string) error {
err := a.client.AddLabel(a.owner, a.repo, a.index, name)
a.logResult("add_label", err, "label", name)
return err
}
func (a *runForgejoActions) RemoveLabel(name string) error {
err := a.client.RemoveLabel(a.owner, a.repo, a.index, name)
a.logResult("remove_label", err, "label", name)
return err
}
func (a *runForgejoActions) CloseIssue() error {
err := a.client.CloseIssue(a.owner, a.repo, a.index)
a.logResult("close_issue", err)
return err
}
func (a *runForgejoActions) ReopenIssue() error {
err := a.client.ReopenIssue(a.owner, a.repo, a.index)
a.logResult("reopen_issue", err)
return err
}
func (a *runForgejoActions) AssignIssue(agentName string) error {
err := a.client.AssignIssue(a.owner, a.repo, a.index, agentName)
a.logResult("assign_issue", err, "assignee", agentName)
return err
}
{"command":"cat /project/internal/agentrun/run.go"}
// Package agentrun creates the throwaway per-job environment an agent
// runs in: a git clone + branch bind-mounted into a Docker container,
// the triggering event written to /event, and the tool-calling loop
// (internal/llm + internal/tools) driven against it.
package agentrun
import (
"context"
_ "embed"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/abrander/zoo/internal/config"
"github.com/abrander/zoo/internal/forgejo"
"github.com/abrander/zoo/internal/livelog"
"github.com/abrander/zoo/internal/llm"
"github.com/abrander/zoo/internal/store"
)
//go:embed system.md
var defaultSystemPrompt string
// DefaultTimeout bounds a single agent run's wall-clock time if the
// caller doesn't override it.
const DefaultTimeout = 20 * time.Minute
type Runner struct {
docker *dockerRuntime
forgejo *forgejo.Client
store *store.Store
hub *livelog.Hub
cfg *config.Config
logger *slog.Logger
timeout time.Duration
keepOnFailure bool
agentClientsMu sync.Mutex
agentClients map[string]*forgejo.Client
}
func NewRunner(cfg *config.Config, fg *forgejo.Client, st *store.Store, hub *livelog.Hub, logger *slog.Logger, timeout time.Duration, keepOnFailure bool) (*Runner, error) {
docker, err := newDockerRuntime()
if err != nil {
return nil, err
}
if timeout <= 0 {
timeout = DefaultTimeout
}
return &Runner{
docker: docker,
forgejo: fg,
store: st,
hub: hub,
cfg: cfg,
logger: logger,
timeout: timeout,
keepOnFailure: keepOnFailure,
agentClients: make(map[string]*forgejo.Client),
}, nil
}
// forgejoAs returns a Forgejo client that impersonates agentName (via
// Sudo:) for every API call it makes, so an agent's actions — comments,
// labels, PRs, assignment — are attributed to its own Forgejo account
// rather than zoo's shared identity. Clients are built once per agent
// and cached, since constructing one costs an extra API round trip.
// Sudo requires the configured forgejo.token to have admin/sudo rights;
// if it doesn't, this logs a warning and falls back to the shared
// identity rather than failing the run outright.
func (r *Runner) forgejoAs(agentName string) *forgejo.Client {
r.agentClientsMu.Lock()
defer r.agentClientsMu.Unlock()
if c, ok := r.agentClients[agentName]; ok {
return c
}
c, err := r.forgejo.Sudo(agentName)
if err != nil {
r.logger.Warn("failed to create sudo'd forgejo client for agent; actions will be attributed to the shared zoo identity instead", "agent", agentName, "error", err)
c = r.forgejo
}
r.agentClients[agentName] = c
return c
}
// Run implements scheduler.Runner.
func (r *Runner) Run(ctx context.Context, jobID string, agent config.Agent, llmCfg config.LLM, dockerImage string, ev forgejo.Event) error {
ctx, cancel := context.WithTimeout(ctx, r.timeout)
defer cancel()
logger := r.logger.With("job", jobID, "agent", agent.Name)
repoInfo, err := r.forgejo.RepositoryInfo(ev.Owner, ev.Repo)
if err != nil {
return fmt.Errorf("look up repository: %w", err)
}
workDir, err := os.MkdirTemp("", "zoo-run-*")
if err != nil {
return fmt.Errorf("create work dir: %w", err)
}
succeeded := false
defer func() {
if succeeded || !r.keepOnFailure {
os.RemoveAll(workDir)
} else {
logger.Warn("keeping work dir after failure", "dir", workDir)
}
}()
branch := fmt.Sprintf("issue-%d-%s", ev.Index, agent.Name)
projectDir := filepath.Join(workDir, "project")
if err := cloneAndBranch(ctx, repoInfo.CloneURL, r.forgejo.Token(), repoInfo.DefaultBranch, branch, projectDir); err != nil {
return fmt.Errorf("prepare git working tree: %w", err)
}
roster := buildRoster(r.forgejo, r.cfg.Agents, logger)
gitName, gitEmail := gitIdentity(agent.Name, roster)
// Local (not --global) scope, so this identity lives in
// projectDir/.git/config: the one place both this host-side clone
// and the container it's bind-mounted into (as /project) actually
// share.
if out, err := runGit(ctx, projectDir, "config", "user.name", gitName); err != nil {
return fmt.Errorf("configure git user.name: %w: %s", err, out)
}
if out, err := runGit(ctx, projectDir, "config", "user.email", gitEmail); err != nil {
return fmt.Errorf("configure git user.email: %w: %s", err, out)
}
eventPath := filepath.Join(workDir, "event.json")
if err := os.WriteFile(eventPath, ev.Raw, 0o644); err != nil {
return fmt.Errorf("write event file: %w", err)
}
containerID, err := r.docker.createContainer(ctx, dockerImage, []string{
projectDir + ":/project",
eventPath + ":/event:ro",
}, fmt.Sprintf("zoo-issue-%d-%s", ev.Index, agent.Name))
if err != nil {
return fmt.Errorf("start container: %w", err)
}
defer func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cleanupCancel()
if err := r.docker.remove(cleanupCtx, containerID); err != nil {
logger.Warn("failed to remove container", "container", containerID, "error", err)
}
}()
// /project is bind-mounted from the host, so it's owned by the host
// UID that ran the clone, not whatever UID runs inside the
// container (usually root) — git's ownership check rejects that by
// default ("detected dubious ownership") unless told otherwise.
// --system (not --global) so this holds regardless of which user
// subsequent `docker exec` calls run as. Commit identity is
// configured host-side, above, with --local scope so it's visible
// from both sides of the bind mount without needing --global here.
out, exitCode, err := r.docker.exec(ctx, containerID, "git config --system --add safe.directory '*'")
if err != nil {
return fmt.Errorf("configure git safe.directory in container: %w: %s", err, out)
}
if exitCode != 0 {
return fmt.Errorf("configure git safe.directory in container: exit %d: %s", exitCode, out)
}
logAppend := func(stream, line string) {
if err := r.store.AppendLog(context.Background(), jobID, stream, line); err != nil {
logger.Warn("failed to append log", "error", err)
}
}
runCtx := &runContext{
docker: r.docker,
containerID: containerID,
projectDir: projectDir,
token: r.forgejo.Token(),
forgejo: &runForgejoActions{
client: r.forgejoAs(agent.Name),
owner: ev.Owner,
repo: ev.Repo,
index: ev.Index,
logger: logger,
},
}
llmClient := llm.NewClient(llmCfg)
systemPrompt := defaultSystemPrompt + identitySection(agent.Name, roster)
instructions := r.cfg.EventInstructions(ev.Kind)
messages := []llm.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: seedMessage(ev, branch, repoInfo.DefaultBranch, instructions)},
}
hooks := r.streamHooks(jobID, logAppend)
_, err = runLoop(ctx, llmClient, runCtx, messages, hooks)
if err != nil {
return fmt.Errorf("agent loop: %w", err)
}
succeeded = true
return nil
}
// streamHooks builds the Hooks a single Run passes to runLoop: every
// delta is published live to the hub for connected dashboard viewers,
// and once a reasoning/content block or tool call is complete, it's
// persisted to the store as one row and the hub's replay buffer for
// jobID is checkpointed — so a viewer connecting from this point on
// sees it via the persisted history instead of a live replay, and is
// never shown it twice.
func (r *Runner) streamHooks(jobID string, logAppend func(stream, line string)) Hooks {
var reasoningBuf, contentBuf strings.Builder
reasoningOpen, contentOpen := false, false
return Hooks{
OnReasoningDelta: func(delta string) {
if !reasoningOpen {
reasoningOpen = true
reasoningBuf.Reset()
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningStart})
}
reasoningBuf.WriteString(delta)
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningDelta, Text: delta})
},
OnContentDelta: func(delta string) {
if !contentOpen {
contentOpen = true
contentBuf.Reset()
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})
}
contentBuf.WriteString(delta)
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})
},
OnTurnEnd: func() {
if reasoningOpen {
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningEnd})
logAppend("reasoning", reasoningBuf.String())
r.hub.Checkpoint(jobID)
reasoningOpen = false
}
if contentOpen {
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentEnd})
logAppend("content", contentBuf.String())
r.hub.Checkpoint(jobID)
contentOpen = false
}
},
OnTool: func(name, arguments, result string, toolErr bool) {
r.hub.Publish(jobID, livelog.Event{
Type: livelog.Tool,
Name: name,
Arguments: arguments,
Result: result,
Error: toolErr,
})
line, err := json.Marshal(store.ToolLogEntry{Name: name, Arguments: arguments, Result: result, Error: toolErr})
if err != nil {
r.logger.Warn("failed to marshal tool log entry", "job", jobID, "error", err)
} else {
logAppend("tool", string(line))
}
r.hub.Checkpoint(jobID)
},
}
}
func seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string) string {
raw, _ := json.MarshalIndent(json.RawMessage(ev.Raw), "", " ")
var instructionsSection string
if instructions != "" {
instructionsSection = fmt.Sprintf("Instructions for this event, from zoo.hcl:\n%s\n\n", instructions)
}
return fmt.Sprintf(
"You were triggered by a %q event on %s/%s.\n\n"+
"%sYour working branch is %q, checked out from the default branch %q.\n\n"+
"Title: %s\n\nBody:\n%s\n\nFull event payload:\n```json\n%s\n```",
ev.Kind, ev.Owner, ev.Repo, instructionsSection, branch, defaultBranch, ev.Title, ev.Body, raw)
}
{"command":"cat /project/internal/agentrun/roster.go /project/internal/agentrun/clone.go"}
package agentrun
import (
"fmt"
"log/slog"
"strings"
"github.com/abrander/zoo/internal/config"
"github.com/abrander/zoo/internal/forgejo"
)
// agentInfo pairs a configured agent with its Forgejo profile: the bio
// (what its account says it does) and the name/email used to set git
// commit authorship inside its container.
type agentInfo struct {
Name string
Description string
FullName string
Email string
}
// buildRoster fetches every configured agent's Forgejo profile (agent
// config labels double as Forgejo usernames). A per-agent lookup failure
// is logged and that agent is included with an empty profile rather than
// failing the whole run — one missing/unreachable profile shouldn't
// block someone else's job.
func buildRoster(fg *forgejo.Client, agents []config.Agent, logger *slog.Logger) []agentInfo {
roster := make([]agentInfo, 0, len(agents))
for _, a := range agents {
profile, err := fg.AgentProfile(a.Name)
if err != nil {
logger.Warn("failed to fetch agent profile from forgejo", "agent", a.Name, "error", err)
}
roster = append(roster, agentInfo{Name: a.Name, Description: profile.Description, FullName: profile.FullName, Email: profile.Email})
}
return roster
}
// gitIdentity returns the git commit author name/email to configure
// inside self's container, from its Forgejo profile, falling back to
// its agent name and a synthetic zoo.local address for whichever fields
// its profile doesn't set.
func gitIdentity(self string, roster []agentInfo) (name, email string) {
for _, a := range roster {
if a.Name == self {
name, email = a.FullName, a.Email
break
}
}
if name == "" {
name = self
}
if email == "" {
email = self + "@zoo.local"
}
return name, email
}
// identitySection renders the "who am I / who else is there" prompt
// section injected after the static system prompt: the agent's own
// Forgejo profile description, and everyone else's, so it can decide
// when a task is better handed off via the assign_issue tool.
func identitySection(self string, roster []agentInfo) string {
var b strings.Builder
var selfDesc string
var others []agentInfo
for _, a := range roster {
if a.Name == self {
selfDesc = a.Description
} else {
others = append(others, a)
}
}
fmt.Fprintf(&b, "\n\n## Your identity\n\nYou are the agent %q.", self)
if selfDesc != "" {
fmt.Fprintf(&b, " Your role, from your Forgejo profile description: %s", selfDesc)
} else {
b.WriteString(" Your Forgejo profile has no description set.")
}
if len(others) > 0 {
b.WriteString("\n\n## Other agents\n\nIf this task is a better fit for another agent's role, use the assign_issue tool with their name to hand it off (this reassigns the issue/PR on Forgejo, which triggers their run):\n")
for _, a := range others {
desc := a.Description
if desc == "" {
desc = "(no description set)"
}
fmt.Fprintf(&b, "### %s\n\n%s\n\n", a.Name, desc)
}
}
return b.String()
}
package agentrun
import (
"context"
"encoding/base64"
"fmt"
"os/exec"
"strings"
)
// gitAuthArgs returns the `-c http.extraHeader=...` global git arguments
// that authenticate a single invocation as the shared zoo Forgejo
// identity. Unlike embedding the token in the remote URL, a `-c`
// override lives only for the lifetime of that one process and is never
// written to .git/config on disk — where the container bind-mounting
// this working tree (and any command an agent runs inside it via the
// bash tool) could otherwise read it straight back out.
func gitAuthArgs(token string) []string {
auth := base64.StdEncoding.EncodeToString([]byte("zoo:" + token))
return []string{"-c", "http.extraHeader=Authorization: Basic " + auth}
}
// cloneAndBranch clones cloneURL into dir, authenticating the clone
// itself with token (see gitAuthArgs), and checks out a new branch off
// of defaultBranch. token never ends up in the resulting working tree:
// the origin remote is left with the plain cloneURL.
func cloneAndBranch(ctx context.Context, cloneURL, token, defaultBranch, branch, dir string) error {
args := append(gitAuthArgs(token), "clone", "--branch", defaultBranch, "--single-branch", cloneURL, dir)
if out, err := runGit(ctx, "", args...); err != nil {
return fmt.Errorf("clone: %w: %s", err, out)
}
if out, err := runGit(ctx, dir, "checkout", "-b", branch); err != nil {
return fmt.Errorf("checkout branch %q: %w: %s", branch, err, out)
}
return nil
}
// runGit runs a git subcommand on the host (not inside any container)
// with no Forgejo credential attached.
func runGit(ctx context.Context, dir string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "git", args...)
if dir != "" {
cmd.Dir = dir
}
out, err := cmd.CombinedOutput()
return strings.TrimSpace(string(out)), err
}
// runGitAuthed is runGit with token attached via gitAuthArgs, for the
// one subcommand (push) that needs to authenticate against Forgejo.
func runGitAuthed(ctx context.Context, dir, token string, args ...string) (string, error) {
return runGit(ctx, dir, append(gitAuthArgs(token), args...)...)
}
{"command":"cat /project/internal/scheduler/scheduler.go /project/cmd/zoo/main.go"}
// Package scheduler resolves incoming Forgejo events to configured
// agents and runs them, bounded by max_live_agents.
package scheduler
import (
"context"
"errors"
"fmt"
"log/slog"
"sync"
"github.com/google/uuid"
"github.com/abrander/zoo/internal/config"
"github.com/abrander/zoo/internal/forgejo"
"github.com/abrander/zoo/internal/livelog"
"github.com/abrander/zoo/internal/store"
)
// forgejoActions is the narrow slice of Client the scheduler needs for
// its own failure-reporting side effects (defined here, not in
// internal/forgejo, so tests can inject a fake).
type forgejoActions interface {
CreateIssueComment(owner, repo string, index int64, body string) error
AddLabel(owner, repo string, index int64, name string) error
}
// FailureLabel is applied to the triggering issue/PR, alongside a
// comment, whenever an agent run fails or times out.
const FailureLabel = "zoo:failed"
// Runner runs a single agent invocation to completion. Implemented by
// internal/agentrun.Run; a narrow interface here so the scheduler is
// testable without Docker.
type Runner interface {
Run(ctx context.Context, jobID string, agent config.Agent, llm config.LLM, dockerImage string, ev forgejo.Event) error
}
type Scheduler struct {
cfg *config.Config
store *store.Store
forgejo forgejoActions
runner Runner
hub *livelog.Hub
logger *slog.Logger
sem chan struct{}
wg sync.WaitGroup
}
func New(cfg *config.Config, st *store.Store, fg forgejoActions, runner Runner, hub *livelog.Hub, logger *slog.Logger) *Scheduler {
return &Scheduler{
cfg: cfg,
store: st,
forgejo: fg,
runner: runner,
hub: hub,
logger: logger,
sem: make(chan struct{}, cfg.MaxLive),
}
}
// resolveAgent returns the name of the agent that should handle ev, if
// any. issue:assigned resolves dynamically: the agent whose config label
// matches the Forgejo assignee's username. Every other event kind uses
// the static event->agent mapping from config.
func resolveAgent(cfg *config.Config, ev forgejo.Event) (string, bool) {
if ev.Kind == config.EventIssueAssigned {
if _, ok := cfg.AgentByName(ev.Assignee); ok {
return ev.Assignee, true
}
return "", false
}
return cfg.EventAgent(ev.Kind)
}
// Run consumes events until ctx is canceled or the channel closes,
// dispatching each to its resolved agent and blocking on the
// max_live_agents semaphore before starting a run.
func (s *Scheduler) Run(ctx context.Context, events <-chan forgejo.Event) {
for {
select {
case <-ctx.Done():
return
case ev, ok := <-events:
if !ok {
return
}
s.handle(ctx, ev)
}
}
}
func (s *Scheduler) handle(ctx context.Context, ev forgejo.Event) {
agentName, ok := resolveAgent(s.cfg, ev)
if !ok {
s.logger.Debug("no agent resolved for event, dropping", "kind", ev.Kind, "owner", ev.Owner, "repo", ev.Repo, "index", ev.Index)
return
}
// An agent's own actions (e.g. a comment posted via the `comment`
// tool, attributed to it by Sudo) can themselves show up as new
// events. Don't let an agent trigger itself off its own activity —
// that's a self-reinforcing loop, not new work.
if ev.Author != "" && ev.Author == agentName {
s.logger.Debug("dropping event authored by the agent it would trigger", "kind", ev.Kind, "agent", agentName, "owner", ev.Owner, "repo", ev.Repo, "index", ev.Index)
return
}
agent, ok := s.cfg.AgentByName(agentName)
if !ok {
s.logger.Error("resolved agent not declared in config", "agent", agentName)
return
}
llm, ok := s.cfg.LLMByName(agent.LLM)
if !ok {
s.logger.Error("agent references undeclared llm", "agent", agentName, "llm", agent.LLM)
return
}
jobID := uuid.NewString()
if err := s.store.CreateJob(ctx, store.Job{
ID: jobID,
EventKind: ev.Kind,
Agent: agentName,
Owner: ev.Owner,
Repo: ev.Repo,
IssueIndex: ev.Index,
}); err != nil {
s.logger.Error("failed to record job", "job", jobID, "error", err)
return
}
select {
case s.sem <- struct{}{}:
case <-ctx.Done():
return
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
defer func() { <-s.sem }()
s.run(ctx, jobID, agent, llm, ev)
}()
}
func (s *Scheduler) run(ctx context.Context, jobID string, agent config.Agent, llm config.LLM, ev forgejo.Event) {
logger := s.logger.With("job", jobID, "agent", agent.Name, "event", ev.Kind, "owner", ev.Owner, "repo", ev.Repo, "index", ev.Index)
// Job status writes use a context detached from ctx, not ctx itself:
// ctx is canceled on daemon shutdown to unwind the in-flight run, and
// an already-canceled ctx would make these UPDATEs fail instantly,
// leaving the job stuck at "running" forever even though the process
// has exited.
if err := s.store.MarkJobStarted(context.Background(), jobID); err != nil {
logger.Error("failed to mark job started", "error", err)
}
logger.Info("agent run starting")
err := s.runner.Run(ctx, jobID, agent, llm, s.cfg.Environment.DockerImage, ev)
status := store.JobSucceeded
errMsg := ""
if err != nil {
errMsg = err.Error()
// The daemon-wide ctx passed in here isn't what times a run out
// (agentrun.Runner.Run applies its own per-run deadline
// internally); a timed-out run surfaces as a wrapped
// context.DeadlineExceeded in the returned error instead.
if errors.Is(err, context.DeadlineExceeded) {
status = store.JobTimedOut
} else {
status = store.JobFailed
}
logger.Error("agent run failed", "status", status, "error", err)
s.reportFailure(ev, status, err)
} else {
logger.Info("agent run succeeded")
}
if err := s.store.MarkJobFinished(context.Background(), jobID, status, errMsg); err != nil {
logger.Error("failed to mark job finished", "error", err)
}
// Closing the hub here, only after the terminal status is durably
// written, is what lets a dashboard tab's auto-reload (triggered by
// this Close) see the finished status immediately. Closing it inside
// Runner.Run instead — before this status write — raced a fast
// reload against MarkJobFinished: the reloaded page could still see
// "running", open a fresh SSE subscription against a hub entry that
// no longer exists, and be stuck showing a stale status forever.
s.hub.Close(jobID)
}
// reportFailure implements the locked-in failure policy: comment on the
// triggering issue/PR with an error summary, and apply FailureLabel.
func (s *Scheduler) reportFailure(ev forgejo.Event, status store.JobStatus, runErr error) {
body := fmt.Sprintf("zoo agent run %s: %s", status, runErr.Error())
if err := s.forgejo.CreateIssueComment(ev.Owner, ev.Repo, ev.Index, body); err != nil {
s.logger.Error("failed to post failure comment", "owner", ev.Owner, "repo", ev.Repo, "index", ev.Index, "error", err)
}
if err := s.forgejo.AddLabel(ev.Owner, ev.Repo, ev.Index, FailureLabel); err != nil {
s.logger.Error("failed to add failure label", "owner", ev.Owner, "repo", ev.Repo, "index", ev.Index, "error", err)
}
}
// Shutdown stops accepting new work is the caller's responsibility (stop
// feeding Run's channel); Shutdown just waits for in-flight runs, up to
// ctx's deadline.
func (s *Scheduler) Shutdown(ctx context.Context) error {
done := make(chan struct{})
go func() {
s.wg.Wait()
close(done)
}()
select {
case <-done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// Command zoo runs the daemon: it watches a Forgejo instance for
// issue/PR events, dispatches them to configured AI agents running in
// Docker containers, and serves a small dashboard over the result.
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/abrander/zoo/internal/agentrun"
"github.com/abrander/zoo/internal/config"
"github.com/abrander/zoo/internal/forgejo"
"github.com/abrander/zoo/internal/livelog"
"github.com/abrander/zoo/internal/scheduler"
"github.com/abrander/zoo/internal/store"
"github.com/abrander/zoo/internal/web"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "zoo:", err)
os.Exit(1)
}
}
func run() error {
var (
configPath = flag.String("config", "zoo.hcl", "path to the zoo.hcl config file")
dbPath = flag.String("db", "zoo.db", "path to the sqlite state database")
listen = flag.String("listen", ":8080", "address to serve webhooks and the dashboard on")
runTimeout = flag.Duration("run-timeout", agentrun.DefaultTimeout, "wall-clock timeout for a single agent run")
keepOnFailure = flag.Bool("keep-on-failure", false, "keep the container and clone around after a failed run, for debugging")
)
flag.Parse()
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
cfg, err := config.Load(*configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
st, err := store.Open(*dbPath)
if err != nil {
return fmt.Errorf("open store: %w", err)
}
defer st.Close()
if n, err := st.ReapOrphanedJobs(context.Background()); err != nil {
logger.Warn("failed to reap orphaned jobs", "error", err)
} else if n > 0 {
logger.Warn("reaped jobs left running by a previous process instance", "count", n)
}
fgClient, err := forgejo.NewClient(cfg.Forgejo)
if err != nil {
return fmt.Errorf("forgejo client: %w", err)
}
watcher := forgejo.NewWatcher(fgClient, st, cfg.Forgejo.WebhookSecret, logger.With("component", "forgejo"))
hub := livelog.NewHub()
runner, err := agentrun.NewRunner(cfg, fgClient, st, hub, logger.With("component", "agentrun"), *runTimeout, *keepOnFailure)
if err != nil {
return fmt.Errorf("agent runner: %w", err)
}
sched := scheduler.New(cfg, st, fgClient, runner, hub, logger.With("component", "scheduler"))
dashboard := web.New(cfg, st, hub)
mux := http.NewServeMux()
mux.Handle("/webhooks/forgejo", watcher.Handler())
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.Handle("/", dashboard.Handler())
server := &http.Server{Addr: *listen, Handler: mux}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go watcher.Run(ctx)
go sched.Run(ctx, watcher.Events())
serveErr := make(chan error, 1)
go func() {
logger.Info("listening", "addr", *listen)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
serveErr <- err
}
}()
select {
case <-ctx.Done():
logger.Info("shutting down")
case err := <-serveErr:
stop()
logger.Error("http server failed", "error", err)
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
logger.Warn("http server shutdown", "error", err)
}
if err := sched.Shutdown(shutdownCtx); err != nil {
logger.Warn("scheduler did not drain in time", "error", err)
}
return nil
}
{"command":"cat /project/internal/config/config_test.go /project/internal/agentrun/roster_test.go"}
package config
import (
"os"
"path/filepath"
"testing"
)
func load(t *testing.T, hcl string) (*Config, error) {
t.Helper()
path := filepath.Join(t.TempDir(), "zoo.hcl")
if err := os.WriteFile(path, []byte(hcl), 0o600); err != nil {
t.Fatal(err)
}
return Load(path)
}
const validConfig = `
llm "qwen" {
openai = "https://example.com"
token = "tok"
model = "qwen3"
}
forgejo {
url = "https://example.com"
token = "tok"
}
max_live_agents = 2
environment {
docker_image = "debian:unstable"
}
agent "leon" {
llm = "qwen"
}
event "issue:new" {
agent = "leon"
}
event "issue:assigned" {
instructions = "Please handle this issue."
}
event "issue:comment" {
agent = "leon"
instructions = "Please review the comment and respond appropriately."
}
event "pr:new" {
agent = "leon"
}
`
func TestLoadValid(t *testing.T) {
cfg, err := load(t, validConfig)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(cfg.LLMs) != 1 || cfg.LLMs[0].Name != "qwen" {
t.Fatalf("unexpected llms: %+v", cfg.LLMs)
}
if agent, ok := cfg.EventAgent(EventIssueNew); !ok || agent != "leon" {
t.Fatalf("expected issue:new -> leon, got %q, %v", agent, ok)
}
if _, ok := cfg.EventAgent(EventIssueAssigned); ok {
t.Fatalf("issue:assigned should have no static agent")
}
if got := cfg.EventInstructions(EventIssueAssigned); got != "Please handle this issue." {
t.Fatalf("unexpected issue:assigned instructions: %q", got)
}
if got := cfg.EventInstructions(EventIssueComment); got != "Please review the comment and respond appropriately." {
t.Fatalf("unexpected issue:comment instructions: %q", got)
}
if got := cfg.EventInstructions(EventPRNew); got != "" {
t.Fatalf("expected no instructions for pr:new, got %q", got)
}
}
func TestValidateUndeclaredLLM(t *testing.T) {
_, err := load(t, `
forgejo { url = "https://example.com" token = "tok" }
max_live_agents = 1
environment { docker_image = "debian:unstable" }
agent "leon" { llm = "missing" }
`)
if err == nil {
t.Fatal("expected error for undeclared llm reference")
}
}
func TestValidateUndeclaredEventAgent(t *testing.T) {
_, err := load(t, `
llm "qwen" { openai = "https://example.com" token = "tok" model = "qwen3" }
forgejo { url = "https://example.com" token = "tok" }
max_live_agents = 1
environment { docker_image = "debian:unstable" }
agent "leon" { llm = "qwen" }
event "issue:new" { agent = "missing" }
`)
if err == nil {
t.Fatal("expected error for undeclared agent reference")
}
}
func TestValidateIssueAssignedRejectsAgent(t *testing.T) {
_, err := load(t, `
llm "qwen" { openai = "https://example.com" token = "tok" model = "qwen3" }
forgejo { url = "https://example.com" token = "tok" }
max_live_agents = 1
environment { docker_image = "debian:unstable" }
agent "leon" { llm = "qwen" }
event "issue:assigned" { agent = "leon" }
`)
if err == nil {
t.Fatal("expected error: issue:assigned must not set agent")
}
}
func TestValidateMaxLiveAgents(t *testing.T) {
_, err := load(t, `
llm "qwen" { openai = "https://example.com" token = "tok" model = "qwen3" }
forgejo { url = "https://example.com" token = "tok" }
max_live_agents = 0
environment { docker_image = "debian:unstable" }
`)
if err == nil {
t.Fatal("expected error for max_live_agents < 1")
}
}
func TestValidateMissingDockerImage(t *testing.T) {
_, err := load(t, `
llm "qwen" { openai = "https://example.com" token = "tok" model = "qwen3" }
forgejo { url = "https://example.com" token = "tok" }
max_live_agents = 1
environment { docker_image = "" }
`)
if err == nil {
t.Fatal("expected error for missing docker_image")
}
}
func TestValidateUnknownEventKind(t *testing.T) {
_, err := load(t, `
llm "qwen" { openai = "https://example.com" token = "tok" model = "qwen3" }
forgejo { url = "https://example.com" token = "tok" }
max_live_agents = 1
environment { docker_image = "debian:unstable" }
agent "leon" { llm = "qwen" }
event "pr:sync" { agent = "leon" }
`)
if err == nil {
t.Fatal("expected error for unknown event kind")
}
}
package agentrun
import (
"strings"
"testing"
)
func TestIdentitySectionIncludesSelfAndOthers(t *testing.T) {
roster := []agentInfo{
{Name: "leon", Description: "Triages new issues"},
{Name: "greg", Description: "Reviews pull requests"},
{Name: "mika", Description: ""},
}
section := identitySection("leon", roster)
if !strings.Contains(section, `You are the agent "leon"`) {
t.Fatalf("expected self identity, got: %s", section)
}
if !strings.Contains(section, "Triages new issues") {
t.Fatalf("expected self description, got: %s", section)
}
if strings.Contains(section, "- leon:") {
t.Fatalf("self should not appear in the other-agents roster: %s", section)
}
if !strings.Contains(section, "- greg: Reviews pull requests") {
t.Fatalf("expected greg in roster, got: %s", section)
}
if !strings.Contains(section, "- mika: (no description set)") {
t.Fatalf("expected fallback text for empty description, got: %s", section)
}
}
func TestIdentitySectionNoOthers(t *testing.T) {
roster := []agentInfo{{Name: "leon", Description: "Solo agent"}}
section := identitySection("leon", roster)
if strings.Contains(section, "Other agents") {
t.Fatalf("expected no roster section with only self, got: %s", section)
}
}
func TestGitIdentityFromProfile(t *testing.T) {
roster := []agentInfo{
{Name: "leon", FullName: "Leon the Triager", Email: "leon@example.com"},
}
name, email := gitIdentity("leon", roster)
if name != "Leon the Triager" || email != "leon@example.com" {
t.Fatalf("expected profile identity, got name=%q email=%q", name, email)
}
}
func TestGitIdentityFallsBackWhenProfileIncomplete(t *testing.T) {
roster := []agentInfo{{Name: "leon"}}
name, email := gitIdentity("leon", roster)
if name != "leon" {
t.Fatalf("expected name to fall back to agent name, got %q", name)
}
if email != "leon@zoo.local" {
t.Fatalf("expected synthetic fallback email, got %q", email)
}
}
func TestGitIdentityUnknownAgent(t *testing.T) {
name, email := gitIdentity("mika", nil)
if name != "mika" || email != "mika@zoo.local" {
t.Fatalf("expected fallback identity for an agent missing from the roster, got name=%q email=%q", name, email)
}
}
{"command":"cat /project/go.mod && find / -type d -path \"*forgejo-sdk*\" 2>/dev/null | head -5"}
module github.com/abrander/zoo go 1.26.4 require ( codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 github.com/docker/docker v28.5.2+incompatible github.com/google/uuid v1.6.0 github.com/hashicorp/hcl/v2 v2.24.0 modernc.org/sqlite v1.56.0 ) require ( github.com/42wim/httpsig v1.2.3 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/agext/levenshtein v1.2.1 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.8.1 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/hashicorp/go-version v1.7.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.1.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/zclconf/go-cty v1.16.3 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect go.opentelemetry.io/otel v1.45.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 // indirect go.opentelemetry.io/otel/metric v1.45.0 // indirect go.opentelemetry.io/otel/trace v1.45.0 // indirect golang.org/x/crypto v0.39.0 // indirect golang.org/x/mod v0.37.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.47.0 // indirect gotest.tools/v3 v3.5.2 // indirect modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect )
{"command":"which go && go version && ls ~/go/pkg/mod/codeberg.org 2>/dev/null || echo \"no mod cache\""}
/usr/local/go/bin/go go version go1.27.0 linux/amd64 no mod cache