From 790b787c095e2dda39fe00228aa9fd6950575a73 Mon Sep 17 00:00:00 2001 From: dipree Date: Wed, 24 Jun 2026 11:08:41 +0200 Subject: [PATCH 1/4] Gate trail injection on enablement cache Entire-Checkpoint: 063b3e0eaa52 --- cmd/entire/cli/api/trails.go | 20 +-- cmd/entire/cli/api/trails_test.go | 4 +- cmd/entire/cli/auth/contexts.go | 33 ++++ cmd/entire/cli/auth/contexts_test.go | 43 +++++ cmd/entire/cli/lifecycle.go | 27 ++-- cmd/entire/cli/lifecycle_test.go | 70 +++++++++ cmd/entire/cli/settings/settings.go | 6 + cmd/entire/cli/setup.go | 4 +- cmd/entire/cli/trail_cmd.go | 13 +- cmd/entire/cli/trail_cmd_test.go | 46 ++++++ cmd/entire/cli/trail_context_cache.go | 217 ++++++++++++++++++++++++-- cmd/entire/cli/trail_review_cmd.go | 2 +- cmd/entire/cli/trail_watch_cmd.go | 3 +- docs/architecture/agent-guide.md | 2 +- 14 files changed, 448 insertions(+), 42 deletions(-) diff --git a/cmd/entire/cli/api/trails.go b/cmd/entire/cli/api/trails.go index db865be2d6..97934c9f96 100644 --- a/cmd/entire/cli/api/trails.go +++ b/cmd/entire/cli/api/trails.go @@ -8,14 +8,8 @@ import ( "net/url" ) -// TrailsEnabled reports whether the trails feature is enabled for the repo on -// the API. It probes the trails list endpoint (limit=1): a 2xx response means -// trails are provisioned/enabled for the repo, while 404/403 (and any other -// non-2xx) mean they are not enabled or not accessible to this caller. -// -// Transport errors are returned to the caller (with enabled=false) so a -// "couldn't reach the API" outcome is distinguishable from a definitive -// "not enabled". +// TrailsEnabled probes trail availability: 2xx=true, 403/404/410=false, +// everything else ambiguous. func (c *Client) TrailsEnabled(ctx context.Context, forge, owner, repo string) (bool, error) { resp, err := c.Get(ctx, fmt.Sprintf("/api/v1/trails/%s/%s/%s?limit=1", url.PathEscape(forge), url.PathEscape(owner), url.PathEscape(repo))) @@ -25,5 +19,13 @@ func (c *Client) TrailsEnabled(ctx context.Context, forge, owner, repo string) ( defer resp.Body.Close() // Drain (bounded) so net/http can reuse the connection; the body is unused. _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16)) //nolint:errcheck // best-effort drain - return resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices, nil + if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { + return true, nil + } + switch resp.StatusCode { + case http.StatusForbidden, http.StatusNotFound, http.StatusGone: + return false, nil + default: + return false, fmt.Errorf("probe trails enablement: unexpected status %s", resp.Status) + } } diff --git a/cmd/entire/cli/api/trails_test.go b/cmd/entire/cli/api/trails_test.go index a192977a1f..ac2ee154ef 100644 --- a/cmd/entire/cli/api/trails_test.go +++ b/cmd/entire/cli/api/trails_test.go @@ -48,7 +48,9 @@ func TestClient_TrailsEnabled(t *testing.T) { {"enabled empty (200)", http.StatusOK, `{"trails":[]}`, true, true}, {"not enabled (404)", http.StatusNotFound, `{"error":"not found"}`, false, true}, {"forbidden (403)", http.StatusForbidden, `{"error":"forbidden"}`, false, true}, - {"server error (500)", http.StatusInternalServerError, `{"error":"boom"}`, false, true}, + {"gone (410)", http.StatusGone, `{"error":"gone"}`, false, true}, + {"unauthorized (401)", http.StatusUnauthorized, `{"error":"unauthorized"}`, false, false}, + {"server error (500)", http.StatusInternalServerError, `{"error":"boom"}`, false, false}, } for _, tt := range tests { diff --git a/cmd/entire/cli/auth/contexts.go b/cmd/entire/cli/auth/contexts.go index 3baf39a727..b2302908d8 100644 --- a/cmd/entire/cli/auth/contexts.go +++ b/cmd/entire/cli/auth/contexts.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/url" + "os" "strings" "time" @@ -147,6 +148,38 @@ func sameIssuer(a, b string) bool { return strings.TrimRight(a, "/") == strings.TrimRight(b, "/") } +// LocalIdentityCacheKey returns a non-secret local auth identity key. +func LocalIdentityCacheKey() (string, error) { + if raw := strings.TrimSpace(os.Getenv(EnvTokenVar)); raw != "" { + claims, err := tokens.ParseClaims(raw) + if err != nil { + return "", fmt.Errorf("parse %s claims: %w", EnvTokenVar, err) + } + return strings.Join([]string{ + "env", + strings.TrimRight(claims.Issuer, "/"), + claims.Subject, + claims.Handle, + strings.Join(claims.Audience, ","), + }, "|"), nil + } + + c, ok, err := activeContext() + if err != nil { + return "", err + } + if !ok { + return "", nil + } + return strings.Join([]string{ + "context", + strings.TrimRight(c.CoreURL, "/"), + c.Name, + c.Handle, + c.KeychainService, + }, "|"), nil +} + // LoginTokenForContext returns the login JWT stored for c, read from the // OS keyring slot the context points at. The encoded expiry is stripped; // the server is the authority on validity and the device-flow login holds diff --git a/cmd/entire/cli/auth/contexts_test.go b/cmd/entire/cli/auth/contexts_test.go index dc5f560fde..378f8c43b8 100644 --- a/cmd/entire/cli/auth/contexts_test.go +++ b/cmd/entire/cli/auth/contexts_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "path/filepath" + "strings" "testing" "time" @@ -27,6 +28,48 @@ func makeJWT(t *testing.T, payloadJSON string) string { return header + "." + payload + "." + enc.EncodeToString([]byte("sig")) } +func TestLocalIdentityCacheKey_ActiveContext(t *testing.T) { + cfgDir := t.TempDir() + t.Setenv("ENTIRE_CONFIG_DIR", cfgDir) + t.Setenv(EnvTokenVar, "") + + ctx := &contexts.Context{ + Name: "alice@core", + CoreURL: "https://core.example.com/", + Handle: "alice", + KeychainService: "entire-core:https://core.example.com", + } + if err := contexts.Save(cfgDir, &contexts.File{CurrentContext: ctx.Name, Contexts: []*contexts.Context{ctx}}); err != nil { + t.Fatalf("save contexts: %v", err) + } + + got, err := LocalIdentityCacheKey() + if err != nil { + t.Fatalf("LocalIdentityCacheKey: %v", err) + } + want := "context|https://core.example.com|alice@core|alice|entire-core:https://core.example.com" + if got != want { + t.Fatalf("cache key = %q, want %q", got, want) + } +} + +func TestLocalIdentityCacheKey_EnvToken(t *testing.T) { + token := makeJWT(t, `{"iss":"https://core.example.com/","sub":"svc-1","handle":"robot","aud":"https://api.example.com"}`) + t.Setenv(EnvTokenVar, token) + + got, err := LocalIdentityCacheKey() + if err != nil { + t.Fatalf("LocalIdentityCacheKey: %v", err) + } + want := "env|https://core.example.com|svc-1|robot|https://api.example.com" + if got != want { + t.Fatalf("cache key = %q, want %q", got, want) + } + if strings.Contains(got, token) { + t.Fatalf("cache key appears to contain raw JWT material: %q", got) + } +} + // RecordLoginContext must persist the refresh token before the access token, // so a failed access write never commits a fresh access JWT against a stale // refresh token left over from an earlier login. diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index 3dd6b64438..ad08d0fc08 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -146,6 +146,14 @@ func handleLifecycleSessionStart(ctx context.Context, ag agent.Agent, event *age slog.String("error", hintErr.Error())) } + // Refresh before the TurnStart prompt path. + refreshCtx, refreshCancel := context.WithTimeout(ctx, trailEnablementRefreshTimeout) + if refreshErr := refreshTrailsEnabledCacheIfStale(refreshCtx); refreshErr != nil { + logging.Debug(logCtx, "trails enablement refresh skipped", + slog.String("error", refreshErr.Error())) + } + refreshCancel() + // Build informational message — warn early if repo has no commits yet, // since checkpoints require at least one commit to work. message := sessionStartMessage(ag.Name(), false) @@ -384,12 +392,8 @@ func emitContextInjection(ctx context.Context, ag agent.Agent, event *agent.Even } logCtx := logging.WithAgent(logging.WithComponent(ctx, "lifecycle"), ag.Name()) - // Decide once per session, recorded on the session state itself (not a - // separate marker file). Winning the check-and-set means this turn owns the - // decision. trailsEnabledForRepo only reads clone-local cached enablement; - // the API refresh happens earlier on `entire enable`, outside the prompt path. - // Marking "decided" before checking the cache means a missing/stale false - // cache fails closed (no hint for this session) rather than retrying/spamming. + // Unknown cache leaves the session retryable. + decision := trailEnablementCacheUnknown mutated := false mutErr := strategy.MutateSessionState(ctx, event.SessionID, func(state *strategy.SessionState) error { if state.ContextInjectionDecided { @@ -401,6 +405,10 @@ func emitContextInjection(ctx context.Context, ag agent.Agent, event *agent.Even if state.Kind != "" { return strategy.ErrMutationSkip } + decision = cachedTrailsEnablementForRepo(ctx, time.Now()) + if decision == trailEnablementCacheUnknown { + return strategy.ErrMutationSkip + } state.ContextInjectionDecided = true mutated = true return nil @@ -414,12 +422,7 @@ func emitContextInjection(ctx context.Context, ag agent.Agent, event *agent.Even // state failed, mutErr was non-nil above and we returned without injecting, // leaving a later turn free to retry safely. won := mutErr == nil && mutated - if !won { - return // already decided for this session, skipped kind, or no session state yet - } - - // Only advertise trails when they're literally enabled for this repo on the API. - if !trailsEnabledForRepo(ctx) { + if !won || decision != trailEnablementCacheEnabled { return } diff --git a/cmd/entire/cli/lifecycle_test.go b/cmd/entire/cli/lifecycle_test.go index 7b081a2556..fc64c9f90a 100644 --- a/cmd/entire/cli/lifecycle_test.go +++ b/cmd/entire/cli/lifecycle_test.go @@ -3,6 +3,7 @@ package cli import ( "context" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -1140,6 +1141,75 @@ func TestHandleLifecycleTurnStart_WritesPromptContent(t *testing.T) { } } +type mockContextInjectorAgent struct { + mockLifecycleAgent +} + +var _ agent.ContextInjector = (*mockContextInjectorAgent)(nil) + +func (m *mockContextInjectorAgent) InjectionEvent() agent.EventType { return agent.TurnStart } + +func (m *mockContextInjectorAgent) RenderContextInjection(agent.ContextInjection) ([]byte, error) { + return nil, nil +} + +func addGitHubOriginForLifecycleTest(t *testing.T, repoDir string) { + t.Helper() + cmd := exec.CommandContext(context.Background(), "git", "remote", "add", "origin", "git@github.com:acme/repo.git") + cmd.Dir = repoDir + cmd.Env = testutil.GitIsolatedEnv() + require.NoError(t, cmd.Run()) +} + +func TestHandleLifecycleTurnStart_ContextInjectionUnknownCacheDoesNotMarkDecided(t *testing.T) { + // Cannot use t.Parallel() because we use t.Chdir(). + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + addGitHubOriginForLifecycleTest(t, tmpDir) + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + session.ClearGitCommonDirCache() + + ag := &mockContextInjectorAgent{mockLifecycleAgent: *newMockAgent()} + sessionID := "test-trail-inject-unknown" + event := &agent.Event{Type: agent.TurnStart, SessionID: sessionID, Prompt: "hello", Timestamp: time.Now()} + + require.NoError(t, handleLifecycleTurnStart(context.Background(), ag, event)) + + state, err := strategy.LoadSessionState(context.Background(), sessionID) + require.NoError(t, err) + require.NotNil(t, state) + require.False(t, state.ContextInjectionDecided, "unknown/missing cache should not permanently suppress later injection") +} + +func TestHandleLifecycleTurnStart_ContextInjectionFreshTrueMarksDecided(t *testing.T) { + // Cannot use t.Parallel() because we use t.Chdir(). + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + addGitHubOriginForLifecycleTest(t, tmpDir) + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + session.ClearGitCommonDirCache() + require.NoError(t, saveTrailsEnabledForRepo(context.Background(), true)) + + ag := &mockContextInjectorAgent{mockLifecycleAgent: *newMockAgent()} + sessionID := "test-trail-inject-true" + event := &agent.Event{Type: agent.TurnStart, SessionID: sessionID, Prompt: "hello", Timestamp: time.Now()} + + require.NoError(t, handleLifecycleTurnStart(context.Background(), ag, event)) + + state, err := strategy.LoadSessionState(context.Background(), sessionID) + require.NoError(t, err) + require.NotNil(t, state) + require.True(t, state.ContextInjectionDecided, "fresh true cache should make a final injection decision") +} + func TestHandleLifecycleTurnStart_RecordsGenericSkillSlashEvent(t *testing.T) { // Cannot use t.Parallel() because we use t.Chdir() tmpDir := t.TempDir() diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index 3d8eb754ac..4043918fc5 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -152,6 +152,12 @@ type ClonePreferences struct { // definitive false. This is clone-local and not committed so hook-time agent // context injection can avoid network/auth work on the prompt path. TrailsEnabled *bool `json:"trails_enabled,omitempty"` + + // Freshness and scope for TrailsEnabled. + TrailsEnabledCheckedAt *time.Time `json:"trails_enabled_checked_at,omitempty"` + TrailsEnabledRepoKey string `json:"trails_enabled_repo_key,omitempty"` + TrailsEnabledAPIBase string `json:"trails_enabled_api_base,omitempty"` + TrailsEnabledAuthKey string `json:"trails_enabled_auth_key,omitempty"` } // SummaryGenerationSettings configures provider selection for on-demand diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 623e4865fb..a56d27f669 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -962,7 +962,7 @@ func reportRepoEnabled(ctx context.Context, insecureHTTPAuth bool) { // Persist the negative locally even if auth is unavailable so a stale true // cache from a previous origin does not inject on the prompt path. No API // report is useful for non-forge remotes. - if err := saveTrailsEnabledForRepo(ctx, false); err != nil { + if err := saveTrailsEnabledForRemote(ctx, info.Forge, info.Owner, info.Repo, false); err != nil { logging.Debug(ctx, "failed to cache trails enablement", "error", err) } return @@ -993,7 +993,7 @@ func reportRepoEnabled(ctx context.Context, insecureHTTPAuth bool) { logging.Debug(ctx, "trails enablement probe failed", "error", err) return } - if err := saveTrailsEnabledForRepo(ctx, enabled); err != nil { + if err := saveTrailsEnabledForRemote(ctx, info.Forge, info.Owner, info.Repo, enabled); err != nil { logging.Debug(ctx, "failed to cache trails enablement", "error", err) } } diff --git a/cmd/entire/cli/trail_cmd.go b/cmd/entire/cli/trail_cmd.go index a0a4e535d4..80ac4da1c0 100644 --- a/cmd/entire/cli/trail_cmd.go +++ b/cmd/entire/cli/trail_cmd.go @@ -116,7 +116,7 @@ If is omitted, shows the trail for the current branch. Otherwise, // runTrailShow shows one trail, defaulting to the current branch's trail. func runTrailShow(ctx context.Context, w, errW io.Writer, insecureHTTP bool, selector string) error { - return runAuthenticatedDataAPI(ctx, errW, insecureHTTP, func(ctx context.Context, client *api.Client) error { + return runAuthenticatedTrailAPI(ctx, errW, insecureHTTP, func(ctx context.Context, client *api.Client) error { forge, owner, repo, err := resolveTrailRemote(ctx) if err != nil { return err @@ -326,7 +326,7 @@ func runTrailListAll(ctx context.Context, w, errW io.Writer, opts trailListOptio if err != nil { return err } - return runAuthenticatedDataAPI(ctx, errW, opts.InsecureHTTP, func(ctx context.Context, client *api.Client) error { + return runAuthenticatedTrailAPI(ctx, errW, opts.InsecureHTTP, func(ctx context.Context, client *api.Client) error { return runTrailListAllWithClient(ctx, w, client, opts, statusFilters) }) } @@ -817,14 +817,17 @@ func runTrailCreate(cmd *cobra.Command, title, body, base, branch, statusStr str var createResp api.TrailCreateResponse resp, err := client.Post(ctx, trailsBasePath(forge, owner, repoName), createReq) if err != nil { + noteTrailCommandEnablement(ctx, client, err) cleanupCreatedTrailBranch(repo, branch, localBranchCreated, remoteBranchPushed, errW) return fmt.Errorf("failed to create trail: %w", err) } defer resp.Body.Close() if err := checkTrailResponse(resp); err != nil { + noteTrailCommandEnablement(ctx, client, err) cleanupCreatedTrailBranch(repo, branch, localBranchCreated, remoteBranchPushed, errW) return err } + saveTrailsEnabledForRemoteBestEffort(ctx, forge, owner, repoName, true) if err := api.DecodeJSON(resp, &createResp); err != nil { cleanupCreatedTrailBranch(repo, branch, localBranchCreated, remoteBranchPushed, errW) @@ -916,7 +919,7 @@ type trailUpdateInputs struct { } func runTrailUpdate(ctx context.Context, w, errW io.Writer, insecureHTTP bool, inputs trailUpdateInputs) error { - return runAuthenticatedDataAPI(ctx, errW, insecureHTTP, func(ctx context.Context, client *api.Client) error { + return runAuthenticatedTrailAPI(ctx, errW, insecureHTTP, func(ctx context.Context, client *api.Client) error { forge, owner, repoName, err := resolveTrailRemote(ctx) if err != nil { return err @@ -1124,7 +1127,7 @@ trail is looked up against that repository's origin remote.`, } func runTrailCheckout(ctx context.Context, w, errW io.Writer, insecureHTTP bool, selector string, force bool) error { - return runAuthenticatedDataAPI(ctx, errW, insecureHTTP, func(ctx context.Context, client *api.Client) error { + return runAuthenticatedTrailAPI(ctx, errW, insecureHTTP, func(ctx context.Context, client *api.Client) error { forge, owner, repo, err := resolveTrailRemote(ctx) if err != nil { return err @@ -1229,7 +1232,7 @@ func runTrailDelete(cmd *cobra.Command, number int, branch string, force bool) e ctx := cmd.Context() w := cmd.OutOrStdout() - return runAuthenticatedDataAPI(ctx, cmd.ErrOrStderr(), trailInsecureHTTP(cmd), func(ctx context.Context, client *api.Client) error { + return runAuthenticatedTrailAPI(ctx, cmd.ErrOrStderr(), trailInsecureHTTP(cmd), func(ctx context.Context, client *api.Client) error { forge, owner, repo, err := resolveTrailRemote(ctx) if err != nil { return err diff --git a/cmd/entire/cli/trail_cmd_test.go b/cmd/entire/cli/trail_cmd_test.go index 6fd3491a51..facac75988 100644 --- a/cmd/entire/cli/trail_cmd_test.go +++ b/cmd/entire/cli/trail_cmd_test.go @@ -19,6 +19,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/api" "github.com/entireio/cli/cmd/entire/cli/auth" + "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/cmd/entire/cli/testutil" "github.com/entireio/cli/cmd/entire/cli/trail" "github.com/entireio/cli/internal/entireclient/clusterdiscovery" @@ -575,6 +576,12 @@ func TestResolveTrailRemote_RejectsUnsupportedForge(t *testing.T) { func TestTrailsEnabledForRepo_ReadsClonePreference(t *testing.T) { repoDir := t.TempDir() testutil.InitRepo(t, repoDir) + cmd := exec.CommandContext(context.Background(), "git", "remote", "add", "origin", "git@github.com:acme/repo.git") + cmd.Dir = repoDir + cmd.Env = testutil.GitIsolatedEnv() + if err := cmd.Run(); err != nil { + t.Fatalf("git remote add: %v", err) + } t.Chdir(repoDir) ctx := context.Background() @@ -593,6 +600,45 @@ func TestTrailsEnabledForRepo_ReadsClonePreference(t *testing.T) { if !trailsEnabledForRepo(ctx) { t.Fatal("expected trails enabled when cache is true") } + + prefs, err := settings.LoadClonePreferences(ctx) + if err != nil { + t.Fatalf("load prefs: %v", err) + } + if prefs.TrailsEnabledRepoKey != "gh/acme/repo" { + t.Fatalf("repo key = %q, want gh/acme/repo", prefs.TrailsEnabledRepoKey) + } + + currentAuthKey := prefs.TrailsEnabledAuthKey + prefs.TrailsEnabledAuthKey = currentAuthKey + "-other" + if err := settings.SaveClonePreferences(ctx, prefs); err != nil { + t.Fatalf("save auth-mismatched prefs: %v", err) + } + if trailsEnabledForRepo(ctx) { + t.Fatal("expected trails disabled for mismatched auth cache scope") + } + prefs.TrailsEnabledAuthKey = currentAuthKey + fresh := time.Now() + prefs.TrailsEnabledCheckedAt = &fresh + if err := settings.SaveClonePreferences(ctx, prefs); err != nil { + t.Fatalf("restore auth-matched prefs: %v", err) + } + + stale := time.Now().Add(-trailEnablementCacheTTL - time.Minute) + prefs.TrailsEnabledCheckedAt = &stale + if err := settings.SaveClonePreferences(ctx, prefs); err != nil { + t.Fatalf("save stale prefs: %v", err) + } + if trailsEnabledForRepo(ctx) { + t.Fatal("expected trails disabled when cache is stale") + } + + if err := saveTrailsEnabledForRemote(ctx, "gh", "other", "repo", true); err != nil { + t.Fatalf("save mismatched cache: %v", err) + } + if trailsEnabledForRepo(ctx) { + t.Fatal("expected trails disabled for mismatched cache scope") + } } func TestTrailWatchDescription(t *testing.T) { diff --git a/cmd/entire/cli/trail_context_cache.go b/cmd/entire/cli/trail_context_cache.go index 68476112d3..1cd753c58a 100644 --- a/cmd/entire/cli/trail_context_cache.go +++ b/cmd/entire/cli/trail_context_cache.go @@ -2,33 +2,230 @@ package cli import ( "context" + "errors" "fmt" + "io" + "strings" + "time" + "github.com/entireio/cli/cmd/entire/cli/api" + "github.com/entireio/cli/cmd/entire/cli/auth" + "github.com/entireio/cli/cmd/entire/cli/gitremote" + "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/settings" ) -// trailsEnabledForRepo reports the locally cached server-side trails -// enablement for this repo. It intentionally performs no git subprocesses, auth -// work, or network I/O: this runs on the agent turn-start path. +const ( + trailEnablementCacheTTL = time.Hour + trailEnablementRefreshTimeout = 3 * time.Second +) + +type trailEnablementCacheStatus int + +const ( + trailEnablementCacheUnknown trailEnablementCacheStatus = iota + trailEnablementCacheEnabled + trailEnablementCacheDisabled +) + +type trailEnablementScope struct { + Forge string + Owner string + Repo string + RepoKey string + APIBase string + AuthKey string + Supported bool +} + +// trailsEnabledForRepo reads the local cache only. func trailsEnabledForRepo(ctx context.Context) bool { + return cachedTrailsEnablementForRepo(ctx, time.Now()) == trailEnablementCacheEnabled +} + +func cachedTrailsEnablementForRepo(ctx context.Context, now time.Time) trailEnablementCacheStatus { prefs, err := settings.LoadClonePreferences(ctx) - if err != nil || prefs.TrailsEnabled == nil { - return false + if err != nil || prefs.TrailsEnabled == nil || prefs.TrailsEnabledCheckedAt == nil { + return trailEnablementCacheUnknown + } + + scope, err := currentTrailEnablementScope(ctx) + if err != nil || !trailEnablementCacheMatchesScope(prefs, scope) || trailEnablementCacheExpired(*prefs.TrailsEnabledCheckedAt, now) { + return trailEnablementCacheUnknown + } + + if *prefs.TrailsEnabled { + return trailEnablementCacheEnabled + } + return trailEnablementCacheDisabled +} + +func trailEnablementCacheMatchesScope(prefs *settings.ClonePreferences, scope trailEnablementScope) bool { + return prefs.TrailsEnabledRepoKey == scope.RepoKey && + prefs.TrailsEnabledAPIBase == scope.APIBase && + prefs.TrailsEnabledAuthKey == scope.AuthKey +} + +func trailEnablementCacheExpired(checkedAt time.Time, now time.Time) bool { + if checkedAt.IsZero() { + return true } - return *prefs.TrailsEnabled + if now.Before(checkedAt) { + return true + } + return now.Sub(checkedAt) > trailEnablementCacheTTL +} + +func currentTrailEnablementScope(ctx context.Context) (trailEnablementScope, error) { + rawURL, err := gitremote.GetRemoteURL(ctx, "origin") + if err != nil { + return trailEnablementScope{}, fmt.Errorf("get origin remote: %w", err) + } + if strings.TrimSpace(rawURL) == "" { + return trailEnablementScope{}, errors.New("get origin remote: empty URL") + } + info, err := gitremote.ParseURL(rawURL) + if err != nil { + return trailEnablementScope{}, fmt.Errorf("parse origin remote: %w", err) + } + authKey, err := auth.LocalIdentityCacheKey() + if err != nil { + return trailEnablementScope{}, fmt.Errorf("resolve auth cache key: %w", err) + } + return trailEnablementScope{ + Forge: info.Forge, + Owner: info.Owner, + Repo: info.Repo, + RepoKey: trailEnablementRepoKey(info.Forge, info.Owner, info.Repo), + APIBase: api.BaseURL(), + AuthKey: authKey, + Supported: info.Forge != "", + }, nil +} + +func trailEnablementRepoKey(forge, owner, repo string) string { + return strings.Join([]string{forge, owner, repo}, "/") } -// saveTrailsEnabledForRepo persists the server-side trails enablement cache in -// clone-local preferences (.git/entire/preferences.json). The value is not -// committed and is shared by linked worktrees of the same clone. func saveTrailsEnabledForRepo(ctx context.Context, enabled bool) error { + scope, err := currentTrailEnablementScope(ctx) + if err != nil { + return err + } + return saveTrailsEnabledForScope(ctx, scope, enabled, time.Now()) +} + +func saveTrailsEnabledForRemote(ctx context.Context, forge, owner, repo string, enabled bool) error { + authKey, err := auth.LocalIdentityCacheKey() + if err != nil { + return fmt.Errorf("resolve auth cache key: %w", err) + } + scope := trailEnablementScope{ + Forge: forge, + Owner: owner, + Repo: repo, + RepoKey: trailEnablementRepoKey(forge, owner, repo), + APIBase: api.BaseURL(), + AuthKey: authKey, + Supported: forge != "", + } + return saveTrailsEnabledForScope(ctx, scope, enabled, time.Now()) +} + +func saveTrailsEnabledForScope(ctx context.Context, scope trailEnablementScope, enabled bool, checkedAt time.Time) error { prefs, err := settings.LoadClonePreferences(ctx) if err != nil { return fmt.Errorf("load clone preferences: %w", err) } - prefs.TrailsEnabled = &enabled + enabledCopy := enabled + checkedAtUTC := checkedAt.UTC() + prefs.TrailsEnabled = &enabledCopy + prefs.TrailsEnabledCheckedAt = &checkedAtUTC + prefs.TrailsEnabledRepoKey = scope.RepoKey + prefs.TrailsEnabledAPIBase = scope.APIBase + prefs.TrailsEnabledAuthKey = scope.AuthKey if err := settings.SaveClonePreferences(ctx, prefs); err != nil { return fmt.Errorf("save clone preferences: %w", err) } return nil } + +func refreshTrailsEnabledCacheIfStale(ctx context.Context) error { + if cachedTrailsEnablementForRepo(ctx, time.Now()) != trailEnablementCacheUnknown { + return nil + } + scope, err := currentTrailEnablementScope(ctx) + if err != nil { + return err + } + if !scope.Supported { + return saveTrailsEnabledForScope(ctx, scope, false, time.Now()) + } + client, err := NewAuthenticatedAPIClient(ctx, false) + if err != nil { + return err + } + _, err = refreshTrailsEnabledCacheForScope(ctx, client, scope) + return err +} + +func refreshTrailsEnabledCache(ctx context.Context, client *api.Client) (bool, error) { + scope, err := currentTrailEnablementScope(ctx) + if err != nil { + return false, err + } + return refreshTrailsEnabledCacheForScope(ctx, client, scope) +} + +func refreshTrailsEnabledCacheForScope(ctx context.Context, client *api.Client, scope trailEnablementScope) (bool, error) { + if !scope.Supported { + if err := saveTrailsEnabledForScope(ctx, scope, false, time.Now()); err != nil { + return false, err + } + return false, nil + } + enabled, err := client.TrailsEnabled(ctx, scope.Forge, scope.Owner, scope.Repo) + if err != nil { + return false, fmt.Errorf("check trails enablement: %w", err) + } + if err := saveTrailsEnabledForScope(ctx, scope, enabled, time.Now()); err != nil { + return false, err + } + return enabled, nil +} + +func saveTrailsEnabledForRepoBestEffort(ctx context.Context, enabled bool) { + if err := saveTrailsEnabledForRepo(ctx, enabled); err != nil { + logging.Debug(ctx, "failed to cache trails enablement", "error", err) + } +} + +func saveTrailsEnabledForRemoteBestEffort(ctx context.Context, forge, owner, repo string, enabled bool) { + if err := saveTrailsEnabledForRemote(ctx, forge, owner, repo, enabled); err != nil { + logging.Debug(ctx, "failed to cache trails enablement", "error", err) + } +} + +func refreshTrailsEnabledCacheBestEffort(ctx context.Context, client *api.Client) { + refreshCtx, cancel := context.WithTimeout(ctx, trailEnablementRefreshTimeout) + defer cancel() + if _, err := refreshTrailsEnabledCache(refreshCtx, client); err != nil { + logging.Debug(ctx, "trails enablement refresh skipped", "error", err) + } +} + +func noteTrailCommandEnablement(ctx context.Context, client *api.Client, commandErr error) { + if commandErr == nil { + saveTrailsEnabledForRepoBestEffort(ctx, true) + return + } + refreshTrailsEnabledCacheBestEffort(ctx, client) +} + +func runAuthenticatedTrailAPI(ctx context.Context, errW io.Writer, insecureHTTP bool, fn func(context.Context, *api.Client) error) error { + return runAuthenticatedDataAPI(ctx, errW, insecureHTTP, func(ctx context.Context, client *api.Client) error { + err := fn(ctx, client) + noteTrailCommandEnablement(ctx, client, err) + return err + }) +} diff --git a/cmd/entire/cli/trail_review_cmd.go b/cmd/entire/cli/trail_review_cmd.go index e855e9156a..45a800a56e 100644 --- a/cmd/entire/cli/trail_review_cmd.go +++ b/cmd/entire/cli/trail_review_cmd.go @@ -466,7 +466,7 @@ func runTrailReviewSetStatus(cmd *cobra.Command, selector string, commentID, sta func authenticatedTrailReviewTarget(cmd *cobra.Command, selector string) (*api.Client, trailReviewTarget, error) { var target trailReviewTarget var resolvedClient *api.Client - err := runAuthenticatedDataAPI(cmd.Context(), cmd.ErrOrStderr(), trailInsecureHTTP(cmd), func(ctx context.Context, client *api.Client) error { + err := runAuthenticatedTrailAPI(cmd.Context(), cmd.ErrOrStderr(), trailInsecureHTTP(cmd), func(ctx context.Context, client *api.Client) error { var err error resolvedClient = client target, err = resolveTrailReviewTarget(ctx, client, selector) diff --git a/cmd/entire/cli/trail_watch_cmd.go b/cmd/entire/cli/trail_watch_cmd.go index 8fa7539124..5330fe1468 100644 --- a/cmd/entire/cli/trail_watch_cmd.go +++ b/cmd/entire/cli/trail_watch_cmd.go @@ -84,11 +84,12 @@ Events emitted by the server: } func runTrailWatch(cmd *cobra.Command, number int, jsonOutput, showPings, once bool) error { - return runAuthenticatedDataAPI(cmd.Context(), cmd.ErrOrStderr(), trailInsecureHTTP(cmd), func(ctx context.Context, client *api.Client) error { + return runAuthenticatedTrailAPI(cmd.Context(), cmd.ErrOrStderr(), trailInsecureHTTP(cmd), func(ctx context.Context, client *api.Client) error { trailID, description, err := resolveTrailWatchTarget(ctx, client, number) if err != nil { return err } + saveTrailsEnabledForRepoBestEffort(ctx, true) return runTrailWatchResolved(cmd, client, trailID, description, jsonOutput, showPings, once) }) } diff --git a/docs/architecture/agent-guide.md b/docs/architecture/agent-guide.md index fc7c033a33..75179be66f 100644 --- a/docs/architecture/agent-guide.md +++ b/docs/architecture/agent-guide.md @@ -51,7 +51,7 @@ Every agent must implement all 19 methods on the `Agent` interface: | `TokenCalculator` | `CalculateTokenUsage` | Agent's transcript contains token usage data | | `SubagentAwareExtractor` | `ExtractAllModifiedFiles`, `CalculateTotalTokenUsage` | Agent spawns subagents (like Claude Code's Task tool) | | `HookResponseWriter` | `WriteHookResponse` | Agent can display messages from hook responses (e.g., session start banner). Claude Code uses JSON `systemMessage` on stdout; Factory AI Droid uses plain text on stdout. | -| `ContextInjector` | `InjectionEvent`, `RenderContextInjection` | Agent can inject text into the **model's** context window (distinct from `HookResponseWriter`, which targets the *user*). The agent declares which lifecycle event it injects at and renders a native stdout payload. The dispatcher (`emitContextInjection`) emits it once per normal session via `session.State.ContextInjectionDecided`, skipping review/investigate sessions, and only when clone-local preferences say trails are enabled for the repo. The API check happens earlier on `entire enable` (`reportRepoEnabled` refreshes `ClonePreferences.TrailsEnabled` using `api.Client.TrailsEnabled`); the prompt path performs no git/auth/network work. Claude Code / Codex / Gemini inject at `TurnStart` using `hookSpecificOutput.additionalContext` (UserPromptSubmit / BeforeAgent); Pi and OpenCode emit a `{"inject_context":...}` envelope that their embedded extension applies (Pi via a `before_agent_start` message, OpenCode via `experimental.chat.system.transform`). | +| `ContextInjector` | `InjectionEvent`, `RenderContextInjection` | Agent can inject text into the **model's** context window (distinct from `HookResponseWriter`, which targets the *user*). The agent declares which lifecycle event it injects at and renders a native stdout payload. The dispatcher (`emitContextInjection`) emits it once per normal session via `session.State.ContextInjectionDecided`, skipping review/investigate sessions, and only when fresh clone-local preferences say trails are enabled for the current repo/API/auth target. The API check happens before the prompt path (`entire enable`, successful `entire trail ...` commands, and stale/missing cache refresh on SessionStart all refresh `ClonePreferences.TrailsEnabled` using `api.Client.TrailsEnabled`); TurnStart performs no auth/network work and leaves unknown/stale caches undecided so a later refresh can still inject. Claude Code / Codex / Gemini inject at `TurnStart` using `hookSpecificOutput.additionalContext` (UserPromptSubmit / BeforeAgent); Pi and OpenCode emit a `{"inject_context":...}` envelope that their embedded extension applies (Pi via a `before_agent_start` message, OpenCode via `experimental.chat.system.transform`). | | `FileWatcher` | `GetWatchPaths`, `OnFileChange` | Agent doesn't support hooks; uses file-based detection instead | ## Step-by-Step Implementation Guide From 6c8f798e8b042838bc87c9dc0a9a002ad5bd2362 Mon Sep 17 00:00:00 2001 From: dipree Date: Wed, 24 Jun 2026 15:32:22 +0200 Subject: [PATCH 2/4] Lock clone preference updates Entire-Checkpoint: f47b5f818fbe --- cmd/entire/cli/review/migration.go | 92 ++++++++++++--------------- cmd/entire/cli/review/picker.go | 28 +++----- cmd/entire/cli/settings/settings.go | 30 +++++++++ cmd/entire/cli/trail_context_cache.go | 18 +++--- 4 files changed, 88 insertions(+), 80 deletions(-) diff --git a/cmd/entire/cli/review/migration.go b/cmd/entire/cli/review/migration.go index ad2fe8a3c2..94900234d3 100644 --- a/cmd/entire/cli/review/migration.go +++ b/cmd/entire/cli/review/migration.go @@ -90,11 +90,10 @@ func maybePromptReviewSettingsMigration( return fmt.Errorf("review settings migration prompt: %w", err) } if !migrate { - if prefs == nil { - prefs = &settings.ClonePreferences{} - } - prefs.ReviewMigrationDismissed = true - if err := settings.SaveClonePreferences(ctx, prefs); err != nil { + if err := settings.ModifyClonePreferences(ctx, func(prefs *settings.ClonePreferences) error { + prefs.ReviewMigrationDismissed = true + return nil + }); err != nil { return fmt.Errorf("save migration dismissal: %w", err) } return nil @@ -154,59 +153,50 @@ func migrateProjectReviewSettings(ctx context.Context, project *projectReviewSet return false, nil } - prefs, err := settings.LoadClonePreferences(ctx) - if err != nil { - return false, fmt.Errorf("load review preferences for migration: %w", err) - } - if prefs == nil { - prefs = &settings.ClonePreferences{} - } - preferencesChanged := false - if project.hasReview && !isJSONNull(project.review) { - var projectReview map[string]settings.ReviewConfig - if err := json.Unmarshal(project.review, &projectReview); err != nil { - return false, fmt.Errorf("parsing project review settings: %w", err) - } - if len(projectReview) > 0 { - merged, mergedOK, conflicts := mergeProjectReviewIntoPrefs(prefs.Review, projectReview) - if len(conflicts) > 0 { - return false, fmt.Errorf( - "review settings exist in both %s and clone-local preferences for agent(s) %v; "+ - "reconcile manually by removing the redundant keys from %s, then re-run `entire review`", - project.path, conflicts, project.path, - ) + if err := settings.ModifyClonePreferences(ctx, func(prefs *settings.ClonePreferences) error { + if project.hasReview && !isJSONNull(project.review) { + var projectReview map[string]settings.ReviewConfig + if err := json.Unmarshal(project.review, &projectReview); err != nil { + return fmt.Errorf("parsing project review settings: %w", err) } - if mergedOK { - prefs.Review = merged - preferencesChanged = true + if len(projectReview) > 0 { + merged, mergedOK, conflicts := mergeProjectReviewIntoPrefs(prefs.Review, projectReview) + if len(conflicts) > 0 { + return fmt.Errorf( + "review settings exist in both %s and clone-local preferences for agent(s) %v; "+ + "reconcile manually by removing the redundant keys from %s, then re-run `entire review`", + project.path, conflicts, project.path, + ) + } + if mergedOK { + prefs.Review = merged + preferencesChanged = true + } } } - } - if project.hasFixAgent && !isJSONNull(project.fixAgent) { - var fixAgent string - if err := json.Unmarshal(project.fixAgent, &fixAgent); err != nil { - return false, fmt.Errorf("parsing project review_fix_agent: %w", err) - } - if fixAgent != "" { - if prefs.ReviewFixAgent != "" && prefs.ReviewFixAgent != fixAgent { - return false, fmt.Errorf( - "review_fix_agent differs between %s (%q) and clone-local preferences (%q); "+ - "reconcile manually by removing review_fix_agent from %s, then re-run `entire review`", - project.path, fixAgent, prefs.ReviewFixAgent, project.path, - ) + if project.hasFixAgent && !isJSONNull(project.fixAgent) { + var fixAgent string + if err := json.Unmarshal(project.fixAgent, &fixAgent); err != nil { + return fmt.Errorf("parsing project review_fix_agent: %w", err) } - if prefs.ReviewFixAgent == "" { - prefs.ReviewFixAgent = fixAgent - preferencesChanged = true + if fixAgent != "" { + if prefs.ReviewFixAgent != "" && prefs.ReviewFixAgent != fixAgent { + return fmt.Errorf( + "review_fix_agent differs between %s (%q) and clone-local preferences (%q); "+ + "reconcile manually by removing review_fix_agent from %s, then re-run `entire review`", + project.path, fixAgent, prefs.ReviewFixAgent, project.path, + ) + } + if prefs.ReviewFixAgent == "" { + prefs.ReviewFixAgent = fixAgent + preferencesChanged = true + } } } - } - - if preferencesChanged { - if err := settings.SaveClonePreferences(ctx, prefs); err != nil { - return false, fmt.Errorf("save review preferences for migration: %w", err) - } + return nil + }); err != nil { + return false, fmt.Errorf("save review preferences for migration: %w", err) } delete(project.raw, "review") diff --git a/cmd/entire/cli/review/picker.go b/cmd/entire/cli/review/picker.go index caf226f4c3..234c4ec288 100644 --- a/cmd/entire/cli/review/picker.go +++ b/cmd/entire/cli/review/picker.go @@ -256,31 +256,21 @@ func MergePickerResults(existing map[string]settings.ReviewConfig, offered map[s } func SaveReviewFixAgent(ctx context.Context, agentName string) error { - prefs, err := settings.LoadClonePreferences(ctx) - if err != nil { - return fmt.Errorf("load review preferences before save: %w", err) - } - if prefs == nil { - prefs = &settings.ClonePreferences{} - } - prefs.ReviewFixAgent = agentName - if err := settings.SaveClonePreferences(ctx, prefs); err != nil { + if err := settings.ModifyClonePreferences(ctx, func(prefs *settings.ClonePreferences) error { + prefs.ReviewFixAgent = agentName + return nil + }); err != nil { return fmt.Errorf("save review preferences: %w", err) } return nil } func saveReviewConfigAndFixAgent(ctx context.Context, review map[string]settings.ReviewConfig, fixAgent string) error { - prefs, err := settings.LoadClonePreferences(ctx) - if err != nil { - return fmt.Errorf("load review preferences before save: %w", err) - } - if prefs == nil { - prefs = &settings.ClonePreferences{} - } - prefs.Review = review - prefs.ReviewFixAgent = fixAgent - if err := settings.SaveClonePreferences(ctx, prefs); err != nil { + if err := settings.ModifyClonePreferences(ctx, func(prefs *settings.ClonePreferences) error { + prefs.Review = review + prefs.ReviewFixAgent = fixAgent + return nil + }); err != nil { return fmt.Errorf("save review preferences: %w", err) } return nil diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index 4043918fc5..a2672cdfb5 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -15,6 +15,7 @@ import ( "strings" "time" + "github.com/entireio/cli/cmd/entire/cli/internal/flock" "github.com/entireio/cli/cmd/entire/cli/jsonutil" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" @@ -588,6 +589,15 @@ func SaveClonePreferences(ctx context.Context, prefs *ClonePreferences) error { return saveClonePreferencesToFile(prefs, path) } +// ModifyClonePreferences runs a read-modify-write under the preferences lock. +func ModifyClonePreferences(ctx context.Context, fn func(*ClonePreferences) error) error { + path, err := ClonePreferencesPath(ctx) + if err != nil { + return err + } + return modifyClonePreferencesFile(path, fn) +} + // LoadFromBytes parses settings from raw JSON bytes without merging local overrides. // Use this when you have settings content from a non-file source (e.g., git show). func LoadFromBytes(data []byte) (*EntireSettings, error) { @@ -692,6 +702,26 @@ func saveClonePreferencesToFile(prefs *ClonePreferences, filePath string) error return nil } +func modifyClonePreferencesFile(filePath string, fn func(*ClonePreferences) error) error { + if err := os.MkdirAll(filepath.Dir(filePath), 0o750); err != nil { + return fmt.Errorf("creating preferences directory: %w", err) + } + release, err := flock.Acquire(filePath + ".lock") + if err != nil { + return fmt.Errorf("lock preferences file: %w", err) + } + defer release() + + prefs, err := loadClonePreferencesFromFile(filePath) + if err != nil { + return err + } + if err := fn(prefs); err != nil { + return err + } + return saveClonePreferencesToFile(prefs, filePath) +} + func applyClonePreferences(settings *EntireSettings, prefs *ClonePreferences) { if prefs == nil { return diff --git a/cmd/entire/cli/trail_context_cache.go b/cmd/entire/cli/trail_context_cache.go index 1cd753c58a..5f26d77c37 100644 --- a/cmd/entire/cli/trail_context_cache.go +++ b/cmd/entire/cli/trail_context_cache.go @@ -133,18 +133,16 @@ func saveTrailsEnabledForRemote(ctx context.Context, forge, owner, repo string, } func saveTrailsEnabledForScope(ctx context.Context, scope trailEnablementScope, enabled bool, checkedAt time.Time) error { - prefs, err := settings.LoadClonePreferences(ctx) - if err != nil { - return fmt.Errorf("load clone preferences: %w", err) - } enabledCopy := enabled checkedAtUTC := checkedAt.UTC() - prefs.TrailsEnabled = &enabledCopy - prefs.TrailsEnabledCheckedAt = &checkedAtUTC - prefs.TrailsEnabledRepoKey = scope.RepoKey - prefs.TrailsEnabledAPIBase = scope.APIBase - prefs.TrailsEnabledAuthKey = scope.AuthKey - if err := settings.SaveClonePreferences(ctx, prefs); err != nil { + if err := settings.ModifyClonePreferences(ctx, func(prefs *settings.ClonePreferences) error { + prefs.TrailsEnabled = &enabledCopy + prefs.TrailsEnabledCheckedAt = &checkedAtUTC + prefs.TrailsEnabledRepoKey = scope.RepoKey + prefs.TrailsEnabledAPIBase = scope.APIBase + prefs.TrailsEnabledAuthKey = scope.AuthKey + return nil + }); err != nil { return fmt.Errorf("save clone preferences: %w", err) } return nil From 89df024664eab9f14cee9d523b670b6352711016 Mon Sep 17 00:00:00 2001 From: dipree Date: Wed, 24 Jun 2026 15:35:13 +0200 Subject: [PATCH 3/4] Shorten session trail refresh timeout Entire-Checkpoint: 16b3011cd8e0 --- cmd/entire/cli/lifecycle.go | 2 +- cmd/entire/cli/trail_context_cache.go | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index ad08d0fc08..7f6b511df0 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -147,7 +147,7 @@ func handleLifecycleSessionStart(ctx context.Context, ag agent.Agent, event *age } // Refresh before the TurnStart prompt path. - refreshCtx, refreshCancel := context.WithTimeout(ctx, trailEnablementRefreshTimeout) + refreshCtx, refreshCancel := context.WithTimeout(ctx, trailEnablementSessionStartRefreshTimeout) if refreshErr := refreshTrailsEnabledCacheIfStale(refreshCtx); refreshErr != nil { logging.Debug(logCtx, "trails enablement refresh skipped", slog.String("error", refreshErr.Error())) diff --git a/cmd/entire/cli/trail_context_cache.go b/cmd/entire/cli/trail_context_cache.go index 5f26d77c37..9e0855bcd9 100644 --- a/cmd/entire/cli/trail_context_cache.go +++ b/cmd/entire/cli/trail_context_cache.go @@ -16,8 +16,9 @@ import ( ) const ( - trailEnablementCacheTTL = time.Hour - trailEnablementRefreshTimeout = 3 * time.Second + trailEnablementCacheTTL = time.Hour + trailEnablementSessionStartRefreshTimeout = time.Second + trailEnablementRefreshTimeout = 3 * time.Second ) type trailEnablementCacheStatus int From 3ae6e8d6169272662225eb1a1163cb72dd2843c1 Mon Sep 17 00:00:00 2001 From: dipree Date: Wed, 24 Jun 2026 15:46:13 +0200 Subject: [PATCH 4/4] Keep trail injection prompt path local Entire-Checkpoint: 66caa3dd69dd --- cmd/entire/cli/lifecycle.go | 26 ++++++-- cmd/entire/cli/lifecycle_test.go | 3 + cmd/entire/cli/trail_context_cache.go | 91 +++++++++++++++++++++------ 3 files changed, 98 insertions(+), 22 deletions(-) diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index 7f6b511df0..692a25aa4d 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -146,11 +146,20 @@ func handleLifecycleSessionStart(ctx context.Context, ag agent.Agent, event *age slog.String("error", hintErr.Error())) } - // Refresh before the TurnStart prompt path. + // Resolve scope before the TurnStart prompt path. refreshCtx, refreshCancel := context.WithTimeout(ctx, trailEnablementSessionStartRefreshTimeout) - if refreshErr := refreshTrailsEnabledCacheIfStale(refreshCtx); refreshErr != nil { + if scope, scopeErr := currentTrailEnablementScope(refreshCtx); scopeErr != nil { logging.Debug(logCtx, "trails enablement refresh skipped", - slog.String("error", refreshErr.Error())) + slog.String("error", scopeErr.Error())) + } else { + if hintErr := saveTrailEnablementScopeHint(ctx, event.SessionID, scope); hintErr != nil { + logging.Debug(logCtx, "failed to cache trails scope hint", + slog.String("error", hintErr.Error())) + } + if refreshErr := refreshTrailsEnabledCacheIfStaleForScope(refreshCtx, scope); refreshErr != nil { + logging.Debug(logCtx, "trails enablement refresh skipped", + slog.String("error", refreshErr.Error())) + } } refreshCancel() @@ -393,6 +402,12 @@ func emitContextInjection(ctx context.Context, ag agent.Agent, event *agent.Even logCtx := logging.WithAgent(logging.WithComponent(ctx, "lifecycle"), ag.Name()) // Unknown cache leaves the session retryable. + scope, scopeOK, scopeErr := loadTrailEnablementScopeHint(ctx, event.SessionID) + if scopeErr != nil { + logging.Warn(logCtx, "failed to load trails scope hint", + slog.String("error", scopeErr.Error())) + return + } decision := trailEnablementCacheUnknown mutated := false mutErr := strategy.MutateSessionState(ctx, event.SessionID, func(state *strategy.SessionState) error { @@ -405,7 +420,10 @@ func emitContextInjection(ctx context.Context, ag agent.Agent, event *agent.Even if state.Kind != "" { return strategy.ErrMutationSkip } - decision = cachedTrailsEnablementForRepo(ctx, time.Now()) + if !scopeOK { + return strategy.ErrMutationSkip + } + decision = cachedTrailsEnablementForScope(ctx, scope, time.Now()) if decision == trailEnablementCacheUnknown { return strategy.ErrMutationSkip } diff --git a/cmd/entire/cli/lifecycle_test.go b/cmd/entire/cli/lifecycle_test.go index fc64c9f90a..09c52a9308 100644 --- a/cmd/entire/cli/lifecycle_test.go +++ b/cmd/entire/cli/lifecycle_test.go @@ -1200,6 +1200,9 @@ func TestHandleLifecycleTurnStart_ContextInjectionFreshTrueMarksDecided(t *testi ag := &mockContextInjectorAgent{mockLifecycleAgent: *newMockAgent()} sessionID := "test-trail-inject-true" + scope, err := currentTrailEnablementScope(context.Background()) + require.NoError(t, err) + require.NoError(t, saveTrailEnablementScopeHint(context.Background(), sessionID, scope)) event := &agent.Event{Type: agent.TurnStart, SessionID: sessionID, Prompt: "hello", Timestamp: time.Now()} require.NoError(t, handleLifecycleTurnStart(context.Background(), ag, event)) diff --git a/cmd/entire/cli/trail_context_cache.go b/cmd/entire/cli/trail_context_cache.go index 9e0855bcd9..11a0d67764 100644 --- a/cmd/entire/cli/trail_context_cache.go +++ b/cmd/entire/cli/trail_context_cache.go @@ -2,17 +2,23 @@ package cli import ( "context" + "encoding/json" "errors" "fmt" "io" + "os" + "path/filepath" "strings" "time" "github.com/entireio/cli/cmd/entire/cli/api" "github.com/entireio/cli/cmd/entire/cli/auth" "github.com/entireio/cli/cmd/entire/cli/gitremote" + "github.com/entireio/cli/cmd/entire/cli/jsonutil" "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/session" "github.com/entireio/cli/cmd/entire/cli/settings" + "github.com/entireio/cli/cmd/entire/cli/validation" ) const ( @@ -30,31 +36,36 @@ const ( ) type trailEnablementScope struct { - Forge string - Owner string - Repo string - RepoKey string - APIBase string - AuthKey string - Supported bool + Forge string `json:"forge"` + Owner string `json:"owner"` + Repo string `json:"repo"` + RepoKey string `json:"repo_key"` + APIBase string `json:"api_base"` + AuthKey string `json:"auth_key"` + Supported bool `json:"supported"` } -// trailsEnabledForRepo reads the local cache only. +// trailsEnabledForRepo reports cached enablement for the current repo. func trailsEnabledForRepo(ctx context.Context) bool { return cachedTrailsEnablementForRepo(ctx, time.Now()) == trailEnablementCacheEnabled } func cachedTrailsEnablementForRepo(ctx context.Context, now time.Time) trailEnablementCacheStatus { + scope, err := currentTrailEnablementScope(ctx) + if err != nil { + return trailEnablementCacheUnknown + } + return cachedTrailsEnablementForScope(ctx, scope, now) +} + +func cachedTrailsEnablementForScope(ctx context.Context, scope trailEnablementScope, now time.Time) trailEnablementCacheStatus { prefs, err := settings.LoadClonePreferences(ctx) if err != nil || prefs.TrailsEnabled == nil || prefs.TrailsEnabledCheckedAt == nil { return trailEnablementCacheUnknown } - - scope, err := currentTrailEnablementScope(ctx) - if err != nil || !trailEnablementCacheMatchesScope(prefs, scope) || trailEnablementCacheExpired(*prefs.TrailsEnabledCheckedAt, now) { + if !trailEnablementCacheMatchesScope(prefs, scope) || trailEnablementCacheExpired(*prefs.TrailsEnabledCheckedAt, now) { return trailEnablementCacheUnknown } - if *prefs.TrailsEnabled { return trailEnablementCacheEnabled } @@ -108,6 +119,54 @@ func trailEnablementRepoKey(forge, owner, repo string) string { return strings.Join([]string{forge, owner, repo}, "/") } +func saveTrailEnablementScopeHint(ctx context.Context, sessionID string, scope trailEnablementScope) error { + path, err := trailEnablementScopeHintPath(ctx, sessionID) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return fmt.Errorf("create session state dir: %w", err) + } + data, err := jsonutil.MarshalIndentWithNewline(scope, "", " ") + if err != nil { + return fmt.Errorf("marshal trail scope hint: %w", err) + } + if err := jsonutil.WriteFileAtomic(path, data, 0o600); err != nil { + return fmt.Errorf("write trail scope hint: %w", err) + } + return nil +} + +func loadTrailEnablementScopeHint(ctx context.Context, sessionID string) (trailEnablementScope, bool, error) { + path, err := trailEnablementScopeHintPath(ctx, sessionID) + if err != nil { + return trailEnablementScope{}, false, err + } + data, err := os.ReadFile(path) //nolint:gosec // path is derived from validated session ID + if err != nil { + if os.IsNotExist(err) { + return trailEnablementScope{}, false, nil + } + return trailEnablementScope{}, false, fmt.Errorf("read trail scope hint: %w", err) + } + var scope trailEnablementScope + if err := json.Unmarshal(data, &scope); err != nil { + return trailEnablementScope{}, false, fmt.Errorf("parse trail scope hint: %w", err) + } + return scope, true, nil +} + +func trailEnablementScopeHintPath(ctx context.Context, sessionID string) (string, error) { + if err := validation.ValidateSessionID(sessionID); err != nil { + return "", fmt.Errorf("invalid session ID: %w", err) + } + commonDir, err := session.GetGitCommonDir(ctx) + if err != nil { + return "", fmt.Errorf("resolve git common dir: %w", err) + } + return filepath.Join(commonDir, session.SessionStateDirName, sessionID+".trail-scope.json"), nil +} + func saveTrailsEnabledForRepo(ctx context.Context, enabled bool) error { scope, err := currentTrailEnablementScope(ctx) if err != nil { @@ -149,14 +208,10 @@ func saveTrailsEnabledForScope(ctx context.Context, scope trailEnablementScope, return nil } -func refreshTrailsEnabledCacheIfStale(ctx context.Context) error { - if cachedTrailsEnablementForRepo(ctx, time.Now()) != trailEnablementCacheUnknown { +func refreshTrailsEnabledCacheIfStaleForScope(ctx context.Context, scope trailEnablementScope) error { + if cachedTrailsEnablementForScope(ctx, scope, time.Now()) != trailEnablementCacheUnknown { return nil } - scope, err := currentTrailEnablementScope(ctx) - if err != nil { - return err - } if !scope.Supported { return saveTrailsEnabledForScope(ctx, scope, false, time.Now()) }