Details and log output for this agent run.
{"command":"cd /project && git status && git log --oneline -10 && git branch -a"}
On branch issue-72-greg nothing to commit, working tree clean 81330d0 Ignore comments by the master token owner everywhere 49416c8 Merge pull request 'Live dashboard at /: jobs-changed SSE stream' (#67) from issue-64-greg into main bf26c51 web: make the dashboard at / live via a jobs-changed SSE stream f443489 Merge pull request 'grep_search: treat a wildcard-free glob as a literal file path' (#66) from issue-65-greg into main 474ef42 grep_search: treat a wildcard-free glob as a literal file path 081d4bd Merge pull request 'livelog: open reasoning/content blocks lazily, on first real text' (#63) from issue-62-greg into main 49f034a livelog: open reasoning/content blocks lazily, on first real text f9b1877 Add some retry logic for the LLM backend. 7423d86 Try to re-roll broken tool calls. f58963a Merge pull request 'Jobs page: follow the live log tail, with a jump-to-bottom button' (#58) from issue-57-greg into main * issue-72-greg main remotes/origin/main
{"path":"."}
drwxr-xr-x 6 user user 4096 Aug 27 07:17 . drwxr-xr-x 1 root root 4096 Aug 27 07:17 .. drwxr-xr-x 7 user user 4096 Aug 27 07:17 .git -rw-r--r-- 1 user user 22 Aug 27 07:17 .gitignore -rw-r--r-- 1 user user 6599 Aug 27 07:17 README.md drwxr-xr-x 3 user user 4096 Aug 27 07:17 cmd drwxr-xr-x 2 user user 4096 Aug 27 07:17 docker -rw-r--r-- 1 user user 2628 Aug 27 07:17 go.mod -rw-r--r-- 1 user user 16578 Aug 27 07:17 go.sum drwxr-xr-x 11 user user 4096 Aug 27 07:17 internal -rw-r--r-- 1 user user 3092 Aug 27 07:17 zoo.hcl.example
{"command":"cd /project && git show --stat HEAD"}
commit 81330d03431981925bf393ed80846e58a9e5ad85
Author: Greg Coolio <greg@example.com>
Date: Thu Aug 27 00:00:04 2026 +0000
Ignore comments by the master token owner everywhere
Comments written by the owner of the master Forgejo token are always
directed at human operators (log messages, debug notes), never at
agents. Resolve the token's owner via GET /user at startup and filter
its comments in the two places comments reach agents:
- Watcher.dispatch: issue:comment events by the master user are
dropped before dedup (webhook and poll paths alike), so they neither
trigger a run nor occupy a dedup id. Non-comment events by that user
(new issues, assignments, PRs) still pass β that's how work gets
assigned to agents.
- agentrun briefing: the master user's comments are removed from the
comment thread an agent is seeded with.
Startup fails fast (after a short retry) if the owner cannot be
resolved: an unusable master token breaks everything else in the
daemon anyway.
README.md | 1 +
cmd/zoo/main.go | 40 +++++++++++++++++++-
internal/agentrun/run.go | 34 ++++++++++++++++-
internal/agentrun/seed_test.go | 26 +++++++++++++
internal/forgejo/client.go | 13 +++++++
internal/forgejo/client_test.go | 27 ++++++++++++++
internal/forgejo/poll_test.go | 4 +-
internal/forgejo/watcher.go | 31 +++++++++++----
internal/forgejo/watcher_test.go | 81 ++++++++++++++++++++++++++++++++++++++--
9 files changed, 242 insertions(+), 15 deletions(-)
{"end_line":200,"path":"internal/forgejo/client.go","start_line":1}
{"path":"internal/forgejo/client.go","content":"// Package forgejo wraps the Forgejo SDK and turns webhook deliveries and\n// polling results into a common Event stream for the scheduler.\npackage forgejo\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\tsdk \"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n)\n\n// Client is zoo's single shared Forgejo identity, used both for the\n// event sources (webhook/poll) and for actions agents/scheduler take\n// (comments, labels, PRs).\ntype Client struct {\n\tsdk *sdk.Client\n\n\tbaseURL string\n\ttoken string\n}\n\nfunc NewClient(cfg config.Forgejo) (*Client, error) {\n\tc, err := sdk.NewClient(cfg.URL, sdk.SetToken(cfg.Token))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"forgejo client: %w\", err)\n\t}\n\n\treturn \u0026Client{sdk: c, baseURL: cfg.URL, token: cfg.Token}, nil\n}\n\n// Token returns the shared zoo Forgejo identity's token, e.g. for\n// authenticating a host-side git clone/push against Forgejo (see\n// internal/agentrun) without ever writing the credential into a working\n// tree an agent's container can read.\nfunc (c *Client) Token() string {\n\treturn c.token\n}\n\n// Owner returns the Forgejo username that owns this client's token. For\n// the shared master client that's the human operator whose comments are\n// always directed at humans, never at agents, and which zoo therefore\n// ignores everywhere (see Watcher.dispatch and agentrun's briefing).\nfunc (c *Client) Owner() (string, error) {\n\tu, _, err := c.sdk.GetMyUserInfo()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"get current user: %w\", err)\n\t}\n\n\treturn u.UserName, nil\n}\n\n// 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 \u0026Client{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 \u0026Client{sdk: sudoClient, baseURL: c.baseURL, token: c.token}, nil\n}\n\n// CreateIssueComment posts a comment on the given issue or pull request\n// (Forgejo/Gitea treat PRs as issues for commenting purposes).\nfunc (c *Client) CreateIssueComment(owner, repo string, index int64, body string) error {\n\t_, _, err := c.sdk.CreateIssueComment(owner, repo, index, sdk.CreateIssueCommentOption{Body: body})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"comment on %s/%s#%d: %w\", owner, repo, index, err)\n\t}\n\n\treturn nil\n}\n\n// IssueComment is one comment on an issue or pull request, in the\n// shape zoo needs when briefing an agent: who said what, and when.\ntype IssueComment struct {\n\tAuthor string\n\tBody string\n\tCreated time.Time\n}\n\n// ListIssueComments fetches every comment on the given issue or pull\n// request, oldest first. PRs are issues under the hood in Forgejo, so\n// the same endpoint serves both. Pages are walked until exhausted so\n// the result isn't capped by the server's default page size.\nfunc (c *Client) ListIssueComments(owner, repo string, index int64) ([]IssueComment, error) {\n\tconst pageSize = 50\n\n\tvar all []*sdk.Comment\n\n\tfor page := 1; ; page++ {\n\t\tbatch, _, err := c.sdk.ListIssueComments(owner, repo, index, sdk.ListIssueCommentOptions{\n\t\t\tListOptions: sdk.ListOptions{Page: page, PageSize: pageSize},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"list comments on %s/%s#%d (page %d): %w\", owner, repo, index, page, err)\n\t\t}\n\n\t\tall = append(all, batch...)\n\n\t\tif len(batch) \u003c pageSize {\n\t\t\tbreak\n\t\t}\n\t}\n\n\tout := make([]IssueComment, 0, len(all))\n\tfor _, cm := range all {\n\t\tauthor := \"\"\n\t\tif cm.Poster != nil {\n\t\t\tauthor = cm.Poster.UserName\n\t\t}\n\n\t\tout = append(out, IssueComment{Author: author, Body: cm.Body, Created: cm.Created})\n\t}\n\n\treturn out, nil\n}\n\n// AddLabel attaches the label with the given name to an issue/PR,\n// creating the label (with a default color) on the repo first if it\n// doesn't already exist.\nfunc (c *Client) AddLabel(owner, repo string, index int64, name string) error {\n\tid, err := c.labelID(owner, repo, name)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, _, err = c.sdk.AddIssueLabels(owner, repo, index, sdk.IssueLabelsOption{Labels: []int64{id}})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"add label %q to %s/%s#%d: %w\", name, owner, repo, index, err)\n\t}\n\n\treturn nil\n}\n\n// RemoveLabel detaches the label with the given name from an issue/PR, if\n// both the label and the attachment exist.\nfunc (c *Client) RemoveLabel(owner, repo string, index int64, name string) error {\n\tlabels, _, err := c.sdk.GetIssueLabels(owner, repo, index, sdk.ListLabelsOptions{})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"list labels on %s/%s#%d: %w\", owner, repo, index, err)\n\t}\n\n\tfor _, l := range labels {\n\t\tif l.Name == name {\n\t\t\t_, err := c.sdk.DeleteIssueLabel(owner, repo, index, l.ID)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"remove label %q from %s/%s#%d: %w\", name, owner, repo, index, err)\n\t\t\t}\n\t\t\treturn nil\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (c *Client) labelID(owner, repo, name string) (int64, error) {\n\tlabels, _, err := c.sdk.ListRepoLabels(owner, repo, sdk.ListLabelsOptions{})\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"list labels on %s/%s: %w\", owner, repo, err)\n\t}\n\n\tfor _, l := range labels {\n\t\tif l.Name == name {\n\t\t\treturn l.ID, nil\n\t\t}\n\t}\n\n\tcreated, _, err := c.sdk.CreateLabel(owner, repo, sdk.CreateLabelOption{\n\t\tName: name,\n\t\tColor: \"#ee0000\",\n\t})\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"create label %q on %s/%s: %w\", name, owner, repo, err)\n\t}\n\n\treturn created.ID, nil\n}\n\n// CreatePullRequest opens a PR from head into base.\nfunc (c *Client) CreatePullRequest(owner, repo, head, base, title, body string) error {","start_line":1,"end_line":200,"total_lines":445,"truncated":true}
{"end_line":150,"path":"internal/forgejo/watcher.go","start_line":1}
{"path":"internal/forgejo/watcher.go","content":"package forgejo\n\nimport (\n\t\"context\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/abrander/zoo/internal/store\"\n)\n\nconst PollInterval = 10 * time.Second\n\n// Watcher merges the webhook receiver and the polling fallback into a\n// single deduplicated Event stream.\ntype Watcher struct {\n\tclient *Client\n\tstore *store.Store\n\tsecret string\n\trepos *RepoFilter\n\tlogger *slog.Logger\n\n\t// masterUser is the Forgejo username that owns the shared master\n\t// token. Comments by that user are always directed at human\n\t// operators (log messages, debug notes), never at agents, so they\n\t// are dropped in dispatch. An empty value disables the filter.\n\tmasterUser string\n\n\tevents chan Event\n}\n\nfunc NewWatcher(client *Client, st *store.Store, webhookSecret string, repos *RepoFilter, masterUser string, logger *slog.Logger) *Watcher {\n\treturn \u0026Watcher{\n\t\tclient: client,\n\t\tstore: st,\n\t\tsecret: webhookSecret,\n\t\trepos: repos,\n\t\tlogger: logger,\n\t\tmasterUser: masterUser,\n\t\tevents: make(chan Event, 64),\n\t}\n}\n\n// Handler returns the http.Handler to mount for incoming webhook\n// deliveries.\nfunc (w *Watcher) Handler() http.Handler {\n\treturn WebhookHandler(w.secret, w.logger, w.dispatch)\n}\n\n// Events returns the deduplicated stream consumed by the scheduler.\nfunc (w *Watcher) Events() \u003c-chan Event {\n\treturn w.events\n}\n\n// Run drives the polling fallback until ctx is canceled. The webhook\n// handler runs independently as part of the daemon's HTTP server.\nfunc (w *Watcher) Run(ctx context.Context) {\n\tticker := time.NewTicker(PollInterval)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase \u003c-ctx.Done():\n\t\t\treturn\n\n\t\tcase \u003c-ticker.C:\n\t\t\tw.pollOnce(ctx)\n\t\t}\n\t}\n}\n\n// dispatch drops ev if its repository isn't watched, dedupes it against\n// the store (regardless of whether it came from the webhook or the\n// poller) and, if new, forwards it to Events(). The repo filter runs\n// before MarkSeen on purpose: events from unwatched repositories must\n// not occupy dedup ids, so that a repository added to the config later\n// still has its new events processed.\nfunc (w *Watcher) dispatch(ev Event) {\n\t// Comments by the master token's owner are always directed at human\n\t// operators (log messages, debug notes), never at agents. Drop them\n\t// before dedup, like unwatched-repo events, so they neither trigger\n\t// a run nor occupy a dedup id. Non-comment events by that user (new\n\t// issues, assignments, ...) are still work for agents and pass.\n\tif w.masterUser != \"\" \u0026\u0026 ev.Kind == EventIssueComment \u0026\u0026 ev.Author == w.masterUser {\n\t\tw.logger.Debug(\"ignoring comment by master token owner\", \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index, \"id\", ev.ID)\n\t\treturn\n\t}\n\n\tif !w.repos.Matches(ev.Owner, ev.Repo) {\n\t\tw.logger.Debug(\"ignoring event from unwatched repository\", \"owner\", ev.Owner, \"repo\", ev.Repo, \"kind\", ev.Kind, \"id\", ev.ID)\n\t\treturn\n\t}\n\n\tisNew, err := w.store.MarkSeen(context.Background(), ev.ID)\n\tif err != nil {\n\t\tw.logger.Error(\"dedup check failed, dropping event\", \"id\", ev.ID, \"error\", err)\n\t\treturn\n\t}\n\n\tif !isNew {\n\t\treturn\n\t}\n\n\tselect {\n\tcase w.events \u003c- ev:\n\n\tdefault:\n\t\tw.logger.Warn(\"event channel full, dropping event\", \"id\", ev.ID, \"kind\", ev.Kind)\n\t}\n}","start_line":1,"end_line":110,"total_lines":110,"truncated":false}
{"end_line":120,"path":"cmd/zoo/main.go","start_line":1}
{"path":"cmd/zoo/main.go","content":"// Command zoo runs the daemon: it watches a Forgejo instance for\n// issue/PR events, dispatches them to configured AI agents running in\n// Docker containers, and serves a small dashboard over the result.\npackage main\n\nimport (\n\t\"context\"\n\t\"flag\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/abrander/zoo/internal/agentrun\"\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/scheduler\"\n\t\"github.com/abrander/zoo/internal/store\"\n\t\"github.com/abrander/zoo/internal/web\"\n)\n\nfunc main() {\n\tif err := run(); err != nil {\n\t\tfmt.Fprintln(os.Stderr, \"zoo:\", err)\n\n\t\tos.Exit(1)\n\t}\n}\n\nfunc run() error {\n\tvar (\n\t\tconfigPath = flag.String(\"config\", \"zoo.hcl\", \"path to the zoo.hcl config file\")\n\t\tdbPath = flag.String(\"db\", \"zoo.db\", \"path to the sqlite state database\")\n\t\tlisten = flag.String(\"listen\", \":8080\", \"address to serve webhooks and the dashboard on\")\n\t\trunTimeout = flag.Duration(\"run-timeout\", agentrun.DefaultTimeout, \"wall-clock timeout for a single agent run\")\n\t\tkeepOnFailure = flag.Bool(\"keep-on-failure\", false, \"keep the container and clone around after a failed run, for debugging\")\n\t)\n\n\tflag.Parse()\n\n\tlogger := slog.New(slog.NewTextHandler(os.Stderr, nil))\n\n\tcfg, err := config.Load(*configPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"load config: %w\", err)\n\t}\n\n\tst, err := store.Open(*dbPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"open store: %w\", err)\n\t}\n\tdefer st.Close()\n\n\tif n, err := st.ReapOrphanedJobs(context.Background()); err != nil {\n\t\tlogger.Warn(\"failed to reap orphaned jobs\", \"error\", err)\n\t} else if n \u003e 0 {\n\t\tlogger.Warn(\"reaped jobs left running by a previous process instance\", \"count\", n)\n\t}\n\n\tfgClient, err := forgejo.NewClient(cfg.Forgejo)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"forgejo client: %w\", err)\n\t}\n\n\t// Resolve who owns the master token: their comments are always\n\t// directed at human operators, never at agents, and zoo must ignore\n\t// them everywhere. A short retry absorbs Forgejo coming up slightly\n\t// after zoo; if it still fails, the token is unusable and nothing\n\t// else in the daemon would work either, so fail fast.\n\tmasterUser, err := resolveMasterUser(fgClient, logger)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlogger.Info(\"master token owner\", \"user\", masterUser)\n\n\trepoFilter, err := forgejo.NewRepoFilter(cfg.Forgejo.Repos)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"forgejo repos: %w\", err)\n\t}\n\n\twatcher := forgejo.NewWatcher(fgClient, st, cfg.Forgejo.WebhookSecret, repoFilter, masterUser, logger.With(\"component\", \"forgejo\"))\n\n\thub := livelog.NewHub()\n\n\trunner, err := agentrun.NewRunner(cfg, fgClient, st, hub, masterUser, logger.With(\"component\", \"agentrun\"), *runTimeout, *keepOnFailure)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"agent runner: %w\", err)\n\t}\n\n\tsched := scheduler.New(cfg, st, fgClient, runner, hub, logger.With(\"component\", \"scheduler\"))\n\tdashboard := web.New(cfg, st, hub, fgClient)\n\n\tmux := http.NewServeMux()\n\n\tmux.Handle(\"/webhooks/forgejo\", watcher.Handler())\n\n\tmux.HandleFunc(\"/healthz\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(\"ok\"))\n\t})\n\n\tmux.Handle(\"/\", dashboard.Handler())\n\n\tserver := \u0026http.Server{Addr: *listen, Handler: mux}\n\n\tctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)\n\tdefer stop()\n\n\tgo watcher.Run(ctx)\n\tgo sched.Run(ctx, watcher.Events())\n\n\tserveErr := make(chan error, 1)\n\n\tgo func() {\n\t\tlogger.Info(\"listening\", \"addr\", *listen)","start_line":1,"end_line":120,"total_lines":172,"truncated":true}
{"end_line":172,"path":"cmd/zoo/main.go","start_line":120}
{"path":"cmd/zoo/main.go","content":"\t\tlogger.Info(\"listening\", \"addr\", *listen)\n\t\tif err := server.ListenAndServe(); err != nil \u0026\u0026 err != http.ErrServerClosed {\n\t\t\tserveErr \u003c- err\n\t\t}\n\t}()\n\n\tselect {\n\tcase \u003c-ctx.Done():\n\t\tlogger.Info(\"shutting down\")\n\n\tcase err := \u003c-serveErr:\n\t\tstop()\n\n\t\tlogger.Error(\"http server failed\", \"error\", err)\n\t}\n\n\tshutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tif err := server.Shutdown(shutdownCtx); err != nil {\n\t\tlogger.Warn(\"http server shutdown\", \"error\", err)\n\t}\n\n\tif err := sched.Shutdown(shutdownCtx); err != nil {\n\t\tlogger.Warn(\"scheduler did not drain in time\", \"error\", err)\n\t}\n\n\treturn nil\n}\n\n// resolveMasterUser asks Forgejo who owns the master token, retrying a\n// few times to ride out Forgejo coming up slightly after zoo.\nfunc resolveMasterUser(client *forgejo.Client, logger *slog.Logger) (string, error) {\n\tconst attempts = 5\n\n\tvar lastErr error\n\n\tfor attempt := 1; attempt \u003c= attempts; attempt++ {\n\t\towner, err := client.Owner()\n\t\tif err == nil {\n\t\t\treturn owner, nil\n\t\t}\n\n\t\tlastErr = err\n\n\t\tif attempt \u003c attempts {\n\t\t\tlogger.Warn(\"resolving master token owner\", \"attempt\", attempt, \"error\", err)\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t}\n\t}\n\n\treturn \"\", fmt.Errorf(\"resolve master token owner: %w\", lastErr)\n}","start_line":120,"end_line":172,"total_lines":172,"truncated":false}
{"context_after":30,"query":"filterMasterComments"}
/project/internal/agentrun/seed_test.go:69:// TestFilterMasterComments: comments by the master token's owner are
/project/internal/agentrun/seed_test.go-70-// always directed at human operators, never at agents, so they must not
/project/internal/agentrun/seed_test.go-71-// reach the agent's briefing.
/project/internal/agentrun/seed_test.go:72:func TestFilterMasterComments(t *testing.T) {
/project/internal/agentrun/seed_test.go-73- comments := []forgejo.IssueComment{
/project/internal/agentrun/seed_test.go-74- {Author: "abrander", Body: "debug: restarted the worker"},
/project/internal/agentrun/seed_test.go-75- {Author: "leon", Body: "Please also handle the edge case."},
/project/internal/agentrun/seed_test.go-76- {Author: "abrander", Body: "log: deploy finished"},
/project/internal/agentrun/seed_test.go-77- {Author: "mika", Body: "And add a test for it."},
/project/internal/agentrun/seed_test.go-78- }
/project/internal/agentrun/seed_test.go-79-
/project/internal/agentrun/seed_test.go:80: got := filterMasterComments(comments, "abrander")
/project/internal/agentrun/seed_test.go-81-
/project/internal/agentrun/seed_test.go-82- if len(got) != 2 {
/project/internal/agentrun/seed_test.go-83- t.Fatalf("expected 2 comments, got %d: %+v", len(got), got)
/project/internal/agentrun/seed_test.go-84- }
/project/internal/agentrun/seed_test.go-85- if got[0].Author != "leon" || got[1].Author != "mika" {
/project/internal/agentrun/seed_test.go-86- t.Fatalf("unexpected survivors: %+v", got)
/project/internal/agentrun/seed_test.go-87- }
/project/internal/agentrun/seed_test.go-88-
/project/internal/agentrun/seed_test.go-89- // An empty masterUser (filter disabled) keeps everything.
/project/internal/agentrun/seed_test.go:90: if got := filterMasterComments(comments, ""); len(got) != len(comments) {
/project/internal/agentrun/seed_test.go-91- t.Fatalf("empty masterUser must be a no-op, got %d comments", len(got))
/project/internal/agentrun/seed_test.go-92- }
/project/internal/agentrun/seed_test.go-93-}
/project/internal/agentrun/seed_test.go-94-
/project/internal/agentrun/seed_test.go-95-func TestSeedMessageIncludesReview(t *testing.T) {
/project/internal/agentrun/seed_test.go-96- ev := forgejo.Event{
/project/internal/agentrun/seed_test.go-97- Kind: forgejo.EventPRReview,
/project/internal/agentrun/seed_test.go-98- Owner: "acme",
/project/internal/agentrun/seed_test.go-99- Repo: "widgets",
/project/internal/agentrun/seed_test.go-100- Index: 9,
/project/internal/agentrun/seed_test.go-101- Title: "Fix the thing",
/project/internal/agentrun/seed_test.go-102- Body: "This fixes it",
/project/internal/agentrun/seed_test.go-103- ReviewID: 42,
/project/internal/agentrun/seed_test.go-104- ReviewState: "REQUEST_CHANGES",
/project/internal/agentrun/seed_test.go-105- ReviewBody: "Please address the inline comments.",
/project/internal/agentrun/seed_test.go-106- Raw: []byte(`{}`),
/project/internal/agentrun/seed_test.go-107- }
/project/internal/agentrun/seed_test.go-108-
/project/internal/agentrun/seed_test.go-109- review := &forgejo.ReviewDetail{
/project/internal/agentrun/seed_test.go-110- ID: 42,
/project/internal/agentrun/seed_test.go-111- State: "REQUEST_CHANGES",
/project/internal/agentrun/seed_test.go-112- Body: "Please address the inline comments.",
/project/internal/agentrun/seed_test.go-113- Reviewer: "leon",
/project/internal/agentrun/seed_test.go-114- Comments: []forgejo.ReviewCommentDetail{
/project/internal/agentrun/seed_test.go-115- {ID: 7, Path: "src/foo.go", Line: 42, Body: "This is wrong.", Author: "leon"},
/project/internal/agentrun/seed_test.go-116- {ID: 8, Path: "src/bar.go", Line: 0, Body: "And this.", Author: "leon"},
/project/internal/agentrun/seed_test.go-117- },
/project/internal/agentrun/seed_test.go-118- }
/project/internal/agentrun/seed_test.go-119-
/project/internal/agentrun/seed_test.go-120- msg := seedMessage(ev, "feature-branch", "main", "", nil, review)
--
/project/internal/agentrun/run.go:273: comments = filterMasterComments(comments, r.masterUser)
/project/internal/agentrun/run.go-274-
/project/internal/agentrun/run.go-275- messages := []llm.Message{
/project/internal/agentrun/run.go-276- {Role: "system", Content: systemPrompt},
/project/internal/agentrun/run.go-277- {Role: "user", Content: seedMessage(ev, branch, repoInfo.DefaultBranch, instructions, comments, review)},
/project/internal/agentrun/run.go-278- }
/project/internal/agentrun/run.go-279-
/project/internal/agentrun/run.go-280- hooks := r.streamHooks(jobID, logAppend)
/project/internal/agentrun/run.go-281-
/project/internal/agentrun/run.go-282- _, err = runLoop(ctx, llmClient, runCtx, messages, hooks)
/project/internal/agentrun/run.go-283- if err != nil {
/project/internal/agentrun/run.go-284- return fmt.Errorf("agent loop: %w", err)
/project/internal/agentrun/run.go-285- }
/project/internal/agentrun/run.go-286-
/project/internal/agentrun/run.go-287- succeeded = true
/project/internal/agentrun/run.go-288-
/project/internal/agentrun/run.go-289- return nil
/project/internal/agentrun/run.go-290-}
/project/internal/agentrun/run.go-291-
/project/internal/agentrun/run.go-292-// streamHooks builds the Hooks a single Run passes to runLoop: every
/project/internal/agentrun/run.go-293-// delta is published live to the hub for connected dashboard viewers,
/project/internal/agentrun/run.go-294-// and once a reasoning/content block or tool call is complete, it's
/project/internal/agentrun/run.go-295-// persisted to the store as one row and the hub's replay buffer for
/project/internal/agentrun/run.go-296-// jobID is checkpointed β so a viewer connecting from this point on
/project/internal/agentrun/run.go-297-// sees it via the persisted history instead of a live replay, and is
/project/internal/agentrun/run.go-298-// never shown it twice.
/project/internal/agentrun/run.go-299-func (r *Runner) streamHooks(jobID string, logAppend func(stream, line string)) Hooks {
/project/internal/agentrun/run.go-300- var reasoningBuf, contentBuf strings.Builder
/project/internal/agentrun/run.go-301-
/project/internal/agentrun/run.go-302- reasoningOpen, contentOpen := false, false
/project/internal/agentrun/run.go-303-
--
/project/internal/agentrun/run.go:395:// filterMasterComments drops comments by the master token's owner: they
/project/internal/agentrun/run.go-396-// are always directed at human operators (log messages, debug notes),
/project/internal/agentrun/run.go-397-// never at agents, so an agent reading them could only be confused by
/project/internal/agentrun/run.go-398-// them. An empty masterUser is a no-op.
/project/internal/agentrun/run.go:399:func filterMasterComments(comments []forgejo.IssueComment, masterUser string) []forgejo.IssueComment {
/project/internal/agentrun/run.go-400- if masterUser == "" {
/project/internal/agentrun/run.go-401- return comments
/project/internal/agentrun/run.go-402- }
/project/internal/agentrun/run.go-403-
/project/internal/agentrun/run.go-404- out := make([]forgejo.IssueComment, 0, len(comments))
/project/internal/agentrun/run.go-405- for _, c := range comments {
/project/internal/agentrun/run.go-406- if c.Author == masterUser {
/project/internal/agentrun/run.go-407- continue
/project/internal/agentrun/run.go-408- }
/project/internal/agentrun/run.go-409-
{"command":"cd /project && go build ./... && go vet ./... && go test ./... 2>&1 | tail -30","timeout":30}
ERROR CALLING TOOL: command timed out after 30 seconds
{"command":"cd /project && go build ./... 2>&1 | tail -n 5; echo \"build exit: $?\"","timeout":300}
ERROR CALLING TOOL: exec create: Error response from daemon: No such container: 95f88d1dae28734131d8c84fe65b32918bc01a296499f0b0fbff3020db6d57be
{"command":"cd /project && go build ./... 2>&1 | tail -n 5; echo \"build exit: $?\"","timeout":300}
ERROR CALLING TOOL: exec create: Error response from daemon: No such container: 95f88d1dae28734131d8c84fe65b32918bc01a296499f0b0fbff3020db6d57be
{"command":"sleep 10 && cd /project && pwd && go version","timeout":60}
ERROR CALLING TOOL: exec create: Error response from daemon: No such container: 95f88d1dae28734131d8c84fe65b32918bc01a296499f0b0fbff3020db6d57be
{"command":"sleep 30 && cd /project && pwd","timeout":120}
ERROR CALLING TOOL: exec create: Error response from daemon: No such container: 95f88d1dae28734131d8c84fe65b32918bc01a296499f0b0fbff3020db6d57be
{"end_line":60,"path":"internal/forgejo/watcher_test.go","start_line":1}
ERROR CALLING TOOL: read "internal/forgejo/watcher_test.go": exec create: Error response from daemon: No such container: 95f88d1dae28734131d8c84fe65b32918bc01a296499f0b0fbff3020db6d57be
{"command":"sleep 60 && cd /project && pwd","timeout":120}
ERROR CALLING TOOL: exec create: Error response from daemon: No such container: 95f88d1dae28734131d8c84fe65b32918bc01a296499f0b0fbff3020db6d57be
{"command":"sleep 90 && cd /project && pwd && go version","timeout":180}
ERROR CALLING TOOL: exec create: Error response from daemon: No such container: 95f88d1dae28734131d8c84fe65b32918bc01a296499f0b0fbff3020db6d57be
{"command":"sleep 120 && cd /project && pwd","timeout":180}
ERROR CALLING TOOL: exec create: Error response from daemon: No such container: 95f88d1dae28734131d8c84fe65b32918bc01a296499f0b0fbff3020db6d57be
{"command":"sleep 120 && cd /project && pwd","timeout":180}
ERROR CALLING TOOL: exec create: Error response from daemon: No such container: 95f88d1dae28734131d8c84fe65b32918bc01a296499f0b0fbff3020db6d57be
{"command":"sleep 120 && cd /project && pwd && go version","timeout":180}
ERROR CALLING TOOL: exec create: Error response from daemon: No such container: 95f88d1dae28734131d8c84fe65b32918bc01a296499f0b0fbff3020db6d57be
{"end_line":50,"path":"internal/forgejo/watcher_test.go","start_line":1}
ERROR CALLING TOOL: read "internal/forgejo/watcher_test.go": exec create: Error response from daemon: No such container: 95f88d1dae28734131d8c84fe65b32918bc01a296499f0b0fbff3020db6d57be
{"body":"Thanks for the review, @abrander! π\n\nSince the approval carries no actionable feedback, there's nothing to address and no new commits to push β the PR stays as-is at `81330d0`, ready to merge whenever you are.\n\nI re-read the diff while replying and confirmed it matches the description:\n\n- **`Client.Owner()`** β resolves the master token's owner via `GET /user`.\n- **`Watcher.dispatch`** β drops `issue:comment` events by the master user *before* the repo filter and dedup, so they neither trigger a run nor occupy a dedup id; non-comment events by that user still pass (that's how work gets assigned).\n- **`agentrun` briefing** β `filterMasterComments` strips the master user's comments from the seeded thread, no-op when `masterUser` is empty.\n- **`main`** β resolves the owner at startup with 5 attempts / 2s apart, fails fast on a bad token, and logs the resolved user.\n\nOne transparency note: the project container went down mid-run (Docker reported the container missing), so I couldn't re-execute `go build`/`go vet`/`go test` in this run. The suite was green at commit time per the PR description, and the code review above found no discrepancies. Happy to re-verify on the next run if the container comes back."}
Comment posted
{"command":"cd /project && go build ./... && go vet ./... && go test ./... 2>&1 | tail -20","timeout":300}
ERROR CALLING TOOL: exec create: Error response from daemon: No such container: 95f88d1dae28734131d8c84fe65b32918bc01a296499f0b0fbff3020db6d57be