Skip to content

Commit 4238df4

Browse files
Stop shipping full prompts to metadata-only job listings (#939)
## Summary - The agent-hook daemon queries `GET /api/jobs` on every Stop and Bash PostToolUse hook event to count open failed reviews, and that listing serialized the full stored prompt of every job. On repos with a long review history each hook event moved tens of megabytes of JSON (35MB observed for one repo), pinning the hook daemon above 100% CPU on decode and ~350MB RSS, with the main daemon paying the matching encode cost. Only job metadata is ever read from these listings. - Adds `omit_prompt=true` to `/api/jobs`, wired down to a storage-level projection (`WithoutPrompt()`) so the prompt TEXT column is never read from SQLite for metadata-only listings — not just stripped after hydration. Used by the agent-hook count query and fix/refine discovery. Measured live: the worst-repo hook query drops from 35.1MB to 978KB (97%). The param is additive, so newer clients degrade gracefully against older daemons; the TUI keeps the default behavior because it renders prompts for queued/running jobs. - Exposes the standard `net/http/pprof` endpoints on both the main daemon and the agent-hook daemon (loopback/unix-socket listeners only), so the next regression like this can be profiled with one command instead of guessing from `ps`. - Regenerates the OpenAPI clients, which also picked up previously uncommitted spec drift (shutdown and export-reviews endpoints). The new endpoint test uses the generated typed client rather than hand-rolled query strings. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <sup>generated by a clanker</sup> Co-authored-by: Marius van Niekerk <mariusvniekerk@users.noreply.github.com>
1 parent e1fde26 commit 4238df4

18 files changed

Lines changed: 1162 additions & 18 deletions

File tree

cmd/roborev/fix.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -776,8 +776,10 @@ func queryOpenJobs(
776776
repoRoot, branch string,
777777
) ([]storage.ReviewJob, error) {
778778
jobs, err := withFixDaemonRetryContext(ctx, getDaemonEndpoint().BaseURL(), func(addr string) ([]storage.ReviewJob, error) {
779+
// omit_prompt: discovery only needs job metadata; prompts would add
780+
// megabytes of JSON on repos with a long review history.
779781
queryURL := fmt.Sprintf(
780-
"%s/api/jobs?status=done&repo=%s&closed=false&limit=0",
782+
"%s/api/jobs?status=done&repo=%s&closed=false&limit=0&omit_prompt=true",
781783
addr, url.QueryEscape(repoRoot),
782784
)
783785
if branch != "" {

internal/agenthook/daemon.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"io"
99
"net/http"
10+
"net/http/pprof"
1011
"os"
1112
"os/signal"
1213
"path/filepath"
@@ -105,6 +106,7 @@ func parseDaemonEndpoint(raw string) (kitdaemon.Endpoint, error) {
105106
}
106107

107108
func registerRoutes(mux *http.ServeMux, state *StateStore, shutdown chan<- struct{}) {
109+
registerPprof(mux)
108110
mux.Handle(kitdaemon.DefaultPingPath, kitdaemon.NewPingHandler(kitdaemon.PingHandlerOptions{
109111
Service: ServiceName,
110112
Version: version.Version,
@@ -194,6 +196,17 @@ func registerRoutes(mux *http.ServeMux, state *StateStore, shutdown chan<- struc
194196
})
195197
}
196198

199+
// registerPprof exposes the standard pprof profiling endpoints. The daemon
200+
// listens only on a unix socket or loopback TCP, so the profiles stay local
201+
// to the machine's user.
202+
func registerPprof(mux *http.ServeMux) {
203+
mux.HandleFunc("/debug/pprof/", pprof.Index)
204+
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
205+
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
206+
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
207+
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
208+
}
209+
197210
func writeJSON(w http.ResponseWriter, v any) {
198211
w.Header().Set("Content-Type", "application/json")
199212
_ = json.NewEncoder(w).Encode(v)

internal/agenthook/daemon_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,21 @@ func channelSignaled(ch <-chan struct{}) bool {
3838
return false
3939
}
4040
}
41+
42+
func TestDaemonServesPprof(t *testing.T) {
43+
assert := assert.New(t)
44+
state := &StateStore{
45+
path: filepath.Join(t.TempDir(), "state.json"),
46+
sessions: map[string]SessionState{},
47+
}
48+
mux := http.NewServeMux()
49+
registerRoutes(mux, state, make(chan struct{}, 1))
50+
51+
index := httptest.NewRecorder()
52+
mux.ServeHTTP(index, httptest.NewRequest(http.MethodGet, "/debug/pprof/", nil))
53+
assert.Equal(http.StatusOK, index.Code)
54+
55+
heap := httptest.NewRecorder()
56+
mux.ServeHTTP(heap, httptest.NewRequest(http.MethodGet, "/debug/pprof/heap", nil))
57+
assert.Equal(http.StatusOK, heap.Code)
58+
}

internal/agenthook/state.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1161,6 +1161,9 @@ func countOpenFailedReviews(ctx context.Context, repoRoot, branch, head, configu
11611161
values.Set("status", "done")
11621162
values.Set("closed", "false")
11631163
values.Set("limit", "10000")
1164+
// Only job metadata is needed to count verdicts; full prompts would add
1165+
// tens of megabytes of JSON per hook event on busy repos.
1166+
values.Set("omit_prompt", "true")
11641167
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ep.BaseURL()+"/api/jobs?"+values.Encode(), nil)
11651168
if err != nil {
11661169
return 0, false

internal/agenthook/state_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"net/http"
88
"net/http/httptest"
9+
"net/url"
910
"os"
1011
"os/exec"
1112
"path/filepath"
@@ -1991,3 +1992,24 @@ func TestRecordPostToolUseAmendPreservesEarlierPendingCommits(t *testing.T) {
19911992
assert.True(atLater.Triggered, "both pending commits count once reviews appear")
19921993
assert.Equal("commit", atLater.TriggeredBy)
19931994
}
1995+
1996+
func TestCountOpenFailedReviewsRequestsOmittedPrompts(t *testing.T) {
1997+
assert := assert.New(t)
1998+
repo := testutil.NewGitRepo(t)
1999+
head := repo.CommitFile("base.txt", "base\n", "base")
2000+
2001+
var gotQuery atomic.Value
2002+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2003+
gotQuery.Store(r.URL.Query())
2004+
assert.NoError(json.NewEncoder(w).Encode(jobsResponse{}))
2005+
}))
2006+
t.Cleanup(server.Close)
2007+
2008+
_, ok := countOpenFailedReviews(context.Background(), repo.Path(), "main", head, server.URL)
2009+
2010+
require.True(t, ok)
2011+
query, _ := gotQuery.Load().(url.Values)
2012+
require.NotNil(t, query)
2013+
assert.Equal("true", query.Get("omit_prompt"),
2014+
"hook count queries must not pull full prompts over the wire")
2015+
}

internal/daemon/routes.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package daemon
33
import (
44
"encoding/json"
55
"net/http"
6+
"net/http/pprof"
67
"reflect"
78

89
"github.com/danielgtaylor/huma/v2"
@@ -17,6 +18,14 @@ import (
1718
// all typed endpoints. The returned huma.API can be used to serve
1819
// the generated OpenAPI spec.
1920
func (s *Server) registerHumaAPI(mux *http.ServeMux) huma.API {
21+
// pprof profiling endpoints; the daemon listens only on loopback (or a
22+
// systemd-provided local socket), so the profiles stay local to the machine.
23+
mux.HandleFunc("/debug/pprof/", pprof.Index)
24+
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
25+
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
26+
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
27+
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
28+
2029
cfg := huma.DefaultConfig("roborev", version.Version)
2130
cfg.DocsPath = ""
2231
cfg.SchemasPath = ""

internal/daemon/server.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,17 @@ func formatDuration(d time.Duration) string {
960960

961961
const limitNotProvided = -999999
962962

963+
// stripJobPrompts clears the large prompt and diff payloads from listed jobs
964+
// for omit_prompt=true callers. Metadata-only consumers such as the agent hook
965+
// daemon poll job lists on every hook event; shipping full prompts to them
966+
// costs tens of megabytes of encode/decode per request.
967+
func stripJobPrompts(jobs []storage.ReviewJob) {
968+
for i := range jobs {
969+
jobs[i].Prompt = ""
970+
jobs[i].DiffContent = nil
971+
}
972+
}
973+
963974
func (s *Server) humaListJobs(
964975
ctx context.Context, input *ListJobsInput,
965976
) (*ListJobsOutput, error) {
@@ -980,6 +991,9 @@ func (s *Server) humaListJobs(
980991
job.Patch = nil
981992
resp := &ListJobsOutput{}
982993
resp.Body.Jobs = []storage.ReviewJob{*job}
994+
if input.OmitPrompt == "true" {
995+
stripJobPrompts(resp.Body.Jobs)
996+
}
983997
return resp, nil
984998
}
985999

@@ -1040,6 +1054,9 @@ func (s *Server) humaListJobs(
10401054
}
10411055

10421056
var listOpts []storage.ListJobsOption
1057+
if input.OmitPrompt == "true" {
1058+
listOpts = append(listOpts, storage.WithoutPrompt())
1059+
}
10431060
if input.GitRef != "" {
10441061
listOpts = append(
10451062
listOpts, storage.WithGitRef(input.GitRef),
@@ -1120,6 +1137,10 @@ func (s *Server) humaListJobs(
11201137
jobs = jobs[:limit]
11211138
}
11221139

1140+
if input.OmitPrompt == "true" {
1141+
stripJobPrompts(jobs)
1142+
}
1143+
11231144
attachPanelSummaries(s.db, jobs)
11241145

11251146
// Stats use same repo/branch filters but ignore closed

internal/daemon/server_jobs_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package daemon
22

33
import (
4+
"context"
45
"fmt"
56
"net/http"
67
"net/http/httptest"
@@ -17,6 +18,7 @@ import (
1718
"github.com/stretchr/testify/require"
1819

1920
"go.kenn.io/roborev/internal/config"
21+
daemonclient "go.kenn.io/roborev/internal/daemon_client"
2022
gitpkg "go.kenn.io/roborev/internal/git"
2123
"go.kenn.io/roborev/internal/storage"
2224
"go.kenn.io/roborev/internal/testenv"
@@ -2679,3 +2681,62 @@ func TestHandleEnqueueMinSeverity(t *testing.T) {
26792681
assert.Contains(t, w.Body.String(), "invalid min_severity")
26802682
})
26812683
}
2684+
2685+
func TestListJobsOmitPrompt(t *testing.T) {
2686+
assert := assert.New(t)
2687+
server, db, _ := newTestServer(t)
2688+
2689+
repo, err := db.GetOrCreateRepo("/test/omit-prompt-repo")
2690+
require.NoError(t, err)
2691+
diff := "diff --git a/f b/f"
2692+
_, err = db.EnqueueJob(storage.EnqueueOpts{
2693+
RepoID: repo.ID,
2694+
GitRef: "dirty",
2695+
Agent: "test",
2696+
Prompt: "a very large stored prompt",
2697+
DiffContent: diff,
2698+
})
2699+
require.NoError(t, err)
2700+
2701+
ts := httptest.NewServer(server.httpServer.Handler)
2702+
t.Cleanup(ts.Close)
2703+
client, err := daemonclient.NewClientWithResponses(ts.URL)
2704+
require.NoError(t, err)
2705+
2706+
ctx := context.Background()
2707+
repoFilter := []string{repo.RootPath}
2708+
omit := daemonclient.ListJobsParamsOmitPromptTrue
2709+
2710+
listJobs := func(t *testing.T, params *daemonclient.ListJobsParams) []daemonclient.ReviewJob {
2711+
t.Helper()
2712+
resp, err := client.ListJobsWithResponse(ctx, params)
2713+
require.NoError(t, err)
2714+
require.Equal(t, http.StatusOK, resp.StatusCode(), "body: %s", resp.Body)
2715+
require.NotNil(t, resp.JSON200)
2716+
require.NotNil(t, resp.JSON200.Jobs)
2717+
return *resp.JSON200.Jobs
2718+
}
2719+
2720+
t.Run("default includes prompt", func(t *testing.T) {
2721+
jobs := listJobs(t, &daemonclient.ListJobsParams{Repo: &repoFilter})
2722+
require.Len(t, jobs, 1)
2723+
require.NotNil(t, jobs[0].Prompt)
2724+
assert.Equal("a very large stored prompt", *jobs[0].Prompt)
2725+
})
2726+
2727+
t.Run("omit_prompt=true strips prompt and diff content", func(t *testing.T) {
2728+
jobs := listJobs(t, &daemonclient.ListJobsParams{Repo: &repoFilter, OmitPrompt: &omit})
2729+
require.Len(t, jobs, 1)
2730+
assert.Nil(jobs[0].Prompt)
2731+
assert.Nil(jobs[0].DiffContent)
2732+
})
2733+
2734+
t.Run("omit_prompt=true strips prompt on single-job lookup", func(t *testing.T) {
2735+
all := listJobs(t, &daemonclient.ListJobsParams{Repo: &repoFilter})
2736+
require.Len(t, all, 1)
2737+
jobs := listJobs(t, &daemonclient.ListJobsParams{Id: &all[0].Id, OmitPrompt: &omit})
2738+
require.Len(t, jobs, 1)
2739+
assert.Nil(jobs[0].Prompt)
2740+
assert.Nil(jobs[0].DiffContent)
2741+
})
2742+
}

internal/daemon/server_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,3 +697,16 @@ func TestCostOptionsFromInput(t *testing.T) {
697697
_, err = costOptionsFromInput(&GetCostInput{Since: "bogus"})
698698
assert.Error(err)
699699
}
700+
701+
func TestServerServesPprof(t *testing.T) {
702+
assert := assert.New(t)
703+
server, _, _ := newTestServer(t)
704+
705+
index := httptest.NewRecorder()
706+
server.httpServer.Handler.ServeHTTP(index, httptest.NewRequest(http.MethodGet, "/debug/pprof/", nil))
707+
assert.Equal(http.StatusOK, index.Code)
708+
709+
heap := httptest.NewRecorder()
710+
server.httpServer.Handler.ServeHTTP(heap, httptest.NewRequest(http.MethodGet, "/debug/pprof/heap", nil))
711+
assert.Equal(http.StatusOK, heap.Code)
712+
}

internal/daemon/types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ type ListJobsInput struct {
8787
ExcludeJobType string `query:"exclude_job_type" doc:"Exclude jobs of this type"`
8888
HideClassifyJobs string `query:"hide_classify_jobs" doc:"Hide auto-design-router rows (job_type=classify and status=skipped)" enum:"true,false,"`
8989
PanelRun string `query:"panel_run" doc:"Return all jobs (members + synthesis) of one panel run"`
90+
OmitPrompt string `query:"omit_prompt" doc:"Omit prompt and diff content from returned jobs (metadata-only listing)" enum:"true,false,"`
9091
RepoPrefix string `query:"repo_prefix" doc:"Filter repos by path prefix"`
9192
Limit int `query:"limit" default:"-999999" doc:"Max results (default 50, 0=unlimited, max 10000)"`
9293
Offset int `query:"offset" default:"-1" doc:"Skip N results (requires limit>0)"`

0 commit comments

Comments
 (0)