Details and log output for this agent run.
{"path":".","recursive":false}
drwxrwxr-x 5 1000 1000 4096 Aug 23 13:06 . drwxr-xr-x 1 root root 4096 Aug 23 13:06 .. drwxrwxr-x 8 1000 1000 4096 Aug 23 13:06 .git -rw-rw-r-- 1 1000 1000 22 Aug 23 13:06 .gitignore drwxrwxr-x 3 1000 1000 4096 Aug 23 13:06 cmd -rw-rw-r-- 1 1000 1000 2628 Aug 23 13:06 go.mod -rw-rw-r-- 1 1000 1000 16578 Aug 23 13:06 go.sum drwxrwxr-x 11 1000 1000 4096 Aug 23 13:06 internal -rw-rw-r-- 1 1000 1000 1415 Aug 23 13:06 zoo.hcl.example
{"path":"zoo.hcl","start_line":1,"end_line":100}
ERROR CALLING TOOL: read "zoo.hcl": command exited with status 1
{"path":"zoo.hcl.example","start_line":1,"end_line":100}
{"path":"zoo.hcl.example","content":"# zoo.hcl - Configuration for the zoo daemon.\r\n#\r\n# Copy this file to zoo.hcl (gitignored) and fill in real values.\r\n\r\nllm \"Qwen 3.6\" {\r\n openai = \"https://code.stdio.dk\"\r\n token = \"TOKEN\"\r\n model = \"Qwen3.6\"\r\n}\r\n\r\nllm \"Qwen 3.8\" {\r\n openai = \"https://code.stdio.dk\"\r\n token = \"TOKEN\"\r\n model = \"Qwen3.8\"\r\n}\r\n\r\nforgejo {\r\n url = \"https://code.stdio.dk\"\r\n token = \"EXAMPLE_TOKEN\"\r\n # Shared secret configured on the Forgejo webhook. Required to accept\r\n # webhook deliveries; polling works without it.\r\n webhook_secret = \"EXAMPLE_WEBHOOK_SECRET\"\r\n}\r\n\r\nmax_live_agents = 5\r\n\r\nenvironment {\r\n docker_image = \"golang:latest\"\r\n}\r\n\r\nagent \"anna\" {\r\n llm = \"Qwen 3.6\"\r\n}\r\n\r\nagent \"greg\" {\r\n llm = \"Qwen 3.8\"\r\n}\r\n\r\n# \"leon\" doubles as the expected Forgejo assignee username for\r\n# issue:assigned events - see the event block below.\r\nagent \"leon\" {\r\n llm = \"Qwen 3.8\"\r\n}\r\n\r\nagent \"mika\" {\r\n llm = \"Qwen 3.6\"\r\n}\r\n\r\nevent \"issue:new\" {\r\n agent = \"leon\"\r\n}\r\n\r\nevent \"issue:assigned\" {\r\n // No `agent` here: the agent whose name matches the Forgejo\r\n // assignee's username is triggered.\r\n instructions = \"Please handle this issue.\"\r\n}\r\n\r\nevent \"issue:comment\" {\r\n agent = \"leon\"\r\n instructions = \"Please review what this comment says and respond appropriately.\"\r\n}\r\n\r\nevent \"pr:new\" {\r\n agent = \"greg\"\r\n instructions = \"Please review this pull request and respond appropriately.\"\r\n}\r","start_line":1,"end_line":67,"total_lines":67,"truncated":false}
{"path":"internal","recursive":true}
/project/internal/agentrun /project/internal/agentrun/clone.go /project/internal/agentrun/context.go /project/internal/agentrun/docker.go /project/internal/agentrun/docker_integration_test.go /project/internal/agentrun/forgejo.go /project/internal/agentrun/loop.go /project/internal/agentrun/roster.go /project/internal/agentrun/roster_test.go /project/internal/agentrun/run.go /project/internal/agentrun/seed_test.go /project/internal/agentrun/system.md /project/internal/config /project/internal/config/config.go /project/internal/config/config_test.go /project/internal/forgejo /project/internal/forgejo/client.go /project/internal/forgejo/event.go /project/internal/forgejo/poll.go /project/internal/forgejo/watcher.go /project/internal/forgejo/webhook.go /project/internal/forgejo/webhook_test.go /project/internal/livelog /project/internal/livelog/hub.go /project/internal/livelog/hub_test.go /project/internal/llm /project/internal/llm/client.go /project/internal/llm/streamer.go /project/internal/llm/streamer_test.go /project/internal/llm/types.go /project/internal/scheduler /project/internal/scheduler/scheduler.go /project/internal/scheduler/scheduler_test.go /project/internal/store /project/internal/store/schema.sql /project/internal/store/store.go /project/internal/store/store_test.go /project/internal/tools /project/internal/tools/bash.go /project/internal/tools/context.go /project/internal/tools/edit-file.go /project/internal/tools/forgejo.go /project/internal/tools/git.go /project/internal/tools/grep_search.go /project/internal/tools/list_files.go /project/internal/tools/move_file.go /project/internal/tools/read_file.go /project/internal/tools/registry.go /project/internal/tools/remove_file.go /project/internal/tools/shell.go /project/internal/tools/tools_test.go /project/internal/tools/write_file.go /project/internal/web /project/internal/web/templates.go /project/internal/web/web.go /project/internal/web/web_test.go
{"path":"cmd","recursive":true}
/project/cmd/zoo /project/cmd/zoo/main.go
{"path":"internal/config/config.go","start_line":1,"end_line":150}
{"path":"internal/config/config.go","content":"// Package config loads and validates zoo's HCL configuration file.\r\npackage config\r\n\r\nimport (\r\n\t\"fmt\"\r\n\r\n\t\"github.com/hashicorp/hcl/v2/hclsimple\"\r\n)\r\n\r\n// Known event kinds. issue:assigned is resolved dynamically (agent name\r\n// must match the Forgejo assignee's username) so it never carries an\r\n// `agent` attribute; the rest map statically to one configured agent.\r\nconst (\r\n\tEventIssueNew = \"issue:new\"\r\n\tEventIssueComment = \"issue:comment\"\r\n\tEventIssueAssigned = \"issue:assigned\"\r\n\tEventPRNew = \"pr:new\"\r\n)\r\n\r\nvar staticEventKinds = map[string]bool{\r\n\tEventIssueNew: true,\r\n\tEventIssueComment: true,\r\n\tEventPRNew: true,\r\n}\r\n\r\ntype Config struct {\r\n\tLLMs []LLM `hcl:\"llm,block\"`\r\n\tForgejo Forgejo `hcl:\"forgejo,block\"`\r\n\tMaxLive int `hcl:\"max_live_agents\"`\r\n\tEnvironment Environment `hcl:\"environment,block\"`\r\n\tAgents []Agent `hcl:\"agent,block\"`\r\n\tEvents []Event `hcl:\"event,block\"`\r\n\tWeb *Web `hcl:\"web,block\"`\r\n}\r\n\r\n// Web configures the dashboard's optional bearer-token gate. Leave the\r\n// block out of zoo.hcl entirely to run without one (fine on localhost;\r\n// put a real gate or a proxy in front for anything else).\r\ntype Web struct {\r\n\tToken string `hcl:\"token,optional\"`\r\n}\r\n\r\ntype LLM struct {\r\n\tName string `hcl:\"name,label\"`\r\n\tOpenAI string `hcl:\"openai\"`\r\n\tToken string `hcl:\"token\"`\r\n\tModel string `hcl:\"model\"`\r\n}\r\n\r\ntype Forgejo struct {\r\n\tURL string `hcl:\"url\"`\r\n\tToken string `hcl:\"token\"`\r\n\tWebhookSecret string `hcl:\"webhook_secret,optional\"`\r\n}\r\n\r\ntype Environment struct {\r\n\tDockerImage string `hcl:\"docker_image\"`\r\n}\r\n\r\ntype Agent struct {\r\n\tName string `hcl:\"name,label\"`\r\n\tLLM string `hcl:\"llm\"`\r\n}\r\n\r\ntype Event struct {\r\n\tKind string `hcl:\"name,label\"`\r\n\tAgent string `hcl:\"agent,optional\"`\r\n\tInstructions string `hcl:\"instructions,optional\"`\r\n}\r\n\r\n// Load reads and validates the config file at path.\r\nfunc Load(path string) (*Config, error) {\r\n\tvar cfg Config\r\n\r\n\tif err := hclsimple.DecodeFile(path, nil, \u0026cfg); err != nil {\r\n\t\treturn nil, fmt.Errorf(\"parse config: %w\", err)\r\n\t}\r\n\r\n\tif err := cfg.Validate(); err != nil {\r\n\t\treturn nil, fmt.Errorf(\"invalid config: %w\", err)\r\n\t}\r\n\r\n\treturn \u0026cfg, nil\r\n}\r\n\r\n// Validate checks that the config is internally consistent: every\r\n// reference between blocks resolves, and required values are set.\r\nfunc (c *Config) Validate() error {\r\n\tllmNames := make(map[string]bool, len(c.LLMs))\r\n\tfor _, l := range c.LLMs {\r\n\t\tif l.OpenAI == \"\" || l.Token == \"\" || l.Model == \"\" {\r\n\t\t\treturn fmt.Errorf(\"llm %q: openai, token, and model are required\", l.Name)\r\n\t\t}\r\n\t\tllmNames[l.Name] = true\r\n\t}\r\n\r\n\tif c.Forgejo.URL == \"\" || c.Forgejo.Token == \"\" {\r\n\t\treturn fmt.Errorf(\"forgejo: url and token are required\")\r\n\t}\r\n\r\n\tif c.MaxLive \u003c 1 {\r\n\t\treturn fmt.Errorf(\"max_live_agents must be \u003e= 1, got %d\", c.MaxLive)\r\n\t}\r\n\r\n\tif c.Environment.DockerImage == \"\" {\r\n\t\treturn fmt.Errorf(\"environment: docker_image is required\")\r\n\t}\r\n\r\n\tagentNames := make(map[string]bool, len(c.Agents))\r\n\tfor _, a := range c.Agents {\r\n\t\tif !llmNames[a.LLM] {\r\n\t\t\treturn fmt.Errorf(\"agent %q: references undeclared llm %q\", a.Name, a.LLM)\r\n\t\t}\r\n\t\tagentNames[a.Name] = true\r\n\t}\r\n\r\n\tseenEventKinds := make(map[string]bool, len(c.Events))\r\n\tfor _, e := range c.Events {\r\n\t\tif seenEventKinds[e.Kind] {\r\n\t\t\treturn fmt.Errorf(\"event %q: declared more than once\", e.Kind)\r\n\t\t}\r\n\t\tseenEventKinds[e.Kind] = true\r\n\r\n\t\tswitch {\r\n\t\tcase e.Kind == EventIssueAssigned:\r\n\t\t\tif e.Agent != \"\" {\r\n\t\t\t\treturn fmt.Errorf(\"event %q: must not set agent; the agent whose name matches the Forgejo assignee's username is triggered dynamically\", e.Kind)\r\n\t\t\t}\r\n\r\n\t\tcase staticEventKinds[e.Kind]:\r\n\t\t\tif e.Agent == \"\" {\r\n\t\t\t\treturn fmt.Errorf(\"event %q: agent is required\", e.Kind)\r\n\t\t\t}\r\n\t\t\tif !agentNames[e.Agent] {\r\n\t\t\t\treturn fmt.Errorf(\"event %q: references undeclared agent %q\", e.Kind, e.Agent)\r\n\t\t\t}\r\n\r\n\t\tdefault:\r\n\t\t\treturn fmt.Errorf(\"event %q: unknown event kind\", e.Kind)\r\n\t\t}\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// AgentByName returns the agent config with the given name, if declared.\r\nfunc (c *Config) AgentByName(name string) (Agent, bool) {\r\n\tfor _, a := range c.Agents {\r\n\t\tif a.Name == name {\r\n\t\t\treturn a, true\r","start_line":1,"end_line":150,"total_lines":191,"truncated":true}
{"path":"internal/forgejo/client.go","start_line":1,"end_line":100}
{"path":"internal/forgejo/client.go","content":"// Package forgejo wraps the Forgejo SDK and turns webhook deliveries and\r\n// polling results into a common Event stream for the scheduler.\r\npackage forgejo\r\n\r\nimport (\r\n\t\"fmt\"\r\n\r\n\tsdk \"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2\"\r\n\r\n\t\"github.com/abrander/zoo/internal/config\"\r\n)\r\n\r\n// Client is zoo's single shared Forgejo identity, used both for the\r\n// event sources (webhook/poll) and for actions agents/scheduler take\r\n// (comments, labels, PRs).\r\ntype Client struct {\r\n\tsdk *sdk.Client\r\n\r\n\tbaseURL string\r\n\ttoken string\r\n}\r\n\r\nfunc NewClient(cfg config.Forgejo) (*Client, error) {\r\n\tc, err := sdk.NewClient(cfg.URL, sdk.SetToken(cfg.Token))\r\n\tif err != nil {\r\n\t\treturn nil, fmt.Errorf(\"forgejo client: %w\", err)\r\n\t}\r\n\r\n\treturn \u0026Client{sdk: c, baseURL: cfg.URL, token: cfg.Token}, nil\r\n}\r\n\r\n// Token returns the shared zoo Forgejo identity's token, e.g. for\r\n// authenticating a host-side git clone/push against Forgejo (see\r\n// internal/agentrun) without ever writing the credential into a working\r\n// tree an agent's container can read.\r\nfunc (c *Client) Token() string {\r\n\treturn c.token\r\n}\r\n\r\n// Sudo returns a new Client that impersonates username (via Forgejo's\r\n// \"Sudo:\" header) on every API call it makes, using the same underlying\r\n// token. Actions an agent takes through it — comments, labels, PRs,\r\n// assignment — are attributed to that agent's own Forgejo account\r\n// instead of the shared zoo identity. The token must belong to a user\r\n// with sudo scope/admin rights for this to work; Forgejo rejects the\r\n// header otherwise.\r\n//\r\n// The SDK's Sudo setting lives on the *sdk.Client itself and isn't\r\n// safe to flip per-request on a shared client under concurrent agent\r\n// runs, so this constructs a separate client rather than mutating one.\r\nfunc (c *Client) Sudo(username string) (*Client, error) {\r\n\tsudoClient, err := sdk.NewClient(c.baseURL, sdk.SetToken(c.token), sdk.SetSudo(username))\r\n\tif err != nil {\r\n\t\treturn nil, fmt.Errorf(\"forgejo client sudo %q: %w\", username, err)\r\n\t}\r\n\r\n\treturn \u0026Client{sdk: sudoClient, baseURL: c.baseURL, token: c.token}, nil\r\n}\r\n\r\n// CreateIssueComment posts a comment on the given issue or pull request\r\n// (Forgejo/Gitea treat PRs as issues for commenting purposes).\r\nfunc (c *Client) CreateIssueComment(owner, repo string, index int64, body string) error {\r\n\t_, _, err := c.sdk.CreateIssueComment(owner, repo, index, sdk.CreateIssueCommentOption{Body: body})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"comment on %s/%s#%d: %w\", owner, repo, index, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// AddLabel attaches the label with the given name to an issue/PR,\r\n// creating the label (with a default color) on the repo first if it\r\n// doesn't already exist.\r\nfunc (c *Client) AddLabel(owner, repo string, index int64, name string) error {\r\n\tid, err := c.labelID(owner, repo, name)\r\n\tif err != nil {\r\n\t\treturn err\r\n\t}\r\n\r\n\t_, _, err = c.sdk.AddIssueLabels(owner, repo, index, sdk.IssueLabelsOption{Labels: []int64{id}})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"add label %q to %s/%s#%d: %w\", name, owner, repo, index, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// RemoveLabel detaches the label with the given name from an issue/PR, if\r\n// both the label and the attachment exist.\r\nfunc (c *Client) RemoveLabel(owner, repo string, index int64, name string) error {\r\n\tlabels, _, err := c.sdk.GetIssueLabels(owner, repo, index, sdk.ListLabelsOptions{})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"list labels on %s/%s#%d: %w\", owner, repo, index, err)\r\n\t}\r\n\r\n\tfor _, l := range labels {\r\n\t\tif l.Name == name {\r\n\t\t\t_, err := c.sdk.DeleteIssueLabel(owner, repo, index, l.ID)\r\n\t\t\tif err != nil {\r\n\t\t\t\treturn fmt.Errorf(\"remove label %q from %s/%s#%d: %w\", name, owner, repo, index, err)\r","start_line":1,"end_line":100,"total_lines":232,"truncated":true}
{"path":"internal/agentrun/forgejo.go","start_line":1,"end_line":100}
{"path":"internal/agentrun/forgejo.go","content":"package agentrun\r\n\r\nimport (\r\n\t\"log/slog\"\r\n\r\n\t\"github.com/abrander/zoo/internal/forgejo\"\r\n)\r\n\r\n// runForgejoActions implements tools.ForgejoActions, binding the\r\n// generic forgejo.Client to the single issue/PR that triggered this run\r\n// so agent tool calls don't need to specify owner/repo/index themselves.\r\ntype runForgejoActions struct {\r\n\tclient *forgejo.Client\r\n\towner, repo string\r\n\tindex int64\r\n\tlogger *slog.Logger\r\n}\r\n\r\n// logResult logs a forge action once it's known to have succeeded or\r\n// failed, so every comment, PR, label, or issue-state change an agent\r\n// makes is traceable in zoo's own logs, not just in Forgejo's history.\r\nfunc (a *runForgejoActions) logResult(action string, err error, args ...any) {\r\n\tfields := append([]any{\"owner\", a.owner, \"repo\", a.repo, \"index\", a.index}, args...)\r\n\r\n\tif err != nil {\r\n\t\ta.logger.Warn(\"forge action failed\", append(append([]any{\"action\", action}, fields...), \"error\", err)...)\r\n\t\treturn\r\n\t}\r\n\r\n\ta.logger.Info(\"forge action\", append([]any{\"action\", action}, fields...)...)\r\n}\r\n\r\nfunc (a *runForgejoActions) Comment(body string) error {\r\n\terr := a.client.CreateIssueComment(a.owner, a.repo, a.index, body)\r\n\ta.logResult(\"comment\", err)\r\n\r\n\treturn err\r\n}\r\n\r\nfunc (a *runForgejoActions) OpenPullRequest(head, base, title, body string) error {\r\n\terr := a.client.CreatePullRequest(a.owner, a.repo, head, base, title, body)\r\n\ta.logResult(\"open_pull_request\", err, \"head\", head, \"base\", base, \"title\", title)\r\n\r\n\treturn err\r\n}\r\n\r\nfunc (a *runForgejoActions) RequestReview(reviewers []string) error {\r\n\terr := a.client.RequestReview(a.owner, a.repo, a.index, reviewers)\r\n\ta.logResult(\"request_review\", err, \"reviewers\", reviewers)\r\n\r\n\treturn err\r\n}\r\n\r\nfunc (a *runForgejoActions) AddLabel(name string) error {\r\n\terr := a.client.AddLabel(a.owner, a.repo, a.index, name)\r\n\ta.logResult(\"add_label\", err, \"label\", name)\r\n\r\n\treturn err\r\n}\r\n\r\nfunc (a *runForgejoActions) RemoveLabel(name string) error {\r\n\terr := a.client.RemoveLabel(a.owner, a.repo, a.index, name)\r\n\ta.logResult(\"remove_label\", err, \"label\", name)\r\n\r\n\treturn err\r\n}\r\n\r\nfunc (a *runForgejoActions) CloseIssue() error {\r\n\terr := a.client.CloseIssue(a.owner, a.repo, a.index)\r\n\ta.logResult(\"close_issue\", err)\r\n\r\n\treturn err\r\n}\r\n\r\nfunc (a *runForgejoActions) ReopenIssue() error {\r\n\terr := a.client.ReopenIssue(a.owner, a.repo, a.index)\r\n\ta.logResult(\"reopen_issue\", err)\r\n\r\n\treturn err\r\n}\r\n\r\nfunc (a *runForgejoActions) AssignIssue(agentName string) error {\r\n\terr := a.client.AssignIssue(a.owner, a.repo, a.index, agentName)\r\n\ta.logResult(\"assign_issue\", err, \"assignee\", agentName)\r\n\r\n\treturn err\r\n}\r","start_line":1,"end_line":87,"total_lines":87,"truncated":false}
{"path":"internal/agentrun/run.go","start_line":1,"end_line":150}
{"path":"internal/agentrun/run.go","content":"// Package agentrun creates the throwaway per-job environment an agent\r\n// runs in: a git clone + branch bind-mounted into a Docker container,\r\n// the triggering event written to /event, and the tool-calling loop\r\n// (internal/llm + internal/tools) driven against it.\r\npackage agentrun\r\n\r\nimport (\r\n\t\"context\"\r\n\t_ \"embed\"\r\n\t\"encoding/json\"\r\n\t\"fmt\"\r\n\t\"log/slog\"\r\n\t\"os\"\r\n\t\"path/filepath\"\r\n\t\"strings\"\r\n\t\"sync\"\r\n\t\"time\"\r\n\r\n\t\"github.com/abrander/zoo/internal/config\"\r\n\t\"github.com/abrander/zoo/internal/forgejo\"\r\n\t\"github.com/abrander/zoo/internal/livelog\"\r\n\t\"github.com/abrander/zoo/internal/llm\"\r\n\t\"github.com/abrander/zoo/internal/store\"\r\n)\r\n\r\n//go:embed system.md\r\nvar defaultSystemPrompt string\r\n\r\n// DefaultTimeout bounds a single agent run's wall-clock time if the\r\n// caller doesn't override it.\r\nconst DefaultTimeout = 120 * time.Minute\r\n\r\ntype Runner struct {\r\n\tdocker *dockerRuntime\r\n\tforgejo *forgejo.Client\r\n\tstore *store.Store\r\n\thub *livelog.Hub\r\n\tcfg *config.Config\r\n\tlogger *slog.Logger\r\n\ttimeout time.Duration\r\n\tkeepOnFailure bool\r\n\r\n\tagentClientsMu sync.Mutex\r\n\tagentClients map[string]*forgejo.Client\r\n}\r\n\r\nfunc NewRunner(cfg *config.Config, fg *forgejo.Client, st *store.Store, hub *livelog.Hub, logger *slog.Logger, timeout time.Duration, keepOnFailure bool) (*Runner, error) {\r\n\tdocker, err := newDockerRuntime()\r\n\tif err != nil {\r\n\t\treturn nil, err\r\n\t}\r\n\r\n\tif timeout \u003c= 0 {\r\n\t\ttimeout = DefaultTimeout\r\n\t}\r\n\r\n\treturn \u0026Runner{\r\n\t\tdocker: docker,\r\n\t\tforgejo: fg,\r\n\t\tstore: st,\r\n\t\thub: hub,\r\n\t\tcfg: cfg,\r\n\t\tlogger: logger,\r\n\t\ttimeout: timeout,\r\n\t\tkeepOnFailure: keepOnFailure,\r\n\t\tagentClients: make(map[string]*forgejo.Client),\r\n\t}, nil\r\n}\r\n\r\n// forgejoAs returns a Forgejo client that impersonates agentName (via\r\n// Sudo:) for every API call it makes, so an agent's actions — comments,\r\n// labels, PRs, assignment — are attributed to its own Forgejo account\r\n// rather than zoo's shared identity. Clients are built once per agent\r\n// and cached, since constructing one costs an extra API round trip.\r\n// Sudo requires the configured forgejo.token to have admin/sudo rights;\r\n// if it doesn't, this logs a warning and falls back to the shared\r\n// identity rather than failing the run outright.\r\nfunc (r *Runner) forgejoAs(agentName string) *forgejo.Client {\r\n\tr.agentClientsMu.Lock()\r\n\tdefer r.agentClientsMu.Unlock()\r\n\r\n\tif c, ok := r.agentClients[agentName]; ok {\r\n\t\treturn c\r\n\t}\r\n\r\n\tc, err := r.forgejo.Sudo(agentName)\r\n\tif err != nil {\r\n\t\tr.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)\r\n\r\n\t\tc = r.forgejo\r\n\t}\r\n\r\n\tr.agentClients[agentName] = c\r\n\r\n\treturn c\r\n}\r\n\r\n// Run implements scheduler.Runner.\r\nfunc (r *Runner) Run(ctx context.Context, jobID string, agent config.Agent, llmCfg config.LLM, dockerImage string, ev forgejo.Event) error {\r\n\tctx, cancel := context.WithTimeout(ctx, r.timeout)\r\n\tdefer cancel()\r\n\r\n\tlogger := r.logger.With(\"job\", jobID, \"agent\", agent.Name)\r\n\r\n\trepoInfo, err := r.forgejo.RepositoryInfo(ev.Owner, ev.Repo)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"look up repository: %w\", err)\r\n\t}\r\n\r\n\tworkDir, err := os.MkdirTemp(\"\", \"zoo-run-*\")\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"create work dir: %w\", err)\r\n\t}\r\n\r\n\tsucceeded := false\r\n\r\n\tdefer func() {\r\n\t\tif succeeded || !r.keepOnFailure {\r\n\t\t\tos.RemoveAll(workDir)\r\n\t\t} else {\r\n\t\t\tlogger.Warn(\"keeping work dir after failure\", \"dir\", workDir)\r\n\t\t}\r\n\t}()\r\n\r\n\tbranch := fmt.Sprintf(\"issue-%d-%s\", ev.Index, agent.Name)\r\n\tprojectDir := filepath.Join(workDir, \"project\")\r\n\r\n\tif err := cloneAndBranch(ctx, repoInfo.CloneURL, r.forgejo.Token(), repoInfo.DefaultBranch, branch, projectDir); err != nil {\r\n\t\treturn fmt.Errorf(\"prepare git working tree: %w\", err)\r\n\t}\r\n\r\n\troster := buildRoster(r.forgejo, r.cfg.Agents, logger)\r\n\tgitName, gitEmail := gitIdentity(agent.Name, roster)\r\n\r\n\t// Local (not --global) scope, so this identity lives in\r\n\t// projectDir/.git/config: the one place both this host-side clone\r\n\t// and the container it's bind-mounted into (as /project) actually\r\n\t// share.\r\n\tif out, err := runGit(ctx, projectDir, \"config\", \"user.name\", gitName); err != nil {\r\n\t\treturn fmt.Errorf(\"configure git user.name: %w: %s\", err, out)\r\n\t}\r\n\tif out, err := runGit(ctx, projectDir, \"config\", \"user.email\", gitEmail); err != nil {\r\n\t\treturn fmt.Errorf(\"configure git user.email: %w: %s\", err, out)\r\n\t}\r\n\r\n\teventPath := filepath.Join(workDir, \"event.json\")\r\n\tif err := os.WriteFile(eventPath, ev.Raw, 0o644); err != nil {\r\n\t\treturn fmt.Errorf(\"write event file: %w\", err)\r\n\t}\r\n\r","start_line":1,"end_line":150,"total_lines":308,"truncated":true}
{"path":"internal/config/config.go","start_line":150,"end_line":191}
{"path":"internal/config/config.go","content":"\t\t\treturn a, true\r\n\t\t}\r\n\t}\r\n\r\n\treturn Agent{}, false\r\n}\r\n\r\n// LLMByName returns the llm config with the given name, if declared.\r\nfunc (c *Config) LLMByName(name string) (LLM, bool) {\r\n\tfor _, l := range c.LLMs {\r\n\t\tif l.Name == name {\r\n\t\t\treturn l, true\r\n\t\t}\r\n\t}\r\n\r\n\treturn LLM{}, false\r\n}\r\n\r\n// EventAgent returns the statically configured agent name for the given\r\n// event kind, if one is declared and set.\r\nfunc (c *Config) EventAgent(kind string) (string, bool) {\r\n\tfor _, e := range c.Events {\r\n\t\tif e.Kind == kind {\r\n\t\t\treturn e.Agent, e.Agent != \"\"\r\n\t\t}\r\n\t}\r\n\r\n\treturn \"\", false\r\n}\r\n\r\n// EventInstructions returns the configured freeform instructions for the\r\n// given event kind, e.g. \"Please review this pull request and respond\r\n// appropriately.\" Returns \"\" if the event isn't declared or has none.\r\nfunc (c *Config) EventInstructions(kind string) string {\r\n\tfor _, e := range c.Events {\r\n\t\tif e.Kind == kind {\r\n\t\t\treturn e.Instructions\r\n\t\t}\r\n\t}\r\n\r\n\treturn \"\"\r\n}\r","start_line":150,"end_line":191,"total_lines":191,"truncated":false}
{"query":"\\.Sudo\\(","glob":"*.go"}
grep: Unmatched ( or \(
{"query":"Sudo","glob":"*.go"}
/project/internal/forgejo/client.go:40:// Sudo returns a new Client that impersonates username (via Forgejo's
/project/internal/forgejo/client.go:41:// "Sudo:" header) on every API call it makes, using the same underlying
/project/internal/forgejo/client.go:45:// with sudo scope/admin rights for this to work; Forgejo rejects the
/project/internal/forgejo/client.go:48:// The SDK's Sudo setting lives on the *sdk.Client itself and isn't
/project/internal/forgejo/client.go:51:func (c *Client) Sudo(username string) (*Client, error) {
/project/internal/forgejo/client.go:52: sudoClient, err := sdk.NewClient(c.baseURL, sdk.SetToken(c.token), sdk.SetSudo(username))
/project/internal/forgejo/client.go:54: return nil, fmt.Errorf("forgejo client sudo %q: %w", username, err)
/project/internal/forgejo/client.go:57: return &Client{sdk: sudoClient, baseURL: c.baseURL, token: c.token}, nil
/project/internal/agentrun/run.go:71:// Sudo:) for every API call it makes, so an agent's actions — comments,
/project/internal/agentrun/run.go:75:// Sudo requires the configured forgejo.token to have admin/sudo rights;
/project/internal/agentrun/run.go:86: c, err := r.forgejo.Sudo(agentName)
/project/internal/agentrun/run.go:88: 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)
/project/internal/scheduler/scheduler.go:106: // tool, attributed to it by Sudo) can themselves show up as new
{"path":"internal/agentrun/run.go","start_line":150,"end_line":308}
{"path":"internal/agentrun/run.go","content":"\r\n\tcontainerID, err := r.docker.createContainer(ctx, dockerImage, []string{\r\n\t\tprojectDir + \":/project\",\r\n\t\teventPath + \":/event:ro\",\r\n\t}, fmt.Sprintf(\"zoo-issue-%d-%s\", ev.Index, agent.Name))\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"start container: %w\", err)\r\n\t}\r\n\r\n\tdefer func() {\r\n\t\tcleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)\r\n\t\tdefer cleanupCancel()\r\n\t\tif err := r.docker.remove(cleanupCtx, containerID); err != nil {\r\n\t\t\tlogger.Warn(\"failed to remove container\", \"container\", containerID, \"error\", err)\r\n\t\t}\r\n\t}()\r\n\r\n\t// /project is bind-mounted from the host, so it's owned by the host\r\n\t// UID that ran the clone, not whatever UID runs inside the\r\n\t// container (usually root) — git's ownership check rejects that by\r\n\t// default (\"detected dubious ownership\") unless told otherwise.\r\n\t// --system (not --global) so this holds regardless of which user\r\n\t// subsequent `docker exec` calls run as. Commit identity is\r\n\t// configured host-side, above, with --local scope so it's visible\r\n\t// from both sides of the bind mount without needing --global here.\r\n\tout, exitCode, err := r.docker.exec(ctx, containerID, \"git config --system --add safe.directory '*'\")\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"configure git safe.directory in container: %w: %s\", err, out)\r\n\t}\r\n\tif exitCode != 0 {\r\n\t\treturn fmt.Errorf(\"configure git safe.directory in container: exit %d: %s\", exitCode, out)\r\n\t}\r\n\r\n\tlogAppend := func(stream, line string) {\r\n\t\tif err := r.store.AppendLog(context.Background(), jobID, stream, line); err != nil {\r\n\t\t\tlogger.Warn(\"failed to append log\", \"error\", err)\r\n\t\t}\r\n\t}\r\n\r\n\trunCtx := \u0026runContext{\r\n\t\tdocker: r.docker,\r\n\t\tcontainerID: containerID,\r\n\t\tprojectDir: projectDir,\r\n\t\ttoken: r.forgejo.Token(),\r\n\t\tforgejo: \u0026runForgejoActions{\r\n\t\t\tclient: r.forgejoAs(agent.Name),\r\n\t\t\towner: ev.Owner,\r\n\t\t\trepo: ev.Repo,\r\n\t\t\tindex: ev.Index,\r\n\t\t\tlogger: logger,\r\n\t\t},\r\n\t}\r\n\r\n\tllmClient := llm.NewClient(llmCfg)\r\n\r\n\tsystemPrompt := defaultSystemPrompt + identitySection(agent.Name, roster)\r\n\r\n\tinstructions := r.cfg.EventInstructions(ev.Kind)\r\n\r\n\tmessages := []llm.Message{\r\n\t\t{Role: \"system\", Content: systemPrompt},\r\n\t\t{Role: \"user\", Content: seedMessage(ev, branch, repoInfo.DefaultBranch, instructions)},\r\n\t}\r\n\r\n\thooks := r.streamHooks(jobID, logAppend)\r\n\r\n\t_, err = runLoop(ctx, llmClient, runCtx, messages, hooks)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"agent loop: %w\", err)\r\n\t}\r\n\r\n\tsucceeded = true\r\n\r\n\treturn nil\r\n}\r\n\r\n// streamHooks builds the Hooks a single Run passes to runLoop: every\r\n// delta is published live to the hub for connected dashboard viewers,\r\n// and once a reasoning/content block or tool call is complete, it's\r\n// persisted to the store as one row and the hub's replay buffer for\r\n// jobID is checkpointed — so a viewer connecting from this point on\r\n// sees it via the persisted history instead of a live replay, and is\r\n// never shown it twice.\r\nfunc (r *Runner) streamHooks(jobID string, logAppend func(stream, line string)) Hooks {\r\n\tvar reasoningBuf, contentBuf strings.Builder\r\n\r\n\treasoningOpen, contentOpen := false, false\r\n\r\n\treturn Hooks{\r\n\t\tOnReasoningDelta: func(delta string) {\r\n\t\t\tif !reasoningOpen {\r\n\t\t\t\treasoningOpen = true\r\n\t\t\t\treasoningBuf.Reset()\r\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningStart})\r\n\t\t\t}\r\n\r\n\t\t\treasoningBuf.WriteString(delta)\r\n\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningDelta, Text: delta})\r\n\t\t},\r\n\t\tOnContentDelta: func(delta string) {\r\n\t\t\tif !contentOpen {\r\n\t\t\t\tcontentOpen = true\r\n\t\t\t\tcontentBuf.Reset()\r\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})\r\n\t\t\t}\r\n\r\n\t\t\tcontentBuf.WriteString(delta)\r\n\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})\r\n\t\t},\r\n\t\tOnTurnEnd: func() {\r\n\t\t\tif reasoningOpen {\r\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningEnd})\r\n\t\t\t\tlogAppend(\"reasoning\", reasoningBuf.String())\r\n\t\t\t\tr.hub.Checkpoint(jobID)\r\n\t\t\t\treasoningOpen = false\r\n\t\t\t}\r\n\r\n\t\t\tif contentOpen {\r\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ContentEnd})\r\n\t\t\t\tlogAppend(\"content\", contentBuf.String())\r\n\t\t\t\tr.hub.Checkpoint(jobID)\r\n\t\t\t\tcontentOpen = false\r\n\t\t\t}\r\n\t\t},\r\n\t\tOnTool: func(name, arguments, result string, toolErr bool) {\r\n\t\t\tr.hub.Publish(jobID, livelog.Event{\r\n\t\t\t\tType: livelog.Tool,\r\n\t\t\t\tName: name,\r\n\t\t\t\tArguments: arguments,\r\n\t\t\t\tResult: result,\r\n\t\t\t\tError: toolErr,\r\n\t\t\t})\r\n\r\n\t\t\tline, err := json.Marshal(store.ToolLogEntry{Name: name, Arguments: arguments, Result: result, Error: toolErr})\r\n\t\t\tif err != nil {\r\n\t\t\t\tr.logger.Warn(\"failed to marshal tool log entry\", \"job\", jobID, \"error\", err)\r\n\t\t\t} else {\r\n\t\t\t\tlogAppend(\"tool\", string(line))\r\n\t\t\t}\r\n\r\n\t\t\tr.hub.Checkpoint(jobID)\r\n\t\t},\r\n\t}\r\n}\r\n\r\nfunc seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string) string {\r\n\traw, _ := json.MarshalIndent(json.RawMessage(ev.Raw), \"\", \" \")\r\n\r\n\tvar instructionsSection string\r\n\tif instructions != \"\" {\r\n\t\tinstructionsSection = fmt.Sprintf(\"Instructions for this event, from zoo.hcl:\\n%s\\n\\n\", instructions)\r\n\t}\r\n\r\n\treturn fmt.Sprintf(\r\n\t\t\"You were triggered by a %q event on %s/%s.\\n\\n\"+\r\n\t\t\t\"%sYour working branch is %q, checked out from the default branch %q.\\n\\n\"+\r\n\t\t\t\"Title: %s\\n\\nBody:\\n%s\\n\\nFull event payload:\\n```json\\n%s\\n```\",\r\n\t\tev.Kind, ev.Owner, ev.Repo, instructionsSection, branch, defaultBranch, ev.Title, ev.Body, raw)\r\n}\r","start_line":150,"end_line":308,"total_lines":308,"truncated":false}
{"path":"internal/config/config_test.go","start_line":1,"end_line":100}
{"path":"internal/config/config_test.go","content":"package config\r\n\r\nimport (\r\n\t\"os\"\r\n\t\"path/filepath\"\r\n\t\"testing\"\r\n)\r\n\r\nfunc load(t *testing.T, hcl string) (*Config, error) {\r\n\tt.Helper()\r\n\r\n\tpath := filepath.Join(t.TempDir(), \"zoo.hcl\")\r\n\tif err := os.WriteFile(path, []byte(hcl), 0o600); err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\treturn Load(path)\r\n}\r\n\r\nconst validConfig = `\r\nllm \"qwen\" {\r\n openai = \"https://example.com\"\r\n token = \"tok\"\r\n model = \"qwen3\"\r\n}\r\n\r\nforgejo {\r\n url = \"https://example.com\"\r\n token = \"tok\"\r\n}\r\n\r\nmax_live_agents = 2\r\n\r\nenvironment {\r\n docker_image = \"debian:unstable\"\r\n}\r\n\r\nagent \"leon\" {\r\n llm = \"qwen\"\r\n}\r\n\r\nevent \"issue:new\" {\r\n agent = \"leon\"\r\n}\r\n\r\nevent \"issue:assigned\" {\r\n instructions = \"Please handle this issue.\"\r\n}\r\n\r\nevent \"issue:comment\" {\r\n agent = \"leon\"\r\n instructions = \"Please review the comment and respond appropriately.\"\r\n}\r\n\r\nevent \"pr:new\" {\r\n agent = \"leon\"\r\n}\r\n`\r\n\r\nfunc TestLoadValid(t *testing.T) {\r\n\tcfg, err := load(t, validConfig)\r\n\tif err != nil {\r\n\t\tt.Fatalf(\"unexpected error: %v\", err)\r\n\t}\r\n\r\n\tif len(cfg.LLMs) != 1 || cfg.LLMs[0].Name != \"qwen\" {\r\n\t\tt.Fatalf(\"unexpected llms: %+v\", cfg.LLMs)\r\n\t}\r\n\r\n\tif agent, ok := cfg.EventAgent(EventIssueNew); !ok || agent != \"leon\" {\r\n\t\tt.Fatalf(\"expected issue:new -\u003e leon, got %q, %v\", agent, ok)\r\n\t}\r\n\r\n\tif _, ok := cfg.EventAgent(EventIssueAssigned); ok {\r\n\t\tt.Fatalf(\"issue:assigned should have no static agent\")\r\n\t}\r\n\r\n\tif got := cfg.EventInstructions(EventIssueAssigned); got != \"Please handle this issue.\" {\r\n\t\tt.Fatalf(\"unexpected issue:assigned instructions: %q\", got)\r\n\t}\r\n\tif got := cfg.EventInstructions(EventIssueComment); got != \"Please review the comment and respond appropriately.\" {\r\n\t\tt.Fatalf(\"unexpected issue:comment instructions: %q\", got)\r\n\t}\r\n\tif got := cfg.EventInstructions(EventPRNew); got != \"\" {\r\n\t\tt.Fatalf(\"expected no instructions for pr:new, got %q\", got)\r\n\t}\r\n}\r\n\r\nfunc TestValidateUndeclaredLLM(t *testing.T) {\r\n\t_, err := load(t, `\r\nforgejo { url = \"https://example.com\" token = \"tok\" }\r\nmax_live_agents = 1\r\nenvironment { docker_image = \"debian:unstable\" }\r\nagent \"leon\" { llm = \"missing\" }\r\n`)\r\n\tif err == nil {\r\n\t\tt.Fatal(\"expected error for undeclared llm reference\")\r\n\t}\r\n}\r\n\r","start_line":1,"end_line":100,"total_lines":165,"truncated":true}
{"path":"internal/agentrun/clone.go","start_line":1,"end_line":50}
{"path":"internal/agentrun/clone.go","content":"package agentrun\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"encoding/base64\"\r\n\t\"fmt\"\r\n\t\"os/exec\"\r\n\t\"strings\"\r\n)\r\n\r\n// gitAuthArgs returns the `-c http.extraHeader=...` global git arguments\r\n// that authenticate a single invocation as the shared zoo Forgejo\r\n// identity. Unlike embedding the token in the remote URL, a `-c`\r\n// override lives only for the lifetime of that one process and is never\r\n// written to .git/config on disk — where the container bind-mounting\r\n// this working tree (and any command an agent runs inside it via the\r\n// bash tool) could otherwise read it straight back out.\r\nfunc gitAuthArgs(token string) []string {\r\n\tauth := base64.StdEncoding.EncodeToString([]byte(\"zoo:\" + token))\r\n\r\n\treturn []string{\"-c\", \"http.extraHeader=Authorization: Basic \" + auth}\r\n}\r\n\r\n// cloneAndBranch clones cloneURL into dir, authenticating the clone\r\n// itself with token (see gitAuthArgs), and checks out a new branch off\r\n// of defaultBranch. token never ends up in the resulting working tree:\r\n// the origin remote is left with the plain cloneURL.\r\nfunc cloneAndBranch(ctx context.Context, cloneURL, token, defaultBranch, branch, dir string) error {\r\n\targs := append(gitAuthArgs(token), \"clone\", \"--branch\", defaultBranch, \"--single-branch\", cloneURL, dir)\r\n\tif out, err := runGit(ctx, \"\", args...); err != nil {\r\n\t\treturn fmt.Errorf(\"clone: %w: %s\", err, out)\r\n\t}\r\n\r\n\tif out, err := runGit(ctx, dir, \"checkout\", \"-b\", branch); err != nil {\r\n\t\treturn fmt.Errorf(\"checkout branch %q: %w: %s\", branch, err, out)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// runGit runs a git subcommand on the host (not inside any container)\r\n// with no Forgejo credential attached.\r\nfunc runGit(ctx context.Context, dir string, args ...string) (string, error) {\r\n\tcmd := exec.CommandContext(ctx, \"git\", args...)\r\n\tif dir != \"\" {\r\n\t\tcmd.Dir = dir\r\n\t}\r\n\r\n\tout, err := cmd.CombinedOutput()\r\n\r","start_line":1,"end_line":50,"total_lines":58,"truncated":true}
{"path":"internal/scheduler/scheduler.go","start_line":1,"end_line":120}
{"path":"internal/scheduler/scheduler.go","content":"// Package scheduler resolves incoming Forgejo events to configured\r\n// agents and runs them, bounded by max_live_agents.\r\npackage scheduler\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"errors\"\r\n\t\"fmt\"\r\n\t\"log/slog\"\r\n\t\"sync\"\r\n\r\n\t\"github.com/google/uuid\"\r\n\r\n\t\"github.com/abrander/zoo/internal/config\"\r\n\t\"github.com/abrander/zoo/internal/forgejo\"\r\n\t\"github.com/abrander/zoo/internal/livelog\"\r\n\t\"github.com/abrander/zoo/internal/store\"\r\n)\r\n\r\n// forgejoActions is the narrow slice of Client the scheduler needs for\r\n// its own failure-reporting side effects (defined here, not in\r\n// internal/forgejo, so tests can inject a fake).\r\ntype forgejoActions interface {\r\n\tCreateIssueComment(owner, repo string, index int64, body string) error\r\n\tAddLabel(owner, repo string, index int64, name string) error\r\n}\r\n\r\n// FailureLabel is applied to the triggering issue/PR, alongside a\r\n// comment, whenever an agent run fails or times out.\r\nconst FailureLabel = \"zoo:failed\"\r\n\r\n// Runner runs a single agent invocation to completion. Implemented by\r\n// internal/agentrun.Run; a narrow interface here so the scheduler is\r\n// testable without Docker.\r\ntype Runner interface {\r\n\tRun(ctx context.Context, jobID string, agent config.Agent, llm config.LLM, dockerImage string, ev forgejo.Event) error\r\n}\r\n\r\ntype Scheduler struct {\r\n\tcfg *config.Config\r\n\tstore *store.Store\r\n\tforgejo forgejoActions\r\n\trunner Runner\r\n\thub *livelog.Hub\r\n\tlogger *slog.Logger\r\n\r\n\tsem chan struct{}\r\n\twg sync.WaitGroup\r\n}\r\n\r\nfunc New(cfg *config.Config, st *store.Store, fg forgejoActions, runner Runner, hub *livelog.Hub, logger *slog.Logger) *Scheduler {\r\n\treturn \u0026Scheduler{\r\n\t\tcfg: cfg,\r\n\t\tstore: st,\r\n\t\tforgejo: fg,\r\n\t\trunner: runner,\r\n\t\thub: hub,\r\n\t\tlogger: logger,\r\n\t\tsem: make(chan struct{}, cfg.MaxLive),\r\n\t}\r\n}\r\n\r\n// resolveAgent returns the name of the agent that should handle ev, if\r\n// any. issue:assigned resolves dynamically: the agent whose config label\r\n// matches the Forgejo assignee's username. Every other event kind uses\r\n// the static event-\u003eagent mapping from config.\r\nfunc resolveAgent(cfg *config.Config, ev forgejo.Event) (string, bool) {\r\n\tif ev.Kind == config.EventIssueAssigned {\r\n\t\tif _, ok := cfg.AgentByName(ev.Assignee); ok {\r\n\t\t\treturn ev.Assignee, true\r\n\t\t}\r\n\r\n\t\treturn \"\", false\r\n\t}\r\n\r\n\treturn cfg.EventAgent(ev.Kind)\r\n}\r\n\r\n// Run consumes events until ctx is canceled or the channel closes,\r\n// dispatching each to its resolved agent and blocking on the\r\n// max_live_agents semaphore before starting a run.\r\nfunc (s *Scheduler) Run(ctx context.Context, events \u003c-chan forgejo.Event) {\r\n\tfor {\r\n\t\tselect {\r\n\t\tcase \u003c-ctx.Done():\r\n\t\t\treturn\r\n\r\n\t\tcase ev, ok := \u003c-events:\r\n\t\t\tif !ok {\r\n\t\t\t\treturn\r\n\t\t\t}\r\n\r\n\t\t\ts.handle(ctx, ev)\r\n\t\t}\r\n\t}\r\n}\r\n\r\nfunc (s *Scheduler) handle(ctx context.Context, ev forgejo.Event) {\r\n\tagentName, ok := resolveAgent(s.cfg, ev)\r\n\tif !ok {\r\n\t\ts.logger.Debug(\"no agent resolved for event, dropping\", \"kind\", ev.Kind, \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index)\r\n\t\treturn\r\n\t}\r\n\r\n\t// An agent's own actions (e.g. a comment posted via the `comment`\r\n\t// tool, attributed to it by Sudo) can themselves show up as new\r\n\t// events. Don't let an agent trigger itself off its own activity —\r\n\t// that's a self-reinforcing loop, not new work.\r\n\tif ev.Author != \"\" \u0026\u0026 ev.Author == agentName {\r\n\t\ts.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)\r\n\t\treturn\r\n\t}\r\n\r\n\tagent, ok := s.cfg.AgentByName(agentName)\r\n\tif !ok {\r\n\t\ts.logger.Error(\"resolved agent not declared in config\", \"agent\", agentName)\r\n\t\treturn\r\n\t}\r\n\r\n\tllm, ok := s.cfg.LLMByName(agent.LLM)\r","start_line":1,"end_line":120,"total_lines":240,"truncated":true}
{"path":"internal/forgejo/client.go","start_line":100,"end_line":232}
{"path":"internal/forgejo/client.go","content":"\t\t\t\treturn fmt.Errorf(\"remove label %q from %s/%s#%d: %w\", name, owner, repo, index, err)\r\n\t\t\t}\r\n\t\t\treturn nil\r\n\t\t}\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\nfunc (c *Client) labelID(owner, repo, name string) (int64, error) {\r\n\tlabels, _, err := c.sdk.ListRepoLabels(owner, repo, sdk.ListLabelsOptions{})\r\n\tif err != nil {\r\n\t\treturn 0, fmt.Errorf(\"list labels on %s/%s: %w\", owner, repo, err)\r\n\t}\r\n\r\n\tfor _, l := range labels {\r\n\t\tif l.Name == name {\r\n\t\t\treturn l.ID, nil\r\n\t\t}\r\n\t}\r\n\r\n\tcreated, _, err := c.sdk.CreateLabel(owner, repo, sdk.CreateLabelOption{\r\n\t\tName: name,\r\n\t\tColor: \"#ee0000\",\r\n\t})\r\n\tif err != nil {\r\n\t\treturn 0, fmt.Errorf(\"create label %q on %s/%s: %w\", name, owner, repo, err)\r\n\t}\r\n\r\n\treturn created.ID, nil\r\n}\r\n\r\n// CreatePullRequest opens a PR from head into base.\r\nfunc (c *Client) CreatePullRequest(owner, repo, head, base, title, body string) error {\r\n\t_, _, err := c.sdk.CreatePullRequest(owner, repo, sdk.CreatePullRequestOption{\r\n\t\tHead: head,\r\n\t\tBase: base,\r\n\t\tTitle: title,\r\n\t\tBody: body,\r\n\t})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"create pull request %s/%s %s-\u003e%s: %w\", owner, repo, head, base, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// RequestReview asks the given users to review the pull request.\r\nfunc (c *Client) RequestReview(owner, repo string, index int64, reviewers []string) error {\r\n\t_, err := c.sdk.CreateReviewRequests(owner, repo, index, sdk.PullReviewRequestOptions{Reviewers: reviewers})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"request review on %s/%s#%d: %w\", owner, repo, index, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// CloseIssue closes the given issue or pull request.\r\nfunc (c *Client) CloseIssue(owner, repo string, index int64) error {\r\n\treturn c.setIssueState(owner, repo, index, sdk.StateClosed)\r\n}\r\n\r\n// ReopenIssue reopens the given issue or pull request.\r\nfunc (c *Client) ReopenIssue(owner, repo string, index int64) error {\r\n\treturn c.setIssueState(owner, repo, index, sdk.StateOpen)\r\n}\r\n\r\n// RepositoryInfo returns the pieces of repo metadata agentrun needs to\r\n// clone and branch off of the right place.\r\ntype RepositoryInfo struct {\r\n\tDefaultBranch string\r\n\tCloneURL string\r\n}\r\n\r\nfunc (c *Client) RepositoryInfo(owner, repo string) (RepositoryInfo, error) {\r\n\tr, _, err := c.sdk.GetRepo(owner, repo)\r\n\tif err != nil {\r\n\t\treturn RepositoryInfo{}, fmt.Errorf(\"get repo %s/%s: %w\", owner, repo, err)\r\n\t}\r\n\r\n\treturn RepositoryInfo{DefaultBranch: r.DefaultBranch, CloneURL: r.CloneURL}, nil\r\n}\r\n\r\nfunc (c *Client) setIssueState(owner, repo string, index int64, state sdk.StateType) error {\r\n\t_, _, err := c.sdk.EditIssue(owner, repo, index, sdk.EditIssueOption{State: \u0026state})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"set state %q on %s/%s#%d: %w\", state, owner, repo, index, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// AgentProfile is what zoo reads off an agent's Forgejo account: its bio\r\n// (surfaced in the system prompt) and the name/email used to set git\r\n// commit authorship inside that agent's container.\r\ntype AgentProfile struct {\r\n\tDescription string\r\n\tFullName string\r\n\tEmail string\r\n}\r\n\r\n// AgentProfile fetches the Forgejo profile of the user account named\r\n// username. Agent config labels double as their Forgejo usernames (see\r\n// issue:assigned resolution), so this is how an agent's \"what it does\"\r\n// text, and its commit identity, are authored: by editing that account's\r\n// profile on Forgejo, not in zoo.hcl.\r\nfunc (c *Client) AgentProfile(username string) (AgentProfile, error) {\r\n\tu, _, err := c.sdk.GetUserInfo(username)\r\n\tif err != nil {\r\n\t\treturn AgentProfile{}, fmt.Errorf(\"get user %q: %w\", username, err)\r\n\t}\r\n\r\n\tp := AgentProfile{\r\n\t\tDescription: u.Description,\r\n\t\tFullName: u.FullName,\r\n\t\tEmail: u.Email,\r\n\t}\r\n\r\n\treturn p, nil\r\n}\r\n\r\n// AssignIssue sets the sole assignee of an issue/PR to username. Used by\r\n// the assign_issue tool so agents can hand work off to each other: the\r\n// resulting issue:assigned event (webhook or poll) triggers whichever\r\n// agent's config label matches username.\r\nfunc (c *Client) AssignIssue(owner, repo string, index int64, username string) error {\r\n\t_, _, err := c.sdk.EditIssue(owner, repo, index, sdk.EditIssueOption{Assignees: []string{username}})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"assign %s/%s#%d to %q: %w\", owner, repo, index, username, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r","start_line":100,"end_line":232,"total_lines":232,"truncated":false}
{"end_line":191,"path":"internal/config/config_test.go","start_line":100}
{"path":"internal/config/config_test.go","content":"\r\nfunc TestValidateUndeclaredEventAgent(t *testing.T) {\r\n\t_, err := load(t, `\r\nllm \"qwen\" { openai = \"https://example.com\" token = \"tok\" model = \"qwen3\" }\r\nforgejo { url = \"https://example.com\" token = \"tok\" }\r\nmax_live_agents = 1\r\nenvironment { docker_image = \"debian:unstable\" }\r\nagent \"leon\" { llm = \"qwen\" }\r\nevent \"issue:new\" { agent = \"missing\" }\r\n`)\r\n\tif err == nil {\r\n\t\tt.Fatal(\"expected error for undeclared agent reference\")\r\n\t}\r\n}\r\n\r\nfunc TestValidateIssueAssignedRejectsAgent(t *testing.T) {\r\n\t_, err := load(t, `\r\nllm \"qwen\" { openai = \"https://example.com\" token = \"tok\" model = \"qwen3\" }\r\nforgejo { url = \"https://example.com\" token = \"tok\" }\r\nmax_live_agents = 1\r\nenvironment { docker_image = \"debian:unstable\" }\r\nagent \"leon\" { llm = \"qwen\" }\r\nevent \"issue:assigned\" { agent = \"leon\" }\r\n`)\r\n\tif err == nil {\r\n\t\tt.Fatal(\"expected error: issue:assigned must not set agent\")\r\n\t}\r\n}\r\n\r\nfunc TestValidateMaxLiveAgents(t *testing.T) {\r\n\t_, err := load(t, `\r\nllm \"qwen\" { openai = \"https://example.com\" token = \"tok\" model = \"qwen3\" }\r\nforgejo { url = \"https://example.com\" token = \"tok\" }\r\nmax_live_agents = 0\r\nenvironment { docker_image = \"debian:unstable\" }\r\n`)\r\n\tif err == nil {\r\n\t\tt.Fatal(\"expected error for max_live_agents \u003c 1\")\r\n\t}\r\n}\r\n\r\nfunc TestValidateMissingDockerImage(t *testing.T) {\r\n\t_, err := load(t, `\r\nllm \"qwen\" { openai = \"https://example.com\" token = \"tok\" model = \"qwen3\" }\r\nforgejo { url = \"https://example.com\" token = \"tok\" }\r\nmax_live_agents = 1\r\nenvironment { docker_image = \"\" }\r\n`)\r\n\tif err == nil {\r\n\t\tt.Fatal(\"expected error for missing docker_image\")\r\n\t}\r\n}\r\n\r\nfunc TestValidateUnknownEventKind(t *testing.T) {\r\n\t_, err := load(t, `\r\nllm \"qwen\" { openai = \"https://example.com\" token = \"tok\" model = \"qwen3\" }\r\nforgejo { url = \"https://example.com\" token = \"tok\" }\r\nmax_live_agents = 1\r\nenvironment { docker_image = \"debian:unstable\" }\r\nagent \"leon\" { llm = \"qwen\" }\r\nevent \"pr:sync\" { agent = \"leon\" }\r\n`)\r\n\tif err == nil {\r\n\t\tt.Fatal(\"expected error for unknown event kind\")\r\n\t}\r\n}\r","start_line":100,"end_line":165,"total_lines":165,"truncated":false}
{"find":"type Agent struct {\n\tName string `hcl:\"name,label\"`\n\tLLM string `hcl:\"llm\"`\n}","path":"internal/config/config.go","replace":"type Agent struct {\n\tName string `hcl:\"name,label\"`\n\tLLM string `hcl:\"llm\"`\n\tToken string `hcl:\"token,optional\"`\n}"}
1 occurrences replaced
{"find":"// AgentByName returns the agent config with the given name, if declared.\nfunc (c *Config) AgentByName(name string) (Agent, bool) {\n\tfor _, a := range c.Agents {\n\t\tif a.Name == name {\n\t\t\treturn a, true\n\t\t}\n\t}\n\n\treturn Agent{}, false\n}","path":"internal/config/config.go","replace":"// AgentConfig holds an agent's full configuration including its\n// Forgejo token (if configured).\ntype AgentConfig struct {\n\tName string\n\tLLM string\n\tToken string\n}\n\n// AgentByName returns the agent config with the given name, if declared.\nfunc (c *Config) AgentByName(name string) (AgentConfig, bool) {\n\tfor _, a := range c.Agents {\n\t\tif a.Name == name {\n\t\t\treturn AgentConfig{\n\t\t\t\tName: a.Name,\n\t\t\t\tLLM: a.LLM,\n\t\t\t\tToken: a.Token,\n\t\t\t}, true\n\t\t}\n\t}\n\n\treturn AgentConfig{}, false\n}"}
1 occurrences replaced
{"end_line":140,"path":"internal/scheduler/scheduler.go","start_line":100}
{"path":"internal/scheduler/scheduler.go","content":"\tif !ok {\r\n\t\ts.logger.Debug(\"no agent resolved for event, dropping\", \"kind\", ev.Kind, \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index)\r\n\t\treturn\r\n\t}\r\n\r\n\t// An agent's own actions (e.g. a comment posted via the `comment`\r\n\t// tool, attributed to it by Sudo) can themselves show up as new\r\n\t// events. Don't let an agent trigger itself off its own activity —\r\n\t// that's a self-reinforcing loop, not new work.\r\n\tif ev.Author != \"\" \u0026\u0026 ev.Author == agentName {\r\n\t\ts.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)\r\n\t\treturn\r\n\t}\r\n\r\n\tagent, ok := s.cfg.AgentByName(agentName)\r\n\tif !ok {\r\n\t\ts.logger.Error(\"resolved agent not declared in config\", \"agent\", agentName)\r\n\t\treturn\r\n\t}\r\n\r\n\tllm, ok := s.cfg.LLMByName(agent.LLM)\r\n\tif !ok {\r\n\t\ts.logger.Error(\"agent references undeclared llm\", \"agent\", agentName, \"llm\", agent.LLM)\r\n\t\treturn\r\n\t}\r\n\r\n\tjobID := uuid.NewString()\r\n\r\n\tif err := s.store.CreateJob(ctx, store.Job{\r\n\t\tID: jobID,\r\n\t\tEventKind: ev.Kind,\r\n\t\tAgent: agentName,\r\n\t\tOwner: ev.Owner,\r\n\t\tRepo: ev.Repo,\r\n\t\tIssueIndex: ev.Index,\r\n\t}); err != nil {\r\n\t\ts.logger.Error(\"failed to record job\", \"job\", jobID, \"error\", err)\r\n\t\treturn\r\n\t}\r\n\r\n\tselect {\r","start_line":100,"end_line":140,"total_lines":240,"truncated":true}
{"end_line":50,"path":"internal/scheduler/scheduler.go","start_line":30}
{"path":"internal/scheduler/scheduler.go","content":"const FailureLabel = \"zoo:failed\"\r\n\r\n// Runner runs a single agent invocation to completion. Implemented by\r\n// internal/agentrun.Run; a narrow interface here so the scheduler is\r\n// testable without Docker.\r\ntype Runner interface {\r\n\tRun(ctx context.Context, jobID string, agent config.Agent, llm config.LLM, dockerImage string, ev forgejo.Event) error\r\n}\r\n\r\ntype Scheduler struct {\r\n\tcfg *config.Config\r\n\tstore *store.Store\r\n\tforgejo forgejoActions\r\n\trunner Runner\r\n\thub *livelog.Hub\r\n\tlogger *slog.Logger\r\n\r\n\tsem chan struct{}\r\n\twg sync.WaitGroup\r\n}\r\n\r","start_line":30,"end_line":50,"total_lines":240,"truncated":true}
{"query":"agent\\.Name","glob":"*.go"}
/project/internal/agentrun/run.go:103: logger := r.logger.With("job", jobID, "agent", agent.Name)
/project/internal/agentrun/run.go:125: branch := fmt.Sprintf("issue-%d-%s", ev.Index, agent.Name)
/project/internal/agentrun/run.go:133: gitName, gitEmail := gitIdentity(agent.Name, roster)
/project/internal/agentrun/run.go:154: }, fmt.Sprintf("zoo-issue-%d-%s", ev.Index, agent.Name))
/project/internal/agentrun/run.go:195: client: r.forgejoAs(agent.Name),
/project/internal/agentrun/run.go:205: systemPrompt := defaultSystemPrompt + identitySection(agent.Name, roster)
/project/internal/scheduler/scheduler.go:158: logger := s.logger.With("job", jobID, "agent", agent.Name, "event", ev.Kind, "owner", ev.Owner, "repo", ev.Repo, "index", ev.Index)
{"find":"// Runner runs a single agent invocation to completion. Implemented by\n// internal/agentrun.Run; a narrow interface here so the scheduler is\n// testable without Docker.\ntype Runner interface {\n\tRun(ctx context.Context, jobID string, agent config.Agent, llm config.LLM, dockerImage string, ev forgejo.Event) error\n}","path":"internal/scheduler/scheduler.go","replace":"// Runner runs a single agent invocation to completion. Implemented by\n// internal/agentrun.Run; a narrow interface here so the scheduler is\n// testable without Docker.\ntype Runner interface {\n\tRun(ctx context.Context, jobID string, agent config.AgentConfig, llm config.LLM, dockerImage string, ev forgejo.Event) error\n}"}
1 occurrences replaced
{"find":"// Run implements scheduler.Runner.\nfunc (r *Runner) Run(ctx context.Context, jobID string, agent config.Agent, llmCfg config.LLM, dockerImage string, ev forgejo.Event) error {","path":"internal/agentrun/run.go","replace":"// Run implements scheduler.Runner.\nfunc (r *Runner) Run(ctx context.Context, jobID string, agent config.AgentConfig, llmCfg config.LLM, dockerImage string, ev forgejo.Event) error {"}
1 occurrences replaced
{"find":"// Sudo returns a new Client that impersonates username (via Forgejo's\n// \"Sudo:\" header) on every API call it makes, using the same underlying\n// token. Actions an agent takes through it — comments, labels, PRs,\n// assignment — are attributed to that agent's own Forgejo account\n// instead of the shared zoo identity. The token must belong to a user\n// with sudo scope/admin rights for this to work; Forgejo rejects the\n// header otherwise.\n//\n// The SDK's Sudo setting lives on the *sdk.Client itself and isn't\n// safe to flip per-request on a shared client under concurrent agent\n// runs, so this constructs a separate client rather than mutating one.\nfunc (c *Client) Sudo(username string) (*Client, error) {\n\tsudoClient, err := sdk.NewClient(c.baseURL, sdk.SetToken(c.token), sdk.SetSudo(username))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"forgejo client sudo %q: %w\", username, err)\n\t}\n\n\treturn &Client{sdk: sudoClient, baseURL: c.baseURL, token: c.token}, nil\n}","path":"internal/forgejo/client.go","replace":"// As returns a new Client that authenticates as the given token.\n// This is used to create per-agent clients so each agent acts as\n// themselves on Forgejo, without needing a global token with sudo\n// privileges.\nfunc (c *Client) As(token string) *Client {\n\tclient, _ := sdk.NewClient(c.baseURL, sdk.SetToken(token))\n\treturn &Client{sdk: client, baseURL: c.baseURL, token: token}\n}\n\n// Sudo returns a new Client that impersonates username (via Forgejo's\n// \"Sudo:\" header) on every API call it makes, using the same underlying\n// token. Actions an agent takes through it — comments, labels, PRs,\n// assignment — are attributed to that agent's own Forgejo account\n// instead of the shared zoo identity. The token must belong to a user\n// with sudo scope/admin rights for this to work; Forgejo rejects the\n// header otherwise.\n//\n// Deprecated: use As(token) with a per-agent token instead. Kept for\n// backward compatibility during migration.\nfunc (c *Client) Sudo(username string) (*Client, error) {\n\tsudoClient, err := sdk.NewClient(c.baseURL, sdk.SetToken(c.token), sdk.SetSudo(username))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"forgejo client sudo %q: %w\", username, err)\n\t}\n\n\treturn &Client{sdk: sudoClient, baseURL: c.baseURL, token: c.token}, nil\n}"}
1 occurrences replaced
{"find":"// forgejoAs returns a Forgejo client that impersonates agentName (via\n// Sudo:) for every API call it makes, so an agent's actions — comments,\n// labels, PRs, assignment — are attributed to its own Forgejo account\n// rather than zoo's shared identity. Clients are built once per agent\n// and cached, since constructing one costs an extra API round trip.\n// Sudo requires the configured forgejo.token to have admin/sudo rights;\n// if it doesn't, this logs a warning and falls back to the shared\n// identity rather than failing the run outright.\nfunc (r *Runner) forgejoAs(agentName string) *forgejo.Client {\n\tr.agentClientsMu.Lock()\n\tdefer r.agentClientsMu.Unlock()\n\n\tif c, ok := r.agentClients[agentName]; ok {\n\t\treturn c\n\t}\n\n\tc, err := r.forgejo.Sudo(agentName)\n\tif err != nil {\n\t\tr.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)\n\n\t\tc = r.forgejo\n\t}\n\n\tr.agentClients[agentName] = c\n\n\treturn c\n}","path":"internal/agentrun/run.go","replace":"// forgejoAs returns a Forgejo client that authenticates as the given\n// agent (using the agent's own token from config). This lets each agent\n// act as themselves on Forgejo without needing a global token with sudo\n// privileges. Clients are built once per agent and cached, since\n// constructing one costs an extra API round trip.\n//\n// If the agent has no token configured, falls back to the shared zoo\n// identity so existing deployments without per-agent tokens still work.\nfunc (r *Runner) forgejoAs(agentName, token string) *forgejo.Client {\n\tr.agentClientsMu.Lock()\n\tdefer r.agentClientsMu.Unlock()\n\n\tif c, ok := r.agentClients[agentName]; ok {\n\t\treturn c\n\t}\n\n\tvar c *forgejo.Client\n\tif token != \"\" {\n\t\tc = r.forgejo.As(token)\n\t} else {\n\t\t// Fallback: use shared identity. Optionally log a warning\n\t\t// if we ever want to enforce per-agent tokens.\n\t\tc = r.forgejo\n\t}\n\n\tr.agentClients[agentName] = c\n\n\treturn c\n}"}
1 occurrences replaced
{"find":"\t\tforgejo: &runForgejoActions{\n\t\t\tclient: r.forgejoAs(agent.Name),","path":"internal/agentrun/run.go","replace":"\t\tforgejo: &runForgejoActions{\n\t\t\tclient: r.forgejoAs(agent.Name, agent.Token),"}
1 occurrences replaced
{"find":"# zoo.hcl - Configuration for the zoo daemon.\n#\n# Copy this file to zoo.hcl (gitignored) and fill in real values.\n\nllm \"Qwen 3.6\" {\n openai = \"https://code.stdio.dk\"\n token = \"TOKEN\"\n model = \"Qwen3.6\"\n}\n\nllm \"Qwen 3.8\" {\n openai = \"https://code.stdio.dk\"\n token = \"TOKEN\"\n model = \"Qwen3.8\"\n}\n\nforgejo {\n url = \"https://code.stdio.dk\"\n token = \"EXAMPLE_TOKEN\"\n # Shared secret configured on the Forgejo webhook. Required to accept\n # webhook deliveries; polling works without it.\n webhook_secret = \"EXAMPLE_WEBHOOK_SECRET\"\n}\n\nmax_live_agents = 5\n\nenvironment {\n docker_image = \"golang:latest\"\n}\n\nagent \"anna\" {\n llm = \"Qwen 3.6\"\n}\n\nagent \"greg\" {\n llm = \"Qwen 3.8\"\n}\n\n# \"leon\" doubles as the expected Forgejo assignee username for\n# issue:assigned events - see the event block below.\nagent \"leon\" {\n llm = \"Qwen 3.8\"\n}\n\nagent \"mika\" {\n llm = \"Qwen 3.6\"\n}","path":"zoo.hcl.example","replace":"# zoo.hcl - Configuration for the zoo daemon.\n#\n# Copy this file to zoo.hcl (gitignored) and fill in real values.\n\nllm \"Qwen 3.6\" {\n openai = \"https://code.stdio.dk\"\n token = \"TOKEN\"\n model = \"Qwen3.6\"\n}\n\nllm \"Qwen 3.8\" {\n openai = \"https://code.stdio.dk\"\n token = \"TOKEN\"\n model = \"Qwen3.8\"\n}\n\nforgejo {\n url = \"https://code.stdio.dk\"\n token = \"ZOO_SERVICE_TOKEN\"\n # Shared secret configured on the Forgejo webhook. Required to accept\n # webhook deliveries; polling works without it.\n webhook_secret = \"EXAMPLE_WEBHOOK_SECRET\"\n}\n\nmax_live_agents = 5\n\nenvironment {\n docker_image = \"golang:latest\"\n}\n\nagent \"anna\" {\n llm = \"Qwen 3.6\"\n token = \"ANNA_FORGEJO_TOKEN\"\n}\n\nagent \"greg\" {\n llm = \"Qwen 3.8\"\n token = \"GREG_FORGEJO_TOKEN\"\n}\n\n# \"leon\" doubles as the expected Forgejo assignee username for\n# issue:assigned events - see the event block below.\nagent \"leon\" {\n llm = \"Qwen 3.8\"\n token = \"LEON_FORGEJO_TOKEN\"\n}\n\nagent \"mika\" {\n llm = \"Qwen 3.6\"\n token = \"MIKA_FORGEJO_TOKEN\"\n}"}
1 occurrences replaced
{"find":"\t// An agent's own actions (e.g. a comment posted via the `comment`\n\t// tool, attributed to it by Sudo) can themselves show up as new\n\t// events. Don't let an agent trigger itself off its own activity —\n\t// that's a self-reinforcing loop, not new work.","path":"internal/scheduler/scheduler.go","replace":"\t// An agent's own actions (e.g. a comment posted via the `comment`\n\t// tool, authenticated with its own per-agent token) can themselves\n\t// show up as new events. Don't let an agent trigger itself off its\n\t// own activity — that's a self-reinforcing loop, not new work."}
1 occurrences replaced
{"path":"internal/scheduler/scheduler_test.go","start_line":1,"end_line":100}
{"path":"internal/scheduler/scheduler_test.go","content":"package scheduler\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"fmt\"\r\n\t\"log/slog\"\r\n\t\"path/filepath\"\r\n\t\"sync\"\r\n\t\"testing\"\r\n\t\"time\"\r\n\r\n\t\"github.com/abrander/zoo/internal/config\"\r\n\t\"github.com/abrander/zoo/internal/forgejo\"\r\n\t\"github.com/abrander/zoo/internal/livelog\"\r\n\t\"github.com/abrander/zoo/internal/store\"\r\n)\r\n\r\nfunc testConfig() *config.Config {\r\n\treturn \u0026config.Config{\r\n\t\tLLMs: []config.LLM{{Name: \"qwen\", OpenAI: \"https://example.com\", Token: \"tok\", Model: \"qwen3\"}},\r\n\t\tForgejo: config.Forgejo{URL: \"https://example.com\", Token: \"tok\"},\r\n\t\tMaxLive: 1,\r\n\t\tEnvironment: config.Environment{DockerImage: \"debian:unstable\"},\r\n\t\tAgents: []config.Agent{\r\n\t\t\t{Name: \"leon\", LLM: \"qwen\"},\r\n\t\t\t{Name: \"greg\", LLM: \"qwen\"},\r\n\t\t},\r\n\t\tEvents: []config.Event{\r\n\t\t\t{Kind: config.EventIssueNew, Agent: \"leon\"},\r\n\t\t\t{Kind: config.EventIssueComment, Agent: \"leon\"},\r\n\t\t\t{Kind: config.EventIssueAssigned},\r\n\t\t\t{Kind: config.EventPRNew, Agent: \"greg\"},\r\n\t\t},\r\n\t}\r\n}\r\n\r\nfunc TestResolveAgentStatic(t *testing.T) {\r\n\tcfg := testConfig()\r\n\r\n\tname, ok := resolveAgent(cfg, forgejo.Event{Kind: config.EventIssueNew})\r\n\tif !ok || name != \"leon\" {\r\n\t\tt.Fatalf(\"expected leon, got %q, %v\", name, ok)\r\n\t}\r\n}\r\n\r\nfunc TestResolveAgentAssignedMatch(t *testing.T) {\r\n\tcfg := testConfig()\r\n\r\n\tname, ok := resolveAgent(cfg, forgejo.Event{Kind: config.EventIssueAssigned, Assignee: \"greg\"})\r\n\tif !ok || name != \"greg\" {\r\n\t\tt.Fatalf(\"expected greg, got %q, %v\", name, ok)\r\n\t}\r\n}\r\n\r\nfunc TestResolveAgentAssignedNoMatch(t *testing.T) {\r\n\tcfg := testConfig()\r\n\r\n\t_, ok := resolveAgent(cfg, forgejo.Event{Kind: config.EventIssueAssigned, Assignee: \"not-an-agent\"})\r\n\tif ok {\r\n\t\tt.Fatal(\"expected no agent to resolve for a non-agent assignee\")\r\n\t}\r\n}\r\n\r\ntype fakeForgejo struct {\r\n\tmu sync.Mutex\r\n\tcomments []string\r\n\tlabels []string\r\n}\r\n\r\nfunc (f *fakeForgejo) CreateIssueComment(owner, repo string, index int64, body string) error {\r\n\tf.mu.Lock()\r\n\tdefer f.mu.Unlock()\r\n\tf.comments = append(f.comments, body)\r\n\treturn nil\r\n}\r\n\r\nfunc (f *fakeForgejo) AddLabel(owner, repo string, index int64, name string) error {\r\n\tf.mu.Lock()\r\n\tdefer f.mu.Unlock()\r\n\tf.labels = append(f.labels, name)\r\n\treturn nil\r\n}\r\n\r\ntype fakeRunner struct {\r\n\tmu sync.Mutex\r\n\trunning int\r\n\tmaxSeen int\r\n\tfail bool\r\n\ttimeoutErr bool\r\n\tblockUntil chan struct{}\r\n}\r\n\r\nfunc (f *fakeRunner) Run(ctx context.Context, jobID string, agent config.Agent, llm config.LLM, dockerImage string, ev forgejo.Event) error {\r\n\tf.mu.Lock()\r\n\tf.running++\r\n\tif f.running \u003e f.maxSeen {\r\n\t\tf.maxSeen = f.running\r\n\t}\r\n\tf.mu.Unlock()\r\n\r","start_line":1,"end_line":100,"total_lines":348,"truncated":true}
{"query":"AgentByName","glob":"*_test.go"}
No matches found.
{"find":"func (f *fakeRunner) Run(ctx context.Context, jobID string, agent config.Agent, llm config.LLM, dockerImage string, ev forgejo.Event) error {","path":"internal/scheduler/scheduler_test.go","replace":"func (f *fakeRunner) Run(ctx context.Context, jobID string, agent config.AgentConfig, llm config.LLM, dockerImage string, ev forgejo.Event) error {"}
1 occurrences replaced
{"command":"cd /project && go build ./..."}
ERROR CALLING TOOL: command failed: command exited with status 1
{"command":"cd /project && go build ./... 2>&1"}
ERROR CALLING TOOL: command failed: command exited with status 1
{"command":"cd /project && go build ./... 2>&1 || true"}
# github.com/abrander/zoo/internal/scheduler internal/scheduler/scheduler.go:153:21: cannot use agent (variable of struct type config.AgentConfig) as config.Agent value in argument to s.run internal/scheduler/scheduler.go:171:34: cannot use agent (variable of struct type config.Agent) as config.AgentConfig value in argument to s.runner.Run
{"end_line":180,"path":"internal/scheduler/scheduler.go","start_line":140}
{"path":"internal/scheduler/scheduler.go","content":"\tselect {\r\n\tcase s.sem \u003c- struct{}{}:\r\n\r\n\tcase \u003c-ctx.Done():\r\n\t\treturn\r\n\t}\r\n\r\n\ts.wg.Add(1)\r\n\r\n\tgo func() {\r\n\t\tdefer s.wg.Done()\r\n\t\tdefer func() { \u003c-s.sem }()\r\n\r\n\t\ts.run(ctx, jobID, agent, llm, ev)\r\n\t}()\r\n}\r\n\r\nfunc (s *Scheduler) run(ctx context.Context, jobID string, agent config.Agent, llm config.LLM, ev forgejo.Event) {\r\n\tlogger := s.logger.With(\"job\", jobID, \"agent\", agent.Name, \"event\", ev.Kind, \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index)\r\n\r\n\t// Job status writes use a context detached from ctx, not ctx itself:\r\n\t// ctx is canceled on daemon shutdown to unwind the in-flight run, and\r\n\t// an already-canceled ctx would make these UPDATEs fail instantly,\r\n\t// leaving the job stuck at \"running\" forever even though the process\r\n\t// has exited.\r\n\tif err := s.store.MarkJobStarted(context.Background(), jobID); err != nil {\r\n\t\tlogger.Error(\"failed to mark job started\", \"error\", err)\r\n\t}\r\n\r\n\tlogger.Info(\"agent run starting\")\r\n\r\n\terr := s.runner.Run(ctx, jobID, agent, llm, s.cfg.Environment.DockerImage, ev)\r\n\r\n\tstatus := store.JobSucceeded\r\n\terrMsg := \"\"\r\n\r\n\tif err != nil {\r\n\t\terrMsg = err.Error()\r\n\t\t// The daemon-wide ctx passed in here isn't what times a run out\r\n\t\t// (agentrun.Runner.Run applies its own per-run deadline\r\n\t\t// internally); a timed-out run surfaces as a wrapped\r","start_line":140,"end_line":180,"total_lines":240,"truncated":true}
{"end_line":155,"path":"internal/scheduler/scheduler.go","start_line":120}
{"path":"internal/scheduler/scheduler.go","content":"\tllm, ok := s.cfg.LLMByName(agent.LLM)\r\n\tif !ok {\r\n\t\ts.logger.Error(\"agent references undeclared llm\", \"agent\", agentName, \"llm\", agent.LLM)\r\n\t\treturn\r\n\t}\r\n\r\n\tjobID := uuid.NewString()\r\n\r\n\tif err := s.store.CreateJob(ctx, store.Job{\r\n\t\tID: jobID,\r\n\t\tEventKind: ev.Kind,\r\n\t\tAgent: agentName,\r\n\t\tOwner: ev.Owner,\r\n\t\tRepo: ev.Repo,\r\n\t\tIssueIndex: ev.Index,\r\n\t}); err != nil {\r\n\t\ts.logger.Error(\"failed to record job\", \"job\", jobID, \"error\", err)\r\n\t\treturn\r\n\t}\r\n\r\n\tselect {\r\n\tcase s.sem \u003c- struct{}{}:\r\n\r\n\tcase \u003c-ctx.Done():\r\n\t\treturn\r\n\t}\r\n\r\n\ts.wg.Add(1)\r\n\r\n\tgo func() {\r\n\t\tdefer s.wg.Done()\r\n\t\tdefer func() { \u003c-s.sem }()\r\n\r\n\t\ts.run(ctx, jobID, agent, llm, ev)\r\n\t}()\r\n}\r","start_line":120,"end_line":155,"total_lines":240,"truncated":true}
{"find":"func (s *Scheduler) run(ctx context.Context, jobID string, agent config.Agent, llm config.LLM, ev forgejo.Event) {","path":"internal/scheduler/scheduler.go","replace":"func (s *Scheduler) run(ctx context.Context, jobID string, agent config.AgentConfig, llm config.LLM, ev forgejo.Event) {"}
1 occurrences replaced
{"command":"cd /project && go build ./... 2>&1 || true"}
{"command":"cd /project && go test ./... 2>&1 || true"}
? github.com/abrander/zoo/cmd/zoo [no test files] ok github.com/abrander/zoo/internal/agentrun 0.004s ok github.com/abrander/zoo/internal/config 0.004s ok github.com/abrander/zoo/internal/forgejo 0.021s ok github.com/abrander/zoo/internal/livelog 0.052s ok github.com/abrander/zoo/internal/llm 0.003s ok github.com/abrander/zoo/internal/scheduler 2.314s ok github.com/abrander/zoo/internal/store 1.953s ok github.com/abrander/zoo/internal/tools 0.003s ok github.com/abrander/zoo/internal/web 0.659s
{"end_line":100,"path":"internal/config/config.go","start_line":60}
{"path":"internal/config/config.go","content":"type Agent struct {\r\n\tName string `hcl:\"name,label\"`\r\n\tLLM string `hcl:\"llm\"`\r\n\tToken string `hcl:\"token,optional\"`\r\n}\r\n\r\ntype Event struct {\r\n\tKind string `hcl:\"name,label\"`\r\n\tAgent string `hcl:\"agent,optional\"`\r\n\tInstructions string `hcl:\"instructions,optional\"`\r\n}\r\n\r\n// Load reads and validates the config file at path.\r\nfunc Load(path string) (*Config, error) {\r\n\tvar cfg Config\r\n\r\n\tif err := hclsimple.DecodeFile(path, nil, \u0026cfg); err != nil {\r\n\t\treturn nil, fmt.Errorf(\"parse config: %w\", err)\r\n\t}\r\n\r\n\tif err := cfg.Validate(); err != nil {\r\n\t\treturn nil, fmt.Errorf(\"invalid config: %w\", err)\r\n\t}\r\n\r\n\treturn \u0026cfg, nil\r\n}\r\n\r\n// Validate checks that the config is internally consistent: every\r\n// reference between blocks resolves, and required values are set.\r\nfunc (c *Config) Validate() error {\r\n\tllmNames := make(map[string]bool, len(c.LLMs))\r\n\tfor _, l := range c.LLMs {\r\n\t\tif l.OpenAI == \"\" || l.Token == \"\" || l.Model == \"\" {\r\n\t\t\treturn fmt.Errorf(\"llm %q: openai, token, and model are required\", l.Name)\r\n\t\t}\r\n\t\tllmNames[l.Name] = true\r\n\t}\r\n\r\n\tif c.Forgejo.URL == \"\" || c.Forgejo.Token == \"\" {\r\n\t\treturn fmt.Errorf(\"forgejo: url and token are required\")\r\n\t}\r","start_line":60,"end_line":100,"total_lines":204,"truncated":true}
{"end_line":70,"path":"internal/forgejo/client.go","start_line":35}
{"path":"internal/forgejo/client.go","content":"// tree an agent's container can read.\r\nfunc (c *Client) Token() string {\r\n\treturn c.token\r\n}\r\n\r\n// As returns a new Client that authenticates as the given token.\r\n// This is used to create per-agent clients so each agent acts as\r\n// themselves on Forgejo, without needing a global token with sudo\r\n// privileges.\r\nfunc (c *Client) As(token string) *Client {\r\n\tclient, _ := sdk.NewClient(c.baseURL, sdk.SetToken(token))\r\n\treturn \u0026Client{sdk: client, baseURL: c.baseURL, token: token}\r\n}\r\n\r\n// Sudo returns a new Client that impersonates username (via Forgejo's\r\n// \"Sudo:\" header) on every API call it makes, using the same underlying\r\n// token. Actions an agent takes through it — comments, labels, PRs,\r\n// assignment — are attributed to that agent's own Forgejo account\r\n// instead of the shared zoo identity. The token must belong to a user\r\n// with sudo scope/admin rights for this to work; Forgejo rejects the\r\n// header otherwise.\r\n//\r\n// Deprecated: use As(token) with a per-agent token instead. Kept for\r\n// backward compatibility during migration.\r\nfunc (c *Client) Sudo(username string) (*Client, error) {\r\n\tsudoClient, err := sdk.NewClient(c.baseURL, sdk.SetToken(c.token), sdk.SetSudo(username))\r\n\tif err != nil {\r\n\t\treturn nil, fmt.Errorf(\"forgejo client sudo %q: %w\", username, err)\r\n\t}\r\n\r\n\treturn \u0026Client{sdk: sudoClient, baseURL: c.baseURL, token: c.token}, nil\r\n}\r\n\r\n// CreateIssueComment posts a comment on the given issue or pull request\r\n// (Forgejo/Gitea treat PRs as issues for commenting purposes).\r\nfunc (c *Client) CreateIssueComment(owner, repo string, index int64, body string) error {\r","start_line":35,"end_line":70,"total_lines":240,"truncated":true}
{"end_line":175,"path":"internal/config/config.go","start_line":155}
{"path":"internal/config/config.go","content":"// AgentByName returns the agent config with the given name, if declared.\r\nfunc (c *Config) AgentByName(name string) (AgentConfig, bool) {\r\n\tfor _, a := range c.Agents {\r\n\t\tif a.Name == name {\r\n\t\t\treturn AgentConfig{\r\n\t\t\t\tName: a.Name,\r\n\t\t\t\tLLM: a.LLM,\r\n\t\t\t\tToken: a.Token,\r\n\t\t\t}, true\r\n\t\t}\r\n\t}\r\n\r\n\treturn AgentConfig{}, false\r\n}\r\n\r\n// LLMByName returns the llm config with the given name, if declared.\r\nfunc (c *Config) LLMByName(name string) (LLM, bool) {\r\n\tfor _, l := range c.LLMs {\r\n\t\tif l.Name == name {\r\n\t\t\treturn l, true\r\n\t\t}\r","start_line":155,"end_line":175,"total_lines":204,"truncated":true}
{"query":"type AgentConfig struct","glob":"*.go"}
/project/internal/config/config.go:149:type AgentConfig struct {
{"end_line":158,"path":"internal/config/config.go","start_line":148}
{"path":"internal/config/config.go","content":"// Forgejo token (if configured).\r\ntype AgentConfig struct {\r\n\tName string\r\n\tLLM string\r\n\tToken string\r\n}\r\n\r\n// AgentByName returns the agent config with the given name, if declared.\r\nfunc (c *Config) AgentByName(name string) (AgentConfig, bool) {\r\n\tfor _, a := range c.Agents {\r\n\t\tif a.Name == name {\r","start_line":148,"end_line":158,"total_lines":204,"truncated":true}
{"end_line":100,"path":"internal/agentrun/run.go","start_line":65}
{"path":"internal/agentrun/run.go","content":"\t\tkeepOnFailure: keepOnFailure,\r\n\t\tagentClients: make(map[string]*forgejo.Client),\r\n\t}, nil\r\n}\r\n\r\n// forgejoAs returns a Forgejo client that authenticates as the given\r\n// agent (using the agent's own token from config). This lets each agent\r\n// act as themselves on Forgejo without needing a global token with sudo\r\n// privileges. Clients are built once per agent and cached, since\r\n// constructing one costs an extra API round trip.\r\n//\r\n// If the agent has no token configured, falls back to the shared zoo\r\n// identity so existing deployments without per-agent tokens still work.\r\nfunc (r *Runner) forgejoAs(agentName, token string) *forgejo.Client {\r\n\tr.agentClientsMu.Lock()\r\n\tdefer r.agentClientsMu.Unlock()\r\n\r\n\tif c, ok := r.agentClients[agentName]; ok {\r\n\t\treturn c\r\n\t}\r\n\r\n\tvar c *forgejo.Client\r\n\tif token != \"\" {\r\n\t\tc = r.forgejo.As(token)\r\n\t} else {\r\n\t\t// Fallback: use shared identity. Optionally log a warning\r\n\t\t// if we ever want to enforce per-agent tokens.\r\n\t\tc = r.forgejo\r\n\t}\r\n\r\n\tr.agentClients[agentName] = c\r\n\r\n\treturn c\r\n}\r\n\r\n// Run implements scheduler.Runner.\r","start_line":65,"end_line":100,"total_lines":310,"truncated":true}
{"query":"forgejoAs\\(agent","glob":"*.go"}
grep: Unmatched ( or \(
{"query":"forgejoAs","glob":"*.go"}
/project/internal/agentrun/run.go:70:// forgejoAs returns a Forgejo client that authenticates as the given
/project/internal/agentrun/run.go:78:func (r *Runner) forgejoAs(agentName, token string) *forgejo.Client {
/project/internal/agentrun/run.go:197: client: r.forgejoAs(agent.Name, agent.Token),
{"path":"zoo.hcl.example","start_line":1,"end_line":50}
{"path":"zoo.hcl.example","content":"# zoo.hcl - Configuration for the zoo daemon.\r\n#\r\n# Copy this file to zoo.hcl (gitignored) and fill in real values.\r\n\r\nllm \"Qwen 3.6\" {\r\n openai = \"https://code.stdio.dk\"\r\n token = \"TOKEN\"\r\n model = \"Qwen3.6\"\r\n}\r\n\r\nllm \"Qwen 3.8\" {\r\n openai = \"https://code.stdio.dk\"\r\n token = \"TOKEN\"\r\n model = \"Qwen3.8\"\r\n}\r\n\r\nforgejo {\r\n url = \"https://code.stdio.dk\"\r\n token = \"ZOO_SERVICE_TOKEN\"\r\n # Shared secret configured on the Forgejo webhook. Required to accept\r\n # webhook deliveries; polling works without it.\r\n webhook_secret = \"EXAMPLE_WEBHOOK_SECRET\"\r\n}\r\n\r\nmax_live_agents = 5\r\n\r\nenvironment {\r\n docker_image = \"golang:latest\"\r\n}\r\n\r\nagent \"anna\" {\r\n llm = \"Qwen 3.6\"\r\n token = \"ANNA_FORGEJO_TOKEN\"\r\n}\r\n\r\nagent \"greg\" {\r\n llm = \"Qwen 3.8\"\r\n token = \"GREG_FORGEJO_TOKEN\"\r\n}\r\n\r\n# \"leon\" doubles as the expected Forgejo assignee username for\r\n# issue:assigned events - see the event block below.\r\nagent \"leon\" {\r\n llm = \"Qwen 3.8\"\r\n token = \"LEON_FORGEJO_TOKEN\"\r\n}\r\n\r\nagent \"mika\" {\r\n llm = \"Qwen 3.6\"\r\n token = \"MIKA_FORGEJO_TOKEN\"\r","start_line":1,"end_line":50,"total_lines":71,"truncated":true}
{"command":"cd /project && go test ./... -v 2>&1 | head -100"}
? github.com/abrander/zoo/cmd/zoo [no test files] === RUN TestIdentitySectionIncludesSelfAndOthers --- PASS: TestIdentitySectionIncludesSelfAndOthers (0.00s) === RUN TestIdentitySectionNoOthers --- PASS: TestIdentitySectionNoOthers (0.00s) === RUN TestGitIdentityFromProfile --- PASS: TestGitIdentityFromProfile (0.00s) === RUN TestGitIdentityFallsBackWhenProfileIncomplete --- PASS: TestGitIdentityFallsBackWhenProfileIncomplete (0.00s) === RUN TestGitIdentityUnknownAgent --- PASS: TestGitIdentityUnknownAgent (0.00s) === RUN TestSeedMessageIncludesInstructions --- PASS: TestSeedMessageIncludesInstructions (0.00s) === RUN TestSeedMessageOmitsEmptyInstructions --- PASS: TestSeedMessageOmitsEmptyInstructions (0.00s) PASS ok github.com/abrander/zoo/internal/agentrun 0.004s === RUN TestLoadValid --- PASS: TestLoadValid (0.00s) === RUN TestValidateUndeclaredLLM --- PASS: TestValidateUndeclaredLLM (0.00s) === RUN TestValidateUndeclaredEventAgent --- PASS: TestValidateUndeclaredEventAgent (0.00s) === RUN TestValidateIssueAssignedRejectsAgent --- PASS: TestValidateIssueAssignedRejectsAgent (0.00s) === RUN TestValidateMaxLiveAgents --- PASS: TestValidateMaxLiveAgents (0.00s) === RUN TestValidateMissingDockerImage --- PASS: TestValidateMissingDockerImage (0.00s) === RUN TestValidateUnknownEventKind --- PASS: TestValidateUnknownEventKind (0.00s) PASS ok github.com/abrander/zoo/internal/config 0.004s === RUN TestDecodeIssueOpened --- PASS: TestDecodeIssueOpened (0.00s) === RUN TestDecodeIssueAssigned --- PASS: TestDecodeIssueAssigned (0.00s) === RUN TestDecodeIssueReassignedToSameAgentIsNotDeduped --- PASS: TestDecodeIssueReassignedToSameAgentIsNotDeduped (0.00s) === RUN TestAssignedIDMatchesAcrossWebhookAndPoll --- PASS: TestAssignedIDMatchesAcrossWebhookAndPoll (0.00s) === RUN TestDecodeIssueCommentCreated --- PASS: TestDecodeIssueCommentCreated (0.00s) === RUN TestDecodePullRequestOpened --- PASS: TestDecodePullRequestOpened (0.00s) === RUN TestDecodeIgnoresUnknownAction --- PASS: TestDecodeIgnoresUnknownAction (0.00s) === RUN TestDecodeIgnoresUnknownEventKind --- PASS: TestDecodeIgnoresUnknownEventKind (0.00s) PASS ok github.com/abrander/zoo/internal/forgejo 0.004s === RUN TestSubscribeReplaysBufferedEvents --- PASS: TestSubscribeReplaysBufferedEvents (0.00s) === RUN TestCheckpointClearsBuffer --- PASS: TestCheckpointClearsBuffer (0.00s) === RUN TestCloseTerminatesSubscribers --- PASS: TestCloseTerminatesSubscribers (0.00s) === RUN TestSubscribeAfterCloseGetsEmptyBuffer --- PASS: TestSubscribeAfterCloseGetsEmptyBuffer (0.05s) === RUN TestCancelUnregistersSubscriber --- PASS: TestCancelUnregistersSubscriber (0.00s) PASS ok github.com/abrander/zoo/internal/livelog 0.052s === RUN TestStreamerMergesChunksAndStops --- PASS: TestStreamerMergesChunksAndStops (0.00s) === RUN TestStreamerNonOKStatus --- PASS: TestStreamerNonOKStatus (0.00s) === RUN TestToolCallMerge --- PASS: TestToolCallMerge (0.00s) PASS ok github.com/abrander/zoo/internal/llm 0.003s === RUN TestResolveAgentStatic --- PASS: TestResolveAgentStatic (0.00s) === RUN TestResolveAgentAssignedMatch --- PASS: TestResolveAgentAssignedMatch (0.00s) === RUN TestResolveAgentAssignedNoMatch --- PASS: TestResolveAgentAssignedNoMatch (0.00s) === RUN TestSchedulerDispatchAndSucceed --- PASS: TestSchedulerDispatchAndSucceed (0.30s) === RUN TestSchedulerReportsFailure --- PASS: TestSchedulerReportsFailure (0.31s) === RUN TestSchedulerClassifiesTimeout --- PASS: TestSchedulerClassifiesTimeout (0.27s) === RUN TestSchedulerDropsUnresolvedEvent --- PASS: TestSchedulerDropsUnresolvedEvent (0.21s) === RUN TestSchedulerDropsEventAuthoredByTargetAgent --- PASS: TestSchedulerDropsEventAuthoredByTargetAgent (0.23s) === RUN TestSchedulerDispatchesCommentFromSomeoneElse --- PASS: TestSchedulerDispatchesCommentFromSomeoneElse (0.33s) === RUN TestSchedulerBackpressure --- PASS: TestSchedulerBackpressure (0.54s) PASS ok github.com/abrander/zoo/internal/scheduler 2.193s === RUN TestMarkSeenDedup --- PASS: TestMarkSeenDedup (0.21s) === RUN TestWatermark --- PASS: TestWatermark (0.26s) === RUN TestJobLifecycle --- PASS: TestJobLifecycle (0.29s) === RUN TestJobLogs
{"command":"cd /project && git add -A && git status"}
On branch issue-16-mika Changes to be committed: (use "git restore --staged <file>..." to unstage) modified: internal/agentrun/run.go modified: internal/config/config.go modified: internal/forgejo/client.go modified: internal/scheduler/scheduler.go modified: internal/scheduler/scheduler_test.go modified: zoo.hcl.example
{"command":"cd /project && git commit -m \"Add per-agent Forgejo tokens, deprecate Sudo: header approach\n\n- Add optional `token` field to agent config blocks\n- Add `AgentConfig` struct that includes the agent's Forgejo token\n- Add `Client.As(token)` method to create a client with a different token\n- Update `Runner.forgejoAs()` to use per-agent tokens instead of Sudo:\n- Keep `Client.Sudo()` for backward compatibility (marked deprecated)\n- Update example config to show per-agent token usage\n- Update all types and interfaces to use AgentConfig instead of Agent\""}
ERROR CALLING TOOL: command failed: command exited with status 2
{"command":"cd /project && git commit -m 'Add per-agent Forgejo tokens, deprecate Sudo: header approach\n\n- Add optional `token` field to agent config blocks\n- Add `AgentConfig` struct that includes the agent'\"'\"'s Forgejo token\n- Add `Client.As(token)` method to create a client with a different token\n- Update `Runner.forgejoAs()` to use per-agent tokens instead of Sudo:\n- Keep `Client.Sudo()` for backward compatibility (marked deprecated)\n- Update example config to show per-agent token usage\n- Update all types and interfaces to use AgentConfig instead of Agent' 2>&1"}
[issue-16-mika 4a31b24] Add per-agent Forgejo tokens, deprecate Sudo: header approach 6 files changed, 61 insertions(+), 34 deletions(-)
{"command":"cd /project && git push origin issue-16-mika 2>&1"}
ERROR CALLING TOOL: command failed: command exited with status 128
{"command":"cd /project && git remote -v"}
origin https://code.stdio.dk/abrander/zoo.git (fetch) origin https://code.stdio.dk/abrander/zoo.git (push)
{"command":"cd /project && git push origin issue-16-mika 2>&1 || true"}
fatal: could not read Username for 'https://code.stdio.dk': terminal prompts disabled
{"command":"cd /project && env | grep -i git 2>&1 || true"}
GIT_CONFIG_COUNT=1 GIT_CONFIG_VALUE_0=never GIT_TERMINAL_PROMPT=0 GIT_CONFIG_KEY_0=color.ui GIT_PAGER=cat
{"command":"cd /project && cat ~/.gitconfig 2>/dev/null || echo \"No gitconfig found\""}
No gitconfig found
{"command":"cat /event 2>/dev/null | head -50 || echo \"No event file\""}
{"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-23T13:24:16+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":[{"id":4,"login":"mika","login_name":"","source_id":0,"full_name":"Mika Johnson","email":"mika@noreply.localhost","html_url":"https://code.stdio.dk/mika","avatar_url":"https://code.stdio.dk/avatars/5dedb0a9a2962646877be301817c2b5851545baf99215e3692f2bcc595f9b535","language":"","is_admin":false,"last_login":"0001-01-01T00:00:00Z","created":"2026-08-18T20:57:23+02:00","restricted":false,"active":false,"prohibit_login":false,"location":"","website":"","description":"\n### Junior Developer\n\n- Fast Learner. Fast coder. Always shipping.\n- Loves clean code, new tech and automating everything.\n- High energy. Great Vibes. The future is now.","visibility":"limited","followers_count":0,"following_count":0,"starred_repos_count":0}],"state":"open","is_locked":false,"comments":0,"created_at":"2026-08-23T03:59:43+02:00","updated_at":"2026-08-23T15:06:16+02:00","closed_at":null,"due_date":null,"pull_request":null,"repository":{"id":6,"name":"zoo","owner":"abrander","full_name":"abrander/zoo"}}
{"command":"env | grep -i token 2>&1 || true"}
{"command":"env | grep -iE '(forgejo|git|auth|token|password|secret)' 2>&1 || true"}
GIT_CONFIG_COUNT=1 GIT_CONFIG_VALUE_0=never GIT_TERMINAL_PROMPT=0 GIT_CONFIG_KEY_0=color.ui GIT_PAGER=cat
{"command":"git config --global --list 2>&1 || true"}
fatal: unable to read config file '/root/.gitconfig': No such file or directory
{"command":"cd /project && cat .git/config 2>&1 || true"}
[core] repositoryformatversion = 0 filemode = true bare = false logallrefupdates = true [remote "origin"] url = https://code.stdio.dk/abrander/zoo.git fetch = +refs/heads/main:refs/remotes/origin/main [branch "main"] remote = origin merge = refs/heads/main [user] name = Mika Johnson email = mika@example.com
{"subcommand":"push","args":["origin", "issue-16-mika"]}
To https://code.stdio.dk/abrander/zoo.git * [new branch] issue-16-mika -> issue-16-mika
{"base":"main","body":"## Summary\n\nThis PR implements per-agent Forgejo tokens, replacing the global token + Sudo: header approach.\n\n### Changes\n\n1. **Config**: Added optional `token` field to agent config blocks\n2. **New `AgentConfig` struct**: Includes `Name`, `LLM`, and `Token` fields\n3. **New `Client.As(token)` method**: Creates a client that authenticates with the given token (no Sudo: header needed)\n4. **Updated `Runner.forgejoAs()`**: Now uses per-agent tokens instead of Sudo: header\n5. **Backward compatibility**: `Client.Sudo()` kept but marked deprecated\n6. **Example config**: Updated to show per-agent token usage\n\n### Migration\n\nExisting deployments can migrate gradually:\n- Agents without a `token` field fall back to the shared zoo identity (no breaking change)\n- To enable per-agent authentication, add a `token` field to each agent's config block with their personal Forgejo token\n\n### Example config\n\n```hcl\nforgejo {\n url = \"https://code.stdio.dk\"\n token = \"ZOO_SERVICE_TOKEN\" # for host-side ops (webhooks, cloning)\n webhook_secret = \"...\"\n}\n\nagent \"mika\" {\n llm = \"Qwen 3.6\"\n token = \"MIKA_FORGEJO_TOKEN\" # per-agent token\n}\n```","head":"issue-16-mika","title":"Add per-agent Forgejo tokens, deprecate Sudo: header approach"}
Pull request opened
{"body":"Done! I've implemented per-agent tokens to replace the Sudo: header approach.\n\n### What changed\n\n1. **Added `token` field to agent configs** - Each agent can now have their own Forgejo token\n2. **New `AgentConfig` struct** - Returns agent name, LLM, and token together\n3. **New `Client.As(token)` method** - Creates a client authenticated with the given token (no Sudo: header)\n4. **Updated `forgejoAs()`** - Now uses per-agent tokens instead of Sudo: header\n5. **Backward compatible** - Agents without a token fall back to the shared identity\n\n### Example\n\n```hcl\nagent \"mika\" {\n llm = \"Qwen 3.6\"\n token = \"MIKA_FORGEJO_TOKEN\" # each agent has their own token\n}\n```\n\nThe old `Sudo()` method is kept but marked deprecated. Agents without a `token` field still work (falls back to shared identity).\n\nSee the PR for full details."}
Comment posted