Details and log output for this agent run.
{"path":".","recursive":false}
drwxr-xr-x 5 user user 4096 Aug 27 08:48 . drwxr-xr-x 1 root root 4096 Aug 27 08:48 .. drwxr-xr-x 7 user user 4096 Aug 27 08:48 .git -rw-r--r-- 1 user user 22 Aug 27 08:48 .gitignore -rw-r--r-- 1 user user 343 Aug 27 08:48 Makefile -rw-r--r-- 1 user user 6589 Aug 27 08:48 README.md drwxr-xr-x 2 user user 4096 Aug 27 08:48 docker -rw-r--r-- 1 user user 2628 Aug 27 08:48 go.mod -rw-r--r-- 1 user user 16578 Aug 27 08:48 go.sum drwxr-xr-x 11 user user 4096 Aug 27 08:48 internal -rw-r--r-- 1 user user 4812 Aug 27 08:48 main.go -rw-r--r-- 1 user user 3092 Aug 27 08:48 zoo.hcl.example
{"command":"cd /project && git log --oneline -5 && git status"}
5ebce58 Merge pull request 'Allow agents to merge approved pull requests' (#77) from issue-76-greg into main 1da1d20 Allow agents to merge approved pull requests f33fed4 Merge pull request 'Move main.go from cmd/zoo/ to root directory' (#75) from issue-74-mika into main ec2fe2d Move main.go from cmd/zoo/ to root directory a00979b Add makefile. On branch issue-78-greg nothing to commit, working tree clean
{"query":"issue-%d-|issue-.*agent|containerName|ContainerName|container name"}
/project/internal/agentrun/forgejo.go:21:// failed, so every comment, PR, label, or issue-state change an agent
/project/internal/agentrun/run.go:149: branch := fmt.Sprintf("issue-%d-%s", ev.Index, agent.Name)
/project/internal/agentrun/run.go:204: }, fmt.Sprintf("zoo-issue-%d-%s", ev.Index, agent.Name))
{"end_line":260,"path":"internal/agentrun/run.go","start_line":100}
{"path":"internal/agentrun/run.go","content":"\t\tc = r.forgejo\n\t}\n\n\tr.agentClients[agentName] = c\n\n\treturn c\n}\n\n// 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 {\n\tctx, cancel := context.WithTimeout(ctx, r.timeout)\n\tdefer cancel()\n\n\tlogger := r.logger.With(\"job\", jobID, \"agent\", agent.Name)\n\n\trepoInfo, err := r.forgejo.RepositoryInfo(ev.Owner, ev.Repo)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"look up repository: %w\", err)\n\t}\n\n\tworkDir, err := os.MkdirTemp(\"\", \"zoo-run-*\")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"create work dir: %w\", err)\n\t}\n\n\tsucceeded := false\n\n\tdefer func() {\n\t\tif succeeded || !r.keepOnFailure {\n\t\t\tos.RemoveAll(workDir)\n\t\t} else {\n\t\t\tlogger.Warn(\"keeping work dir after failure\", \"dir\", workDir)\n\t\t}\n\t}()\n\n\t// The container bind-mounts projectDir as /project and does the\n\t// initial clone into it, so the (empty) directory must exist on the\n\t// host before the container is created โ otherwise Docker would\n\t// create it itself, root-owned.\n\tprojectDir := filepath.Join(workDir, \"project\")\n\n\tif err := os.MkdirAll(projectDir, 0o755); err != nil {\n\t\treturn fmt.Errorf(\"create project dir: %w\", err)\n\t}\n\n\t// A pr:review run works on the PR's own head branch, so the agent's\n\t// commits push straight to the PR. Every other event kind branches\n\t// off the default branch as usual.\n\tvar review *forgejo.ReviewDetail\n\tbranch := fmt.Sprintf(\"issue-%d-%s\", ev.Index, agent.Name)\n\n\tif ev.Kind == forgejo.EventPRReview {\n\t\t// Always fetch the current head ref, not just when the event\n\t\t// lacks one (the polling path doesn't carry it): the webhook's\n\t\t// copy could be stale if the PR's head branch was renamed since\n\t\t// the review, and the push target depends on it.\n\t\theadRef := ev.HeadRef\n\n\t\tif prInfo, err := r.forgejo.PullRequestInfo(ev.Owner, ev.Repo, ev.Index); err != nil {\n\t\t\tlogger.Warn(\"fetch pull request head failed; falling back to the event's head ref\", \"error\", err)\n\t\t} else if prInfo.HeadRef != \"\" {\n\t\t\theadRef = prInfo.HeadRef\n\t\t}\n\n\t\tif headRef == \"\" {\n\t\t\treturn fmt.Errorf(\"pr:review event has no pull request head branch to check out\")\n\t\t}\n\n\t\tbranch = headRef\n\n\t\t// Fetch the full review (verdict, body, inline comments) so the\n\t\t// agent sees all the feedback, not just the triggering event. A\n\t\t// failure degrades to no review detail rather than failing the\n\t\t// run: the agent can still do its job, just without the inline\n\t\t// comments.\n\t\treview, err = r.forgejo.ReviewDetail(ev.Owner, ev.Repo, ev.Index, ev.ReviewID)\n\t\tif err != nil {\n\t\t\tlogger.Warn(\"fetch review detail failed; agent will not see inline review comments\", \"error\", err)\n\t\t\treview = nil\n\t\t}\n\t}\n\n\troster := buildRoster(r.forgejo, r.cfg.Agents, logger)\n\tgitName, gitEmail := gitIdentity(agent.Name, roster)\n\n\t// The credential the sandbox's git uses for remote operations: the\n\t// agent's own Forgejo token when configured, so its git activity is\n\t// attributed to its own account, falling back to the shared zoo\n\t// identity for deployments without per-agent tokens (mirroring\n\t// forgejoAs).\n\tgitUser, gitToken := \"zoo\", r.forgejo.Token()\n\n\tif agent.Token != \"\" {\n\t\tgitUser, gitToken = agent.Name, agent.Token\n\t}\n\n\teventPath := filepath.Join(workDir, \"event.json\")\n\tif err := os.WriteFile(eventPath, ev.Raw, 0o644); err != nil {\n\t\treturn fmt.Errorf(\"write event file: %w\", err)\n\t}\n\n\tcontainerID, err := r.docker.createContainer(ctx, dockerImage, []string{\n\t\tprojectDir + \":/project\",\n\t\teventPath + \":/event:ro\",\n\t}, fmt.Sprintf(\"zoo-issue-%d-%s\", ev.Index, agent.Name))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"start container: %w\", err)\n\t}\n\n\tdefer func() {\n\t\tcleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\tdefer cleanupCancel()\n\t\tif err := r.docker.remove(cleanupCtx, containerID); err != nil {\n\t\t\tlogger.Warn(\"failed to remove container\", \"container\", containerID, \"error\", err)\n\t\t}\n\t}()\n\n\t// Git must simply work inside the sandbox: safe.directory, commit\n\t// identity, and the remote credential all go into the container's\n\t// system gitconfig (see configureSandboxGit).\n\tif err := configureSandboxGit(ctx, r.docker, containerID, repoInfo.CloneURL, gitUser, gitToken, gitName, gitEmail); err != nil {\n\t\treturn fmt.Errorf(\"configure git in container: %w\", err)\n\t}\n\n\t// The initial clone happens inside the sandbox, so the working tree\n\t// is owned by the container's user and git never runs on the host.\n\tif ev.Kind == forgejo.EventPRReview {\n\t\tif err := clonePRHead(ctx, r.docker, containerID, repoInfo.CloneURL, repoInfo.DefaultBranch, branch, ev.Index); err != nil {\n\t\t\treturn fmt.Errorf(\"prepare git working tree: %w\", err)\n\t\t}\n\t} else {\n\t\tif err := cloneAndBranch(ctx, r.docker, containerID, repoInfo.CloneURL, repoInfo.DefaultBranch, branch); err != nil {\n\t\t\treturn fmt.Errorf(\"prepare git working tree: %w\", err)\n\t\t}\n\t}\n\n\tlogAppend := func(stream, line string) {\n\t\tif err := r.store.AppendLog(context.Background(), jobID, stream, line); err != nil {\n\t\t\tlogger.Warn(\"failed to append log\", \"error\", err)\n\t\t}\n\t}\n\n\trunCtx := \u0026runContext{\n\t\tdocker: r.docker,\n\t\tcontainerID: containerID,\n\t\tforgejo: \u0026runForgejoActions{\n\t\t\tclient: r.forgejoAs(agent.Name, agent.Token),\n\t\t\towner: ev.Owner,\n\t\t\trepo: ev.Repo,\n\t\t\tindex: ev.Index,\n\t\t\tlogger: logger,\n\t\t},\n\t}\n\n\tllmClient := llm.NewClient(llmCfg)\n\n\tsystemPrompt := defaultSystemPrompt + identitySection(agent.Name, roster)\n\n\tinstructions := r.cfg.EventInstructions(ev.Kind)\n\n\t// Fetch the full comment thread so the agent sees everything that's","start_line":100,"end_line":260,"total_lines":503,"truncated":true}
{"path":"internal","recursive":true}
/project/internal/agentrun /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/loop_test.go /project/internal/agentrun/roster.go /project/internal/agentrun/roster_test.go /project/internal/agentrun/run.go /project/internal/agentrun/run_test.go /project/internal/agentrun/sandboxgit.go /project/internal/agentrun/sandboxgit_test.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/client_test.go /project/internal/forgejo/event.go /project/internal/forgejo/poll.go /project/internal/forgejo/poll_test.go /project/internal/forgejo/repo_filter.go /project/internal/forgejo/repo_filter_test.go /project/internal/forgejo/watcher.go /project/internal/forgejo/watcher_test.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/arguments.go /project/internal/llm/arguments_test.go /project/internal/llm/client.go /project/internal/llm/error.go /project/internal/llm/error_test.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/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
{"end_line":120,"path":"internal/agentrun/docker.go","start_line":1}
{"path":"internal/agentrun/docker.go","content":"package agentrun\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/docker/docker/api/types/container\"\n\t\"github.com/docker/docker/client\"\n\t\"github.com/docker/docker/pkg/stdcopy\"\n)\n\n// containerCPUs and containerMemory bound each agent container's\n// resource usage; there's no per-agent config knob for this yet (see\n// TODO.md), so every run gets the same sane default.\nconst (\n\tcontainerNanoCPUs = 2_000_000_000 // 2 CPUs\n\tcontainerMemory = 2 \u003c\u003c 30 // 2 GiB\n)\n\ntype dockerRuntime struct {\n\tcli *client.Client\n}\n\nfunc newDockerRuntime() (*dockerRuntime, error) {\n\tcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"docker client: %w\", err)\n\t}\n\n\treturn \u0026dockerRuntime{cli: cli}, nil\n}\n\n// createContainer creates and starts a container from image with the\n// given bind mounts, kept alive with `sleep infinity` regardless of the\n// image's own entrypoint so it can be repeatedly `exec`'d into.\nfunc (d *dockerRuntime) createContainer(ctx context.Context, image string, binds []string, name string) (string, error) {\n\tresp, err := d.cli.ContainerCreate(ctx,\n\t\t\u0026container.Config{\n\t\t\tImage: image,\n\t\t\tEntrypoint: []string{\"sleep\"},\n\t\t\tCmd: []string{\"infinity\"},\n\t\t\tWorkingDir: \"/project\",\n\t\t},\n\t\t\u0026container.HostConfig{\n\t\t\tBinds: binds,\n\t\t\tResources: container.Resources{\n\t\t\t\tNanoCPUs: containerNanoCPUs,\n\t\t\t\tMemory: containerMemory,\n\t\t\t},\n\t\t},\n\t\tnil, nil, name)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"create container: %w\", err)\n\t}\n\n\tif err := d.cli.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {\n\t\treturn \"\", fmt.Errorf(\"start container: %w\", err)\n\t}\n\n\treturn resp.ID, nil\n}\n\n// exec runs command via `sh -c` inside containerID and returns its\n// stdout and stderr as separate strings, plus its exit code.\n//\n// No TTY is attached: a TTY would make every command believe it is\n// interactive (launching pagers, prompting for credentials, ...) and\n// the line discipline would rewrite stdout's line endings to CRLF.\n// Without a TTY, Docker frames the attached stream with its stdcopy\n// format, so the two streams are demultiplexed back apart with\n// stdcopy.StdCopy.\nfunc (d *dockerRuntime) exec(ctx context.Context, containerID, command string) (string, string, int, error) {\n\tcreated, err := d.cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{\n\t\tCmd: []string{\"sh\", \"-c\", command},\n\t\t// GIT_TERMINAL_PROMPT=0 stops git from ever trying to read a\n\t\t// credential from the terminal. With no TTY attached such a\n\t\t// prompt would fail anyway, but failing fast with a clear error\n\t\t// is preferable to any chance of a hang.\n\t\tEnv: []string{\"GIT_TERMINAL_PROMPT=0\"},\n\t\tAttachStdout: true,\n\t\tAttachStderr: true,\n\t})\n\tif err != nil {\n\t\treturn \"\", \"\", 0, fmt.Errorf(\"exec create: %w\", err)\n\t}\n\n\tattached, err := d.cli.ContainerExecAttach(ctx, created.ID, container.ExecAttachOptions{})\n\tif err != nil {\n\t\treturn \"\", \"\", 0, fmt.Errorf(\"exec attach: %w\", err)\n\t}\n\tdefer attached.Close()\n\n\t// Once hijacked, this stream is a raw connection that ctx cancellation\n\t// no longer reaches โ a wedged child process (anything reading a stdin\n\t// nobody attaches) would otherwise block this read forever, past the\n\t// run's deadline, with no way to recover the job. Race the read against\n\t// ctx and force-remove the container to sever the connection if it\n\t// fires.\n\ttype readResult struct {\n\t\tstdout []byte\n\t\tstderr []byte\n\t\terr error\n\t}\n\n\treadDone := make(chan readResult, 1)\n\n\tgo func() {\n\t\tvar stdout, stderr bytes.Buffer\n\n\t\t// Without a TTY the attached stream is Docker's stdcopy-framed\n\t\t// multiplex of the process's stdout and stderr; StdCopy splits\n\t\t// it back into the two separate streams.\n\t\t_, err := stdcopy.StdCopy(\u0026stdout, \u0026stderr, attached.Reader)\n\n\t\treadDone \u003c- readResult{stdout.Bytes(), stderr.Bytes(), err}\n\t}()\n\n\tvar stdout, stderr []byte","start_line":1,"end_line":120,"total_lines":163,"truncated":true}
{"query":"zoo-issue"}
/project/internal/agentrun/run.go:204: }, fmt.Sprintf("zoo-issue-%d-%s", ev.Index, agent.Name))
{"query":"createContainer|containerName"}
/project/internal/agentrun/docker.go:35:// createContainer creates and starts a container from image with the
/project/internal/agentrun/docker.go:38:func (d *dockerRuntime) createContainer(ctx context.Context, image string, binds []string, name string) (string, error) {
/project/internal/agentrun/docker_integration_test.go:26: containerID, err := rt.createContainer(ctx, "debian:unstable", nil, "zoo-test-run")
/project/internal/agentrun/docker_integration_test.go:68: containerID, err := rt.createContainer(ctx, "debian:unstable", nil, "zoo-test-streams")
/project/internal/agentrun/docker_integration_test.go:140: containerID, err := rt.createContainer(ctx, "golang:latest", []string{projectDir + ":/project"}, "zoo-test-git")
/project/internal/agentrun/docker_integration_test.go:221: containerID, err := rt.createContainer(ctx, "golang:latest", []string{
/project/internal/agentrun/run.go:201: containerID, err := r.docker.createContainer(ctx, dockerImage, []string{
{"end_line":80,"path":"internal/agentrun/run_test.go","start_line":1}
{"path":"internal/agentrun/run_test.go","content":"package agentrun\n\nimport (\n\t\"io\"\n\t\"log/slog\"\n\t\"testing\"\n\n\t\"github.com/abrander/zoo/internal/livelog\"\n)\n\n// streamHooksTestRunner builds a Runner with just the fields streamHooks\n// touches (hub + logger), so the test doesn't have to stand up Docker.\nfunc streamHooksTestRunner(t *testing.T) (*Runner, *livelog.Hub) {\n\tt.Helper()\n\n\thub := livelog.NewHub()\n\n\treturn \u0026Runner{hub: hub, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}, hub\n}\n\n// collectEvents subscribes to jobID, runs fn (which publishes events\n// synchronously), and returns everything published, in order.\nfunc collectEvents(t *testing.T, hub *livelog.Hub, jobID string, fn func()) []livelog.Event {\n\tt.Helper()\n\n\tbuffered, ch, cancel := hub.Subscribe(jobID)\n\tdefer cancel()\n\n\tfn()\n\n\tevents := append([]livelog.Event(nil), buffered...)\n\ndrain:\n\tfor {\n\t\tselect {\n\t\tcase ev, ok := \u003c-ch:\n\t\t\tif !ok {\n\t\t\t\tbreak drain\n\t\t\t}\n\t\t\tevents = append(events, ev)\n\t\tdefault:\n\t\t\tbreak drain\n\t\t}\n\t}\n\n\treturn events\n}\n\nfunc hasType(events []livelog.Event, want livelog.Type) bool {\n\tfor _, ev := range events {\n\t\tif ev.Type == want {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// A whitespace-only content block โ the model's habit on reasoning +\n// tool-call turns โ must not open a block: no start/end events, and\n// nothing persisted. This is what made the live view show an empty\n// block while the static view (buildBlocks) dropped it (issue #62).\nfunc TestStreamHooksDropsWhitespaceOnlyBlocks(t *testing.T) {\n\tr, hub := streamHooksTestRunner(t)\n\n\tvar persisted []string\n\n\thooks := r.streamHooks(\"job-1\", func(stream, line string) {\n\t\tpersisted = append(persisted, stream+\":\"+line)\n\t})\n\n\tevents := collectEvents(t, hub, \"job-1\", func() {\n\t\t// A reasoning + tool-call turn where the server emits a\n\t\t// whitespace-only content field.\n\t\thooks.OnReasoningDelta(\"thinking hard\")\n\t\thooks.OnContentDelta(\" \")\n\t\thooks.OnTurnEnd()\n\t})\n\n\t// The reasoning block opened and closed normally.","start_line":1,"end_line":80,"total_lines":159,"truncated":true}
{"end_line":1,"path":"internal/agentrun/run.go","start_line":1}
{"path":"internal/agentrun/run.go","content":"// Package agentrun creates the throwaway per-job environment an agent","start_line":1,"end_line":1,"total_lines":503,"truncated":true}
{"end_line":100,"path":"internal/agentrun/run.go","start_line":1}
{"path":"internal/agentrun/run.go","content":"// Package agentrun creates the throwaway per-job environment an agent\n// runs in: a Docker container with the agent's git working tree cloned\n// inside it (authenticated with the agent's own Forgejo token), the\n// triggering event written to /event, and the tool-calling loop\n// (internal/llm + internal/tools) driven against it.\npackage agentrun\n\nimport (\n\t\"context\"\n\t_ \"embed\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n\t\"github.com/abrander/zoo/internal/forgejo\"\n\t\"github.com/abrander/zoo/internal/livelog\"\n\t\"github.com/abrander/zoo/internal/llm\"\n\t\"github.com/abrander/zoo/internal/store\"\n)\n\n//go:embed system.md\nvar defaultSystemPrompt string\n\n// DefaultTimeout bounds a single agent run's wall-clock time if the\n// caller doesn't override it.\nconst DefaultTimeout = 120 * time.Minute\n\ntype Runner struct {\n\tdocker *dockerRuntime\n\tforgejo *forgejo.Client\n\tstore *store.Store\n\thub *livelog.Hub\n\tcfg *config.Config\n\tlogger *slog.Logger\n\ttimeout time.Duration\n\tkeepOnFailure bool\n\n\t// masterUser is the Forgejo username that owns the shared master\n\t// token. Its comments are always directed at human operators, never\n\t// at agents, so they're kept out of the agent's briefing. An empty\n\t// value disables the filter.\n\tmasterUser string\n\n\tagentClientsMu sync.Mutex\n\tagentClients map[string]*forgejo.Client\n}\n\nfunc NewRunner(cfg *config.Config, fg *forgejo.Client, st *store.Store, hub *livelog.Hub, masterUser string, logger *slog.Logger, timeout time.Duration, keepOnFailure bool) (*Runner, error) {\n\tdocker, err := newDockerRuntime()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif timeout \u003c= 0 {\n\t\ttimeout = DefaultTimeout\n\t}\n\n\treturn \u0026Runner{\n\t\tdocker: docker,\n\t\tforgejo: fg,\n\t\tstore: st,\n\t\thub: hub,\n\t\tcfg: cfg,\n\t\tlogger: logger,\n\t\ttimeout: timeout,\n\t\tkeepOnFailure: keepOnFailure,\n\t\tmasterUser: masterUser,\n\t\tagentClients: make(map[string]*forgejo.Client),\n\t}, nil\n}\n\n// 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","start_line":1,"end_line":100,"total_lines":503,"truncated":true}
{"query":"container"}
/project/README.md:5:Zoo watches a [Forgejo](https://codeberg.org/forgejo/forgejo) instance for issue and pull request events, then dispatches them to configured AI agents running in isolated Docker containers. Each agent has its own role, LLM model, and personality โ like a menagerie working together to keep your projects moving.
/project/README.md:15:- **Isolated execution** โ Agents run in Docker containers with full filesystem access but no persistence between runs.
/project/README.md:98: docker_image = "golang:latest" # base image for agent containers
/project/README.md:115:operations (pull, push, ...) run inside the container with that
/project/README.md:149:| `-keep-on-failure`| `false` | Preserve container after failure for debugging |
/project/go.sum:17:github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
/project/go.sum:18:github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
/project/go.sum:19:github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
/project/go.sum:20:github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
/project/go.sum:21:github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
/project/go.sum:22:github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
/project/go.sum:78:github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
/project/go.sum:79:github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
/project/go.sum:80:github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
/project/go.sum:81:github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
/project/go.mod:19: github.com/containerd/errdefs v1.0.0 // indirect
/project/go.mod:20: github.com/containerd/errdefs/pkg v0.3.0 // indirect
/project/go.mod:21: github.com/containerd/log v0.1.0 // indirect
/project/go.mod:40: github.com/opencontainers/go-digest v1.0.0 // indirect
/project/go.mod:41: github.com/opencontainers/image-spec v1.1.1 // indirect
/project/main.go:3:// Docker containers, and serves a small dashboard over the result.
/project/main.go:40: keepOnFailure = flag.Bool("keep-on-failure", false, "keep the container and clone around after a failed run, for debugging")
/project/internal/web/templates.go:105: /* โโ Main container โโโโโโโโโโโโโโโโโโโโโโโโโโโ */
/project/internal/web/templates.go:106: .container {
/project/internal/web/templates.go:411: .log-container {
/project/internal/web/templates.go:462: .log-container .block + .block {
/project/internal/web/templates.go:665: .container { padding: 1rem; }
/project/internal/web/templates.go:841:<div class="container">
/project/internal/web/templates.go:986:<div class="container">
/project/internal/web/templates.go:1035:<div class="container">
/project/internal/web/templates.go:1074: <div class="log-container" id="log">
/project/internal/web/web_test.go:157:// its scroll math against the log container (the actual scrollable
/project/internal/tools/grep_search.go:24: "Search for an extended regular expression (ERE, e.g. 'foo|bar', 'func\\(') in project files (in the project container). Supports context lines, a glob filter, and case sensitivity control.")
/project/internal/tools/context.go:3:// container (via Exec) and the Forgejo issue/PR that triggered the run
/project/internal/tools/context.go:12: // Exec runs command inside the run's container via `sh -c` and
/project/internal/tools/remove_file.go:17: "Remove an existing file in the project container.")
/project/internal/tools/write_file.go:17: "Write a new file with the given content in the project container. Use this to create new files. If the file already exists, this will fail.")
/project/internal/tools/move_file.go:18: "Rename or move a file or directory in the project container.")
/project/internal/tools/read_file.go:31: "Read lines from a file in the project container. Returns the content of the file along with metadata such as total lines.")
/project/internal/tools/list_files.go:19: "List files in a directory in the project container, optionally recursively. Hidden (dot) entries are skipped.")
/project/internal/tools/bash.go:21: "Execute a shell command in the project container. Use this to run shell commands and scripts.")
/project/internal/forgejo/client.go:36:// tree an agent's container can read.
/project/internal/forgejo/client.go:477:// authorship inside that agent's container, and its avatar URL (surfaced
/project/internal/forgejo/event.go:59: // equivalent when polling), written to /event in the agent container.
/project/internal/agentrun/context.go:11:// shell commands via docker exec against the run's container, and
/project/internal/agentrun/context.go:15: containerID string
/project/internal/agentrun/context.go:19:// Exec runs command inside the container via `sh -c` and returns its
/project/internal/agentrun/context.go:23: stdout, stderr, exitCode, err := c.docker.exec(ctx, c.containerID, command)
/project/internal/agentrun/system.md:13: at `/event` inside the container, and is also included below.
/project/internal/agentrun/system.md:21: `move_file`, `remove_file` operate on the project container.
/project/internal/agentrun/sandboxgit.go:11:// This file makes git "just work" inside the agent's container: the
/project/internal/agentrun/sandboxgit.go:13:// credential is written to the container's system gitconfig so every
/project/internal/agentrun/sandboxgit.go:37:// runSandboxGit runs `git <args...>` inside containerID (in its
/project/internal/agentrun/sandboxgit.go:41:func runSandboxGit(ctx context.Context, rt *dockerRuntime, containerID string, args ...string) (string, error) {
/project/internal/agentrun/sandboxgit.go:42: stdout, stderr, exitCode, err := rt.exec(ctx, containerID, shellGitCmd(args...))
/project/internal/agentrun/sandboxgit.go:86:// configureSandboxGit writes the container's system gitconfig so git
/project/internal/agentrun/sandboxgit.go:90:// regardless of which UID the container runs git as;
/project/internal/agentrun/sandboxgit.go:102:// The credential lives in the container's own filesystem (ephemeral,
/project/internal/agentrun/sandboxgit.go:103:// torn down with the container), never in the bind-mounted working
/project/internal/agentrun/sandboxgit.go:107:func configureSandboxGit(ctx context.Context, rt *dockerRuntime, containerID, cloneURL, user, token, name, email string) error {
/project/internal/agentrun/sandboxgit.go:108: if _, err := runSandboxGit(ctx, rt, containerID, "config", "--global", "--add", "safe.directory", "*"); err != nil {
/project/internal/agentrun/sandboxgit.go:112: if _, err := runSandboxGit(ctx, rt, containerID, "config", "--global", "user.name", name); err != nil {
/project/internal/agentrun/sandboxgit.go:116: if _, err := runSandboxGit(ctx, rt, containerID, "config", "--global", "user.email", email); err != nil {
/project/internal/agentrun/sandboxgit.go:120: if _, err := runSandboxGit(ctx, rt, containerID, "config", "--global", "http."+forgeHost(cloneURL)+".extraHeader", gitAuthHeader(user, token)); err != nil {
/project/internal/agentrun/sandboxgit.go:124: if _, err := runSandboxGit(ctx, rt, containerID, "config", "--global", "push.autoSetupRemote", "true"); err != nil {
/project/internal/agentrun/sandboxgit.go:131:// cloneAndBranch clones cloneURL into /project inside the container
/project/internal/agentrun/sandboxgit.go:136:func cloneAndBranch(ctx context.Context, rt *dockerRuntime, containerID, cloneURL, defaultBranch, branch string) error {
/project/internal/agentrun/sandboxgit.go:137: if _, err := runSandboxGit(ctx, rt, containerID, "clone", "--branch", defaultBranch, "--single-branch", cloneURL, "/project"); err != nil {
/project/internal/agentrun/sandboxgit.go:141: if _, err := runSandboxGit(ctx, rt, containerID, "checkout", "-b", branch); err != nil {
/project/internal/agentrun/sandboxgit.go:148:// clonePRHead clones cloneURL into /project inside the container and
/project/internal/agentrun/sandboxgit.go:154:func clonePRHead(ctx context.Context, rt *dockerRuntime, containerID, cloneURL, defaultBranch, headRef string, index int64) error {
/project/internal/agentrun/sandboxgit.go:155: if _, err := runSandboxGit(ctx, rt, containerID, "clone", "--branch", defaultBranch, "--single-branch", cloneURL, "/project"); err != nil {
/project/internal/agentrun/sandboxgit.go:161: if _, err := runSandboxGit(ctx, rt, containerID, "fetch", "--no-tags", "origin", fetchRef); err != nil {
/project/internal/agentrun/sandboxgit.go:165: if _, err := runSandboxGit(ctx, rt, containerID, "checkout", "-B", headRef, "FETCH_HEAD"); err != nil {
/project/internal/agentrun/docker.go:9: "github.com/docker/docker/api/types/container"
/project/internal/agentrun/docker.go:14:// containerCPUs and containerMemory bound each agent container's
/project/internal/agentrun/docker.go:18: containerNanoCPUs = 2_000_000_000 // 2 CPUs
/project/internal/agentrun/docker.go:19: containerMemory = 2 << 30 // 2 GiB
/project/internal/agentrun/docker.go:35:// createContainer creates and starts a container from image with the
/project/internal/agentrun/docker.go:38:func (d *dockerRuntime) createContainer(ctx context.Context, image string, binds []string, name string) (string, error) {
/project/internal/agentrun/docker.go:39: resp, err := d.cli.ContainerCreate(ctx,
/project/internal/agentrun/docker.go:40: &container.Config{
/project/internal/agentrun/docker.go:46: &container.HostConfig{
/project/internal/agentrun/docker.go:48: Resources: container.Resources{
/project/internal/agentrun/docker.go:49: NanoCPUs: containerNanoCPUs,
/project/internal/agentrun/docker.go:50: Memory: containerMemory,
/project/internal/agentrun/docker.go:55: return "", fmt.Errorf("create container: %w", err)
/project/internal/agentrun/docker.go:58: if err := d.cli.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
/project/internal/agentrun/docker.go:59: return "", fmt.Errorf("start container: %w", err)
/project/internal/agentrun/docker.go:65:// exec runs command via `sh -c` inside containerID and returns its
/project/internal/agentrun/docker.go:74:func (d *dockerRuntime) exec(ctx context.Context, containerID, command string) (string, string, int, error) {
/project/internal/agentrun/docker.go:75: created, err := d.cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{
/project/internal/agentrun/docker.go:89: attached, err := d.cli.ContainerExecAttach(ctx, created.ID, container.ExecAttachOptions{})
/project/internal/agentrun/docker.go:99: // ctx and force-remove the container to sever the connection if it
/project/internal/agentrun/docker.go:134: _ = d.cli.ContainerRemove(killCtx, containerID, container.RemoveOptions{Force: true})
/project/internal/agentrun/docker.go:140: inspect, err := d.cli.ContainerExecInspect(ctx, created.ID)
/project/internal/agentrun/docker.go:148:// remove force-stops and removes containerID, ignoring "already gone"
/project/internal/agentrun/docker.go:150:func (d *dockerRuntime) remove(ctx context.Context, containerID string) error {
/project/internal/agentrun/docker.go:152: _ = d.cli.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout})
/project/internal/agentrun/docker.go:154: if err := d.cli.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: true}); err != nil {
{"end_line":80,"path":"internal/forgejo/event.go","start_line":1}
{"path":"internal/forgejo/event.go","content":"package forgejo\n\nimport \"encoding/json\"\n\n// Event kinds, matching the values used as event block labels in\n// config.Config (config avoids importing this package, so the kind\n// strings are duplicated by value, not by identity).\nconst (\n\tEventIssueNew = \"issue:new\"\n\tEventIssueComment = \"issue:comment\"\n\tEventIssueAssigned = \"issue:assigned\"\n\tEventPRNew = \"pr:new\"\n\tEventPRReview = \"pr:review\"\n)\n\n// Event is the normalized shape produced by both the webhook receiver and\n// the polling fallback, and is what the scheduler dispatches on.\ntype Event struct {\n\tKind string\n\n\t// ID uniquely identifies this event for dedup, regardless of whether\n\t// it arrived via webhook or polling.\n\tID string\n\n\tOwner, Repo string\n\tIndex int64 // issue or PR number\n\n\tTitle, Body string\n\tAuthor string\n\n\t// Assignee is set for EventIssueAssigned: the Forgejo username the\n\t// issue was assigned to.\n\tAssignee string\n\n\t// PRAuthor is set for PR events: the Forgejo username of the user\n\t// who opened the pull request. For EventPRReview the scheduler\n\t// resolves the event to this user's agent, so the agent that opened\n\t// the PR reacts to the review of it.\n\tPRAuthor string\n\n\t// Comment is set for EventIssueComment.\n\tComment string\n\n\t// Review fields, set for EventPRReview: the submitted review's id,\n\t// verdict, and body. Inline review comments are not part of the\n\t// webhook payload, so they're fetched separately when briefing the\n\t// agent (see Client.ReviewDetail).\n\tReviewID int64\n\tReviewState string\n\tReviewBody string\n\n\t// HeadRef is set for PR events when the source payload carries the\n\t// PR's head branch: the branch the PR's changes live on. A run\n\t// reacting to the PR checks it out so its pushes update the PR\n\t// directly.\n\tHeadRef string\n\n\t// Raw is the full source payload (webhook body, or a synthesized\n\t// equivalent when polling), written to /event in the agent container.\n\tRaw json.RawMessage\n}","start_line":1,"end_line":61,"total_lines":61,"truncated":false}
{"end_line":30,"path":"1","start_line":1}
ERROR CALLING TOOL: read "1": command exited with status 1: read_file: no such file: /project/1
{"end_line":30,"path":"internal/agentrun/docker_integration_test.go","start_line":1}
{"path":"internal/agentrun/docker_integration_test.go","content":"//go:build docker\n\n// Run with: go test -tags docker ./internal/agentrun/...\n// Requires a reachable Docker daemon (DOCKER_HOST or the default socket).\npackage agentrun\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestDockerRuntimeExecRoundTrip(t *testing.T) {\n\trt, err := newDockerRuntime()\n\tif err != nil {\n\t\tt.Fatalf(\"docker client: %v\", err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)\n\tdefer cancel()\n\n\tcontainerID, err := rt.createContainer(ctx, \"debian:unstable\", nil, \"zoo-test-run\")\n\tif err != nil {\n\t\tt.Fatalf(\"create container: %v\", err)\n\t}\n\tdefer rt.remove(context.Background(), containerID)","start_line":1,"end_line":30,"total_lines":287,"truncated":true}
{"end_line":20,"path":"Makefile","start_line":1}
{"path":"Makefile","content":"all: zoo\n\n.PHONY: docker-image doc zoo\n\nzoo:\n\tCGO_ENABLED=0 go build -o zoo .\n\ndocker-image:\n\tdocker build -t devcont:latest docker\n\n${GOPATH}/bin/pkgsite:\n\tgo install golang.org/x/pkgsite/cmd/pkgsite@latest\n\ndoc: ${GOPATH}/bin/pkgsite\n\t(sleep 1 ; xdg-open \"http://127.0.0.1:1234/github.com/abrander/zoo\") \u0026\n\t${GOPATH}/bin/pkgsite -http=:1234","start_line":1,"end_line":16,"total_lines":16,"truncated":false}
{"end_line":20,"path":"internal/forgejo.go","start_line":1}
ERROR CALLING TOOL: read "internal/forgejo.go": command exited with status 1: read_file: no such file: /project/internal/forgejo.go
{"find":"import (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/docker/docker/api/types/container\"\n\t\"github.com/docker/docker/client\"\n\t\"github.com/docker/docker/pkg/stdcopy\"\n)","path":"internal/agentrun/docker.go","replace":"import (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/docker/docker/api/types/container\"\n\t\"github.com/docker/docker/client\"\n\t\"github.com/docker/docker/pkg/stdcopy\"\n)","replace_all":false}
1 occurrences replaced
{"find":"import (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"time\"","path":"internal/runner/docker.go","replace":"import (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"fmt\"\n\t\"time\""}
ERROR CALLING TOOL: command exited with status 1: base64: /project/internal/runner/docker.go: No such file or directory
{"find":"import (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"time\"","path":"internal/agentrun/docker.go","replace":"import (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"fmt\"\n\t\"time\""}
1 occurrences replaced
{"find":"const (\n\tcontainerNanoCPUs = 2_000_000_000 // 2 CPUs\n\tcontainerMemory = 2 << 30 // 2 GiB\n)\n\ntype dockerRuntime struct {","path":"internal/agentrun/docker.go","replace":"const (\n\tcontainerNanoCPUs = 2_000_000_000 // 2 CPUs\n\tcontainerMemory = 2 << 30 // 2 GiB\n)\n\n// containerName builds the name for a run's container: the stable\n// zoo-issue-<n>-<agent> prefix, which keeps it easy to spot which issue\n// and agent a container belongs to, plus a short random hex suffix so\n// two concurrent runs of the same issue by the same agent never collide\n// on the name (issue #78).\nfunc containerName(issue int64, agent string) string {\n\tvar suffix [4]byte\n\n\tif _, err := rand.Read(suffix[:]); err != nil {\n\t\t// crypto/rand failing is effectively impossible on supported\n\t\t// platforms; a time-based suffix still keeps concurrent names\n\t\t// distinct rather than failing the run over a name.\n\t\tnanos := uint32(time.Now().UnixNano())\n\t\tsuffix = [4]byte{byte(nanos >> 24), byte(nanos >> 16), byte(nanos >> 8), byte(nanos)}\n\t}\n\n\treturn fmt.Sprintf(\"zoo-issue-%d-%s-%x\", issue, agent, suffix[:])\n}\n\ntype dockerRuntime struct {"}
1 occurrences replaced
{"find":"\tcontainerID, err := r.docker.createContainer(ctx, dockerImage, []string{\n\t\tprojectDir + \":/project\",\n\t\teventPath + \":/event:ro\",\n\t}, fmt.Sprintf(\"zoo-issue-%d-%s\", ev.Index, agent.Name))","path":"internal/agentrun/run.go","replace":"\tcontainerID, err := r.docker.createContainer(ctx, dockerImage, []string{\n\t\tprojectDir + \":/project\",\n\t\teventPath + \":/event:ro\",\n\t}, containerName(ev.Index, agent.Name))"}
1 occurrences replaced
{"content":"package agentrun\n\nimport (\n\t\"regexp\"\n\t\"strings\"\n\t\"testing\"\n)\n\n// dockerNameRe mirrors the Docker daemon's container-name validation\n// (NameRe in the moby source): an alphanumeric first character, then\n// alphanumerics, dots, underscores, or dashes.\nvar dockerNameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]*$`)\n\n// The name must keep the human-readable zoo-issue-<n>-<agent> prefix\n// (easy to spot which issue and agent a container belongs to) and add a\n// unique 8-hex-char suffix, so concurrent runs of the same issue by the\n// same agent never collide on the name (issue #78).\nfunc TestContainerName(t *testing.T) {\n\tconst prefix = \"zoo-issue-78-greg-\"\n\n\tseen := make(map[string]bool)\n\n\tfor i := 0; i < 1000; i++ {\n\t\tname := containerName(78, \"greg\")\n\n\t\tif !strings.HasPrefix(name, prefix) {\n\t\t\tt.Fatalf(\"name %q lost its %s prefix\", name, prefix)\n\t\t}\n\n\t\tsuffix := name[len(prefix):]\n\n\t\tif len(suffix) != 8 || !isLowerHex(suffix) {\n\t\t\tt.Fatalf(\"suffix %q is not 8 lowercase hex chars\", suffix)\n\t\t}\n\n\t\tif !dockerNameRe.MatchString(name) {\n\t\t\tt.Fatalf(\"name %q is not a valid Docker container name\", name)\n\t\t}\n\n\t\tif seen[name] {\n\t\t\tt.Fatalf(\"generated duplicate container name %q\", name)\n\t\t}\n\n\t\tseen[name] = true\n\t}\n}\n\n// Different issues and agents must stay distinguishable in the name.\nfunc TestContainerNameDistinguishesIssueAndAgent(t *testing.T) {\n\tif !strings.HasPrefix(containerName(78, \"greg\"), \"zoo-issue-78-greg-\") {\n\t\tt.Fatalf(\"issue 78 / greg: wrong prefix\")\n\t}\n\n\tif !strings.HasPrefix(containerName(78, \"mika\"), \"zoo-issue-78-mika-\") {\n\t\tt.Fatalf(\"issue 78 / mika: wrong prefix\")\n\t}\n\n\tif !strings.HasPrefix(containerName(79, \"greg\"), \"zoo-issue-79-greg-\") {\n\t\tt.Fatalf(\"issue 79 / greg: wrong prefix\")\n\t}\n}\n\nfunc isLowerHex(s string) bool {\n\tfor _, c := range s {\n\t\tif !strings.ContainsRune(\"0123456789abcdef\", c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\treturn true\n}\n","path":"internal/agentrun/docker_test.go"}
OK
{"command":"cd /project && gofmt -l . && go vet ./... && go build ./... && go test ./internal/agentrun/ -run 'TestContainerName' -v"}
internal/web/web.go === RUN TestContainerName --- PASS: TestContainerName (0.00s) === RUN TestContainerNameDistinguishesIssueAndAgent --- PASS: TestContainerNameDistinguishesIssueAndAgent (0.00s) PASS ok github.com/abrander/zoo/internal/agentrun 0.005s go: downloading github.com/docker/docker v28.5.2+incompatible go: downloading github.com/hashicorp/hcl/v2 v2.24.0 go: downloading codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 go: downloading github.com/google/uuid v1.6.0 go: downloading modernc.org/sqlite v1.56.0 go: downloading github.com/docker/go-connections v0.8.1 go: downloading github.com/docker/go-units v0.5.0 go: downloading github.com/moby/docker-image-spec v1.3.1 go: downloading github.com/opencontainers/image-spec v1.1.1 go: downloading github.com/containerd/errdefs v1.0.0 go: downloading github.com/containerd/errdefs/pkg v0.3.0 go: downloading github.com/distribution/reference v0.6.0 go: downloading github.com/opencontainers/go-digest v1.0.0 go: downloading github.com/pkg/errors v0.9.1 go: downloading go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 go: downloading go.opentelemetry.io/otel/trace v1.45.0 go: downloading go.opentelemetry.io/otel v1.45.0 go: downloading github.com/42wim/httpsig v1.2.3 go: downloading github.com/go-fed/httpsig v1.1.0 go: downloading github.com/hashicorp/go-version v1.7.0 go: downloading golang.org/x/crypto v0.39.0 go: downloading github.com/felixge/httpsnoop v1.1.0 go: downloading go.opentelemetry.io/otel/metric v1.45.0 go: downloading github.com/agext/levenshtein v1.2.1 go: downloading github.com/apparentlymart/go-textseg/v15 v15.0.0 go: downloading github.com/mitchellh/go-wordwrap v1.0.1 go: downloading github.com/zclconf/go-cty v1.16.3 go: downloading github.com/go-logr/logr v1.4.4 go: downloading golang.org/x/sys v0.47.0 go: downloading github.com/google/go-cmp v0.7.0 go: downloading github.com/go-logr/stdr v1.2.2 go: downloading go.opentelemetry.io/auto/sdk v1.2.1 go: downloading github.com/cespare/xxhash/v2 v2.3.0 go: downloading golang.org/x/text v0.40.0 go: downloading modernc.org/libc v1.74.4 go: downloading github.com/dustin/go-humanize v1.0.1 go: downloading modernc.org/mathutil v1.7.1 go: downloading modernc.org/memory v1.11.0 go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec
{"command":"cd /project && git status --short && git stash -q && gofmt -l . ; git stash pop -q && gofmt -l internal/agentrun/ && echo \"agentrun gofmt clean\""}
M internal/agentrun/docker.go M internal/agentrun/run.go ?? internal/agentrun/docker_test.go internal/web/web.go agentrun gofmt clean
{"command":"cd /project && true; cd /project && go test ./... 2>&1 | tail -20"}
? github.com/abrander/zoo [no test files] ok github.com/abrander/zoo/internal/agentrun 0.027s ok github.com/abrander/zoo/internal/config 0.006s ok github.com/abrander/zoo/internal/forgejo 1.802s 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 3.258s ok github.com/abrander/zoo/internal/store 1.746s ok github.com/abrander/zoo/internal/tools 0.003s ok github.com/abrander/zoo/internal/web 1.537s
{"command":"cd /project && git diff"}
diff --git a/internal/agentrun/docker.go b/internal/agentrun/docker.go
index 26304ef..82afa88 100644
--- a/internal/agentrun/docker.go
+++ b/internal/agentrun/docker.go
@@ -3,6 +3,7 @@ package agentrun
import (
"bytes"
"context"
+ "crypto/rand"
"fmt"
"time"
@@ -19,6 +20,25 @@ const (
containerMemory = 2 << 30 // 2 GiB
)
+// containerName builds the name for a run's container: the stable
+// zoo-issue-<n>-<agent> prefix, which keeps it easy to spot which issue
+// and agent a container belongs to, plus a short random hex suffix so
+// two concurrent runs of the same issue by the same agent never collide
+// on the name (issue #78).
+func containerName(issue int64, agent string) string {
+ var suffix [4]byte
+
+ if _, err := rand.Read(suffix[:]); err != nil {
+ // crypto/rand failing is effectively impossible on supported
+ // platforms; a time-based suffix still keeps concurrent names
+ // distinct rather than failing the run over a name.
+ nanos := uint32(time.Now().UnixNano())
+ suffix = [4]byte{byte(nanos >> 24), byte(nanos >> 16), byte(nanos >> 8), byte(nanos)}
+ }
+
+ return fmt.Sprintf("zoo-issue-%d-%s-%x", issue, agent, suffix[:])
+}
+
type dockerRuntime struct {
cli *client.Client
}
diff --git a/internal/agentrun/run.go b/internal/agentrun/run.go
index beec210..4cfd87f 100644
--- a/internal/agentrun/run.go
+++ b/internal/agentrun/run.go
@@ -201,7 +201,7 @@ func (r *Runner) Run(ctx context.Context, jobID string, agent config.AgentConfig
containerID, err := r.docker.createContainer(ctx, dockerImage, []string{
projectDir + ":/project",
eventPath + ":/event:ro",
- }, fmt.Sprintf("zoo-issue-%d-%s", ev.Index, agent.Name))
+ }, containerName(ev.Index, agent.Name))
if err != nil {
return fmt.Errorf("start container: %w", err)
}
{"command":"cd /project && git add internal/agentrun/docker.go internal/agentrun/run.go internal/agentrun/docker_test.go && git commit -m \"Make agent container names unique per run\n\nKeep the human-readable zoo-issue-<n>-<agent> prefix but append a\nshort random hex suffix (8 hex chars, crypto/rand) so two concurrent\nruns of the same issue by the same agent can no longer collide on the\ncontainer name.\n\nCloses #78\" && git push origin issue-78-greg"}
[issue-78-greg db98498] Make agent container names unique per run 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 internal/agentrun/docker_test.go To https://code.stdio.dk/abrander/zoo.git * [new branch] issue-78-greg -> issue-78-greg
{"base":"main","body":"## Problem\n\nAgent container names were `zoo-issue-<n>-<agent>` โ deterministic per issue + agent. When two runs of the same issue by the same agent overlapped (e.g. an `issue:assigned` event and a comment event on the same issue, or a re-run before the previous container was torn down), the second `ContainerCreate` failed with a name collision.\n\n## Change\n\nKept the readable prefix (which makes it easy to spot which issue/agent a container belongs to, as requested in the issue) and appended a short random hex suffix:\n\n```\nzoo-issue-78-greg-a1b2c3d4\n```\n\n- `containerName(issue, agent)` in `internal/agentrun/docker.go` draws 4 bytes from `crypto/rand` โ 32 bits of entropy, so a collision between two concurrent runs of the same issue + agent is ~1 in 4 billion.\n- The suffix is lowercase hex, so the full name stays valid against Docker's name regex (`^[a-zA-Z0-9][a-zA-Z0-9_.-]*$`).\n- If `crypto/rand` ever fails (effectively impossible), it falls back to a time-based suffix rather than failing the run over a name.\n- The **branch** name (`issue-<n>-<agent>`) is intentionally unchanged โ it must stay stable so an agent can re-push to the same branch.\n\n## Tests\n\nNew `internal/agentrun/docker_test.go` (pure unit test, no Docker daemon needed):\n\n- 1000 generated names all keep the `zoo-issue-78-greg-` prefix, carry an 8-char lowercase-hex suffix, match Docker's name regex, and are pairwise unique.\n- Different issues/agents stay distinguishable in the name.\n\n`go build ./...`, `go vet ./...`, and `go test ./...` all pass.\n\nCloses #78","head":"issue-78-greg","title":"Make agent container names unique per run"}
Pull request opened
{"body":"Hi @abrander,\n\nGood catch โ the name was fully deterministic (`zoo-issue-<n>-<agent>`), so any two overlapping runs of the same issue by the same agent (e.g. an assign event plus a comment event on the same issue) would collide on container creation.\n\nI've opened a PR that keeps the prefix you like and appends a short random hex suffix:\n\n```\nzoo-issue-78-greg-a1b2c3d4\n```\n\n- 8 hex chars from `crypto/rand` (32 bits) โ collisions between concurrent runs of the same issue + agent are ~1 in 4 billion, while the name stays short and greppable.\n- The suffix is lowercase hex, so the name stays valid against Docker's name regex.\n- The **branch** name (`issue-<n>-<agent>`) is intentionally left alone โ it must stay stable so an agent can re-push to the same branch.\n\nUnit tests cover the prefix, suffix format, Docker name validity, and uniqueness across 1000 generated names. `go build`, `go vet`, and `go test ./...` all pass."}
Comment posted