From 9784a23d139aadd2d593d4c583ff1ecb87d79536 Mon Sep 17 00:00:00 2001 From: Shun Kakinoki Date: Sat, 12 Sep 2026 03:51:02 +0000 Subject: [PATCH] feat: allow session usage queries without source sync --- cmd/agentsview/archive_query_backend.go | 6 +- cmd/agentsview/archive_query_backend_test.go | 16 ++ cmd/agentsview/session_test.go | 149 +++++++++++++++++++ cmd/agentsview/session_usage.go | 10 +- docs/session-api.md | 13 +- 5 files changed, 188 insertions(+), 6 deletions(-) diff --git a/cmd/agentsview/archive_query_backend.go b/cmd/agentsview/archive_query_backend.go index ec529e0fc7..99892a13da 100644 --- a/cmd/agentsview/archive_query_backend.go +++ b/cmd/agentsview/archive_query_backend.go @@ -40,10 +40,12 @@ type archiveQueryBackend interface { // sessionUsageQuery selects the session and the attribution scope for // `session usage`. OwnOnly restores the pre-rollup behavior of reporting -// just the named transcript's own rows. +// just the named transcript's own rows. NoSync skips source refreshes while +// preserving the selected attribution scope. type sessionUsageQuery struct { SessionID string OwnOnly bool + NoSync bool } type dailyUsageQuery struct { @@ -261,7 +263,7 @@ func (b localArchiveQueryBackend) SessionUsage( ctx, b.database, b.cfg.AgentDirs, query.SessionID, ) - if known && !b.skipFreshData { + if known && !b.skipFreshData && !query.NoSync { engine := sync.NewEngine(b.database, sync.EngineConfig{ AgentDirs: b.cfg.AgentDirs, SourceMachines: b.cfg.SourceMachines, diff --git a/cmd/agentsview/archive_query_backend_test.go b/cmd/agentsview/archive_query_backend_test.go index dee558fd11..2c7ea1d4a7 100644 --- a/cmd/agentsview/archive_query_backend_test.go +++ b/cmd/agentsview/archive_query_backend_test.go @@ -216,6 +216,16 @@ func TestLocalSessionUsageRefreshesSubagentTranscripts(t *testing.T) { String()), 0o644)) + // Passive reads must leave newly written source transcripts unindexed. + archived, _, err := backend.SessionUsage( + ctx, sessionUsageQuery{SessionID: "parent-uuid", NoSync: true}) + require.NoError(t, err) + require.NotNil(t, archived) + assert.Zero(t, archived.SubagentCount) + unsynced, err := database.GetSession(ctx, "agent-worker1") + require.NoError(t, err) + assert.Nil(t, unsynced) + out, _, err := backend.SessionUsage( ctx, sessionUsageQuery{SessionID: "parent-uuid"}) require.NoError(t, err) @@ -229,6 +239,12 @@ func TestLocalSessionUsageRefreshesSubagentTranscripts(t *testing.T) { require.NotNil(t, child.ParentSessionID) assert.Equal(t, "parent-uuid", *child.ParentSessionID) + archived, _, err = backend.SessionUsage( + ctx, sessionUsageQuery{SessionID: "parent-uuid", NoSync: true}) + require.NoError(t, err) + require.NotNil(t, archived) + assert.Equal(t, out.SessionUsage, archived.SessionUsage) + // --own-only skips the subagent refresh and the combined view. own, _, err := backend.SessionUsage( ctx, sessionUsageQuery{SessionID: "parent-uuid", OwnOnly: true}) diff --git a/cmd/agentsview/session_test.go b/cmd/agentsview/session_test.go index 67343b9259..32a5c3cc06 100644 --- a/cmd/agentsview/session_test.go +++ b/cmd/agentsview/session_test.go @@ -1330,6 +1330,155 @@ func TestSessionUsage_ServerFlagUsesHTTP(t *testing.T) { assert.True(t, out.ServerRunning) } +func TestSessionUsage_NoSyncPreservesAuthenticatedResolution(t *testing.T) { + dataDir := newAgentDataDir(t) + tokenFile := filepath.Join(dataDir, "remote-token") + require.NoError(t, os.WriteFile(tokenFile, []byte("test-token\n"), 0o600)) + const rawID = "session-uuid" + const canonicalID = "codex:" + rawID + ts, reqs := newRemoteUsageServer(t, remoteUsageSpec{ + canonicalID: canonicalID, + bearer: "test-token", + }) + cmd := sessionUsageCommand(t, "session", "usage", rawID, + "--server", ts.URL, "--server-token-file", tokenFile, "--no-sync") + + out, code, err := sessionUsageDataForCommand(cmd, rawID) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, tokenUseExitOK, code) + assert.Equal(t, canonicalID, out.SessionID) + assert.Equal(t, 42, out.TotalOutputTokens) + assert.Equal(t, "breakdown=true&subagents=true", reqs.UsageQuery) + assert.Empty(t, reqs.SyncInput.ID) + assert.NotContains(t, reqs.RequestPath, "/api/v1/sessions/sync") + assert.Contains(t, reqs.RequestPath, "/api/v1/sessions/"+rawID) + assert.Contains(t, reqs.RequestPath, "/api/v1/sessions/"+canonicalID) +} + +func TestSessionUsage_NoSyncAutostartDisablesSourceSync(t *testing.T) { + testDataDir(t) + ts, reqs := newRemoteUsageServer(t, remoteUsageSpec{canonicalID: "remote-session"}) + u, err := url.Parse(ts.URL) + require.NoError(t, err) + host, portText, err := net.SplitHostPort(u.Host) + require.NoError(t, err) + port, err := strconv.Atoi(portText) + require.NoError(t, err) + var started bool + stubStartBackgroundServeForTransport(t, func( + _ context.Context, cfg *config.Config, _ time.Duration, + ) (*DaemonRuntime, error) { + started = true + assert.True(t, cfg.NoSync) + return &DaemonRuntime{Host: host, Port: port}, nil + }) + cmd := sessionUsageCommand(t, "session", "usage", "remote-session", "--no-sync") + out, code, err := sessionUsageDataForCommand(cmd, "remote-session") + require.NoError(t, err) + require.NotNil(t, out) + assert.True(t, started) + assert.Equal(t, tokenUseExitOK, code) + assert.Empty(t, reqs.SyncInput.ID) + assert.Equal(t, "breakdown=true&subagents=true", reqs.UsageQuery) +} + +func TestSessionUsage_NoSyncDiscoveredDaemon(t *testing.T) { + for _, tc := range []struct { + name string + status int + body string + code int + wantError bool + }{ + {"subagent usage", http.StatusOK, + `{"session_id":"codex:parent","total_output_tokens":24,"has_token_data":true,"subagent_count":2}`, + tokenUseExitOK, false}, + {"no data", http.StatusOK, + `{"session_id":"codex:parent","has_token_data":false,"has_cost":false}`, + tokenUseExitNoTokenData, false}, + {"missing", http.StatusNotFound, "", tokenUseExitNotFound, false}, + {"unauthorized", http.StatusUnauthorized, "", tokenUseExitErr, true}, + } { + t.Run(tc.name, func(t *testing.T) { + dataDir := newAgentDataDir(t) + writeTestConfig(t, dataDir, `auth_token = "test-token"`) + var paths []string + ts := sessionUsageRuntimeServer(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + paths = append(paths, r.URL.Path) + switch r.URL.Path { + case "/api/v1/sessions/codex:parent": + writeJSONResponse(w, `{"id":"codex:parent","agent":"codex"}`) + case "/api/v1/sessions/codex:parent/usage": + assert.Equal(t, "breakdown=true&subagents=true", r.URL.RawQuery) + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + default: + http.NotFound(w, r) + } + }) + registerSyncRouteTestRuntime(t, dataDir, ts.URL) + cmd := sessionUsageCommand(t, "session", "usage", "codex:parent", "--no-sync") + out, code, err := sessionUsageDataForCommand(cmd, "codex:parent") + if tc.wantError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tc.code, code) + assert.Equal(t, []string{ + "/api/v1/sessions/codex:parent", + "/api/v1/sessions/codex:parent/usage", + }, paths) + if tc.code == tokenUseExitOK { + require.NotNil(t, out) + assert.Equal(t, 24, out.TotalOutputTokens) + assert.Equal(t, 2, out.SubagentCount) + } + }) + } +} + +func TestSessionUsage_NoSyncLocalAttributionAndMissingData(t *testing.T) { + for _, tc := range []struct { + name string + id string + ownOnly bool + code int + output int + children int + }{ + {"subagent usage", "claude:parent-only", false, tokenUseExitOK, 24, 1}, + {"own only", "claude:parent-only", true, tokenUseExitNoTokenData, 0, 0}, + {"missing", "claude:missing", false, tokenUseExitNotFound, 0, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + dataDir := newAgentDataDir(t) + localDB := dbtest.OpenTestDBAt(t, sessionsDBPath(dataDir)) + seedSubagentOnlyUsage(t, localDB, "claude:parent-only", "agent-child", 24) + backend := localArchiveQueryBackend{ + cfg: config.Config{DBPath: sessionsDBPath(dataDir)}, + database: localDB, + offline: true, + } + out, code, err := backend.SessionUsage(context.Background(), sessionUsageQuery{ + SessionID: tc.id, OwnOnly: tc.ownOnly, NoSync: true, + }) + require.NoError(t, err) + assert.Equal(t, tc.code, code) + if tc.code == tokenUseExitNotFound { + assert.Nil(t, out) + return + } + require.NotNil(t, out) + assert.Equal(t, tc.output, out.TotalOutputTokens) + assert.Equal(t, tc.children, out.SubagentCount) + }) + } +} + func TestSessionUsage_ServerFlagRejectsCombinedUsageFromOlderDaemon( t *testing.T, ) { diff --git a/cmd/agentsview/session_usage.go b/cmd/agentsview/session_usage.go index 7e6db597e5..4c9148e45e 100644 --- a/cmd/agentsview/session_usage.go +++ b/cmd/agentsview/session_usage.go @@ -48,6 +48,8 @@ func newSessionUsageCommand() *cobra.Command { runSessionUsage(cmd, args[0], outputFormat(cmd)) }, } + cmd.Flags().Bool("no-sync", false, + "Use archived usage without synchronizing source transcripts") cmd.Flags().Bool("own-only", false, "Report only this session's own usage, excluding subagents") return cmd @@ -89,7 +91,10 @@ func sessionUsageDataForCommand( } ownOnly, _ := cmd.Flags().GetBool("own-only") - query := sessionUsageQuery{SessionID: sessionID, OwnOnly: ownOnly} + noSync, _ := cmd.Flags().GetBool("no-sync") + query := sessionUsageQuery{ + SessionID: sessionID, OwnOnly: ownOnly, NoSync: noSync, + } remote, _ := cmd.Flags().GetString("server") if remote != "" { @@ -126,6 +131,7 @@ func sessionUsageDataForCommand( ctx, cfg, archiveQueryPolicy{ + NoSync: noSync, AutoStart: true, ReadOnlyDaemon: archiveQueryUseReadOnlyDaemon, DirectReadOnlyAction: "refresh session usage directly", @@ -186,7 +192,7 @@ func httpSessionUsageData( } return nil, tokenUseExitErr, err } - if !query.OwnOnly { + if !query.OwnOnly && !query.NoSync { backend := service.NewHTTPBackend(baseURL, token, false) if _, syncErr := backend.Sync(ctx, service.SyncInput{ ID: resolvedID, Subagents: true, diff --git a/docs/session-api.md b/docs/session-api.md index bc8836564b..604f3fd12f 100644 --- a/docs/session-api.md +++ b/docs/session-api.md @@ -675,7 +675,7 @@ request. returns from the rollup branch first, so `subagents` has no effect. ```bash -agentsview session usage [--format json] [--own-only] +agentsview session usage [--format json] [--own-only] [--no-sync] ``` ```json @@ -843,9 +843,18 @@ Before querying, the local backend refreshes the session's own transcript and the `agent-*.jsonl` files under its `subagents/` directory, so a session that just finished reports complete numbers. `--own-only` skips the subagent refresh. +Pass `--no-sync` to read archived usage without refreshing source transcripts. +This preserves the full subagent rollup and the exit codes above; combine it +with `--own-only` only when you want to exclude subagents. Local and remote +HTTP queries skip the sync request, so recent usage appears after the watcher +or a separate sync has indexed it. PostgreSQL reads already use archived data. +The local `--no-sync` path requires a compatible daemon and starts one with +source synchronization disabled if needed. With `AGENTSVIEW_NO_DAEMON=1`, +it requires an existing compatible daemon. + The command uses a writable local daemon when one is running, or starts a detached daemon when fresh local data is needed and no compatible daemon is -running. With `AGENTSVIEW_NO_DAEMON=1`, it falls back to direct local SQLite +running. Without `--no-sync`, `AGENTSVIEW_NO_DAEMON=1` selects direct local SQLite after acquiring the write-owner lock for any required refresh. Configured PostgreSQL does not change this command's default local behavior; pass `--pg` to read usage from the shared PostgreSQL store. With `--server`, it calls