Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 20 additions & 8 deletions cmd/entire/cli/checkpoint/checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package checkpoint

import (
"context"
"encoding/json"
"errors"
"time"

Expand Down Expand Up @@ -282,6 +283,12 @@ type WriteCommittedOptions struct {
// comparing checkpoint tree (agent work) to committed tree (may include human edits)
InitialAttribution *InitialAttribution

// PromptAttributionsJSON is the raw PromptAttributions data, JSON-encoded.
// Persisted for diagnostic purposes — shows exactly which prompt recorded
// which "user" lines, enabling root cause analysis of attribution bugs.
// Uses json.RawMessage to avoid importing session package.
PromptAttributionsJSON json.RawMessage

// Summary is an optional AI-generated summary for this checkpoint.
// This field may be nil when:
// - summarization is disabled in settings
Expand Down Expand Up @@ -402,6 +409,10 @@ type CommittedMetadata struct {

// InitialAttribution is line-level attribution calculated at commit time
InitialAttribution *InitialAttribution `json:"initial_attribution,omitempty"`

// PromptAttributions is the raw per-prompt attribution data used to compute InitialAttribution.
// Diagnostic field — shows which prompt recorded which "user" lines.
PromptAttributions json.RawMessage `json:"prompt_attributions,omitempty"`
}

// GetTranscriptStart returns the transcript line offset at which this checkpoint's data begins.
Expand Down Expand Up @@ -443,14 +454,15 @@ type SessionFilePaths struct {
//
//nolint:revive // Named CheckpointSummary to avoid conflict with existing Summary struct
type CheckpointSummary struct {
CLIVersion string `json:"cli_version,omitempty"`
CheckpointID id.CheckpointID `json:"checkpoint_id"`
Strategy string `json:"strategy"`
Branch string `json:"branch,omitempty"`
CheckpointsCount int `json:"checkpoints_count"`
FilesTouched []string `json:"files_touched"`
Sessions []SessionFilePaths `json:"sessions"`
TokenUsage *agent.TokenUsage `json:"token_usage,omitempty"`
CLIVersion string `json:"cli_version,omitempty"`
CheckpointID id.CheckpointID `json:"checkpoint_id"`
Strategy string `json:"strategy"`
Branch string `json:"branch,omitempty"`
CheckpointsCount int `json:"checkpoints_count"`
FilesTouched []string `json:"files_touched"`
Sessions []SessionFilePaths `json:"sessions"`
TokenUsage *agent.TokenUsage `json:"token_usage,omitempty"`
CombinedAttribution *InitialAttribution `json:"combined_attribution,omitempty"`
}

// SessionMetrics contains hook-provided session metrics from agents that report
Expand Down
140 changes: 132 additions & 8 deletions cmd/entire/cli/checkpoint/committed.go
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ func (s *GitStore) writeSessionToSubdirectory(ctx context.Context, opts WriteCom
TokenUsage: opts.TokenUsage,
SessionMetrics: opts.SessionMetrics,
InitialAttribution: opts.InitialAttribution,
PromptAttributions: opts.PromptAttributionsJSON,
Summary: redactSummary(opts.Summary),
CLIVersion: versioninfo.Version,
}
Expand Down Expand Up @@ -414,15 +415,25 @@ func (s *GitStore) writeCheckpointSummary(opts WriteCommittedOptions, basePath s
return fmt.Errorf("failed to aggregate session stats: %w", err)
}

var combinedAttribution *InitialAttribution
rootMetadataPath := basePath + paths.MetadataFileName
if entry, exists := entries[rootMetadataPath]; exists {
existingSummary, readErr := s.readSummaryFromBlob(entry.Hash)
if readErr == nil {
combinedAttribution = existingSummary.CombinedAttribution
}
}

summary := CheckpointSummary{
CheckpointID: opts.CheckpointID,
CLIVersion: versioninfo.Version,
Strategy: opts.Strategy,
Branch: opts.Branch,
CheckpointsCount: checkpointsCount,
FilesTouched: filesTouched,
Sessions: sessions,
TokenUsage: tokenUsage,
CheckpointID: opts.CheckpointID,
CLIVersion: versioninfo.Version,
Strategy: opts.Strategy,
Branch: opts.Branch,
CheckpointsCount: checkpointsCount,
FilesTouched: filesTouched,
Sessions: sessions,
TokenUsage: tokenUsage,
CombinedAttribution: combinedAttribution,
}

metadataJSON, err := jsonutil.MarshalIndentWithNewline(summary, "", " ")
Expand All @@ -441,6 +452,76 @@ func (s *GitStore) writeCheckpointSummary(opts WriteCommittedOptions, basePath s
return nil
}

// UpdateCheckpointSummary updates root-level checkpoint metadata fields that depend
// on the full set of sessions already written to the checkpoint.
func (s *GitStore) UpdateCheckpointSummary(ctx context.Context, checkpointID id.CheckpointID, combinedAttribution *InitialAttribution) error {
if err := ctx.Err(); err != nil {
return err //nolint:wrapcheck // Propagating context cancellation
}

if err := s.ensureSessionsBranch(); err != nil {
return fmt.Errorf("failed to ensure sessions branch: %w", err)
}

parentHash, rootTreeHash, err := s.getSessionsBranchRef()
if err != nil {
return err
}

basePath := checkpointID.Path() + "/"
checkpointPath := checkpointID.Path()
entries, err := s.flattenCheckpointEntries(rootTreeHash, checkpointPath)
if err != nil {
return err
}

rootMetadataPath := basePath + paths.MetadataFileName
entry, exists := entries[rootMetadataPath]
if !exists {
return ErrCheckpointNotFound
}

summary, err := s.readSummaryFromBlob(entry.Hash)
if err != nil {
return fmt.Errorf("failed to read checkpoint summary: %w", err)
}
summary.CombinedAttribution = combinedAttribution

metadataJSON, err := jsonutil.MarshalIndentWithNewline(summary, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal checkpoint summary: %w", err)
}
metadataHash, err := CreateBlobFromContent(s.repo, metadataJSON)
if err != nil {
return fmt.Errorf("failed to create checkpoint summary blob: %w", err)
}
entries[rootMetadataPath] = object.TreeEntry{
Name: rootMetadataPath,
Mode: filemode.Regular,
Hash: metadataHash,
}

newTreeHash, err := s.spliceCheckpointSubtree(rootTreeHash, checkpointID, basePath, entries)
if err != nil {
return err
}

authorName, authorEmail := GetGitAuthorFromRepo(s.repo)
commitMsg := fmt.Sprintf("Update checkpoint summary for %s", checkpointID)
newCommitHash, err := s.createCommit(newTreeHash, parentHash, commitMsg, authorName, authorEmail)
if err != nil {
return err
}

refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
newRef := plumbing.NewHashReference(refName, newCommitHash)
if err := s.repo.Storer.SetReference(newRef); err != nil {
return fmt.Errorf("failed to set branch reference: %w", err)
}

return nil
}

// findSessionIndex returns the index of an existing session with the given ID,
// or the next available index if not found. This prevents duplicate session entries.
func (s *GitStore) findSessionIndex(ctx context.Context, basePath string, existingSummary *CheckpointSummary, entries map[string]object.TreeEntry, sessionID string) int {
Expand Down Expand Up @@ -827,6 +908,49 @@ func (s *GitStore) ReadSessionContent(ctx context.Context, checkpointID id.Check
return result, nil
}

// ReadSessionMetadata reads only the metadata.json for a session (no transcript or prompts).
// This is much cheaper than ReadSessionContent for cases that only need metadata fields
// like InitialAttribution.
func (s *GitStore) ReadSessionMetadata(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*CommittedMetadata, error) {
if err := ctx.Err(); err != nil {
return nil, err //nolint:wrapcheck // Propagating context cancellation
}

ft, err := s.getFetchingTree(ctx)
if err != nil {
return nil, ErrCheckpointNotFound
}

checkpointPath := checkpointID.Path()
checkpointTree, err := ft.Tree(checkpointPath)
if err != nil {
return nil, ErrCheckpointNotFound
}

sessionDir := strconv.Itoa(sessionIndex)
sessionTree, err := checkpointTree.Tree(sessionDir)
if err != nil {
return nil, fmt.Errorf("session %d not found: %w", sessionIndex, err)
}

metadataFile, err := sessionTree.File(paths.MetadataFileName)
if err != nil {
return nil, fmt.Errorf("session %d metadata not found: %w", sessionIndex, err)
}

content, err := metadataFile.Contents()
if err != nil {
return nil, fmt.Errorf("failed to read session %d metadata: %w", sessionIndex, err)
}

var metadata CommittedMetadata
if err := json.Unmarshal([]byte(content), &metadata); err != nil {
return nil, fmt.Errorf("failed to parse session %d metadata: %w", sessionIndex, err)
}

return &metadata, nil
}

// ReadLatestSessionContent is a convenience method that reads the latest session's content.
// This is equivalent to ReadSessionContent(ctx, checkpointID, len(summary.Sessions)-1).
func (s *GitStore) ReadLatestSessionContent(ctx context.Context, checkpointID id.CheckpointID) (*SessionContent, error) {
Expand Down
1 change: 1 addition & 0 deletions cmd/entire/cli/checkpoint/v2_committed.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ func (s *V2GitStore) writeMainSessionToSubdirectory(opts WriteCommittedOptions,
TokenUsage: opts.TokenUsage,
SessionMetrics: opts.SessionMetrics,
InitialAttribution: opts.InitialAttribution,
PromptAttributions: opts.PromptAttributionsJSON,
Summary: redactSummary(opts.Summary),
CLIVersion: versioninfo.Version,
}
Expand Down
16 changes: 16 additions & 0 deletions cmd/entire/cli/strategy/manual_commit_condensation.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re
TokenUsage: sessionData.TokenUsage,
SessionMetrics: buildSessionMetrics(state),
InitialAttribution: attribution,
PromptAttributionsJSON: marshalPromptAttributions(state.PromptAttributions),
Summary: summary,
}

Expand All @@ -285,6 +286,21 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re
}, nil
}

// marshalPromptAttributions encodes PromptAttributions to JSON for diagnostic persistence.
// Returns nil if there are no attributions to persist or if marshaling fails (logged as warning).
func marshalPromptAttributions(pas []PromptAttribution) json.RawMessage {
if len(pas) == 0 {
return nil
}
data, err := json.Marshal(pas)
if err != nil {
slog.Warn("failed to marshal prompt attributions for diagnostic persistence",
slog.String("error", err.Error()))
return nil
}
return data
}

// buildSessionMetrics creates a SessionMetrics from session state if any metrics are available.
// Returns nil if no hook-provided metrics exist (e.g., for agents that don't report them).
func buildSessionMetrics(state *SessionState) *cpkg.SessionMetrics {
Expand Down
76 changes: 76 additions & 0 deletions cmd/entire/cli/strategy/manual_commit_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -904,6 +904,12 @@ func (s *ManualCommitStrategy) PostCommit(ctx context.Context) error { //nolint:
}
processSessionsLoop.End()

if err := s.updateCombinedAttributionForCheckpoint(ctx, repo, checkpointID); err != nil {
logging.Warn(logCtx, "failed to update combined checkpoint attribution",
slog.String("checkpoint_id", checkpointID.String()),
slog.String("error", err.Error()))
}

// Clean up shadow branches — only delete when ALL sessions on the branch are non-active
// or were condensed during this PostCommit.
_, cleanupBranchesSpan := perf.Start(ctx, "cleanup_shadow_branches")
Expand Down Expand Up @@ -934,6 +940,76 @@ func (s *ManualCommitStrategy) PostCommit(ctx context.Context) error { //nolint:
return nil
}

func (s *ManualCommitStrategy) updateCombinedAttributionForCheckpoint(ctx context.Context, repo *git.Repository, checkpointID id.CheckpointID) error {
store := checkpoint.NewGitStore(repo)

summary, err := store.ReadCommitted(ctx, checkpointID)
if err != nil {
return fmt.Errorf("reading checkpoint summary: %w", err)
}
if summary == nil || len(summary.Sessions) <= 1 {
return nil
}

combined := aggregateCheckpointAttribution()
for i := range len(summary.Sessions) {
metadata, readErr := store.ReadSessionMetadata(ctx, checkpointID, i)
if readErr != nil || metadata == nil || metadata.InitialAttribution == nil {
continue
}
combined.add(metadata.InitialAttribution)
}

if !combined.hasData() {
return nil
}

if err := store.UpdateCheckpointSummary(ctx, checkpointID, combined.snapshot()); err != nil {
return fmt.Errorf("updating combined attribution: %w", err)
}

return nil
}

type checkpointAttributionAggregate struct {
agentLines int
humanAdded int
humanModified int
humanRemoved int
totalCommitted int
}

func aggregateCheckpointAttribution() *checkpointAttributionAggregate {
return &checkpointAttributionAggregate{}
}

func (a *checkpointAttributionAggregate) add(attr *checkpoint.InitialAttribution) {
a.agentLines += attr.AgentLines
a.humanAdded += attr.HumanAdded
a.humanModified += attr.HumanModified
a.humanRemoved += attr.HumanRemoved
a.totalCommitted += attr.TotalCommitted
}

func (a *checkpointAttributionAggregate) hasData() bool {
return a.agentLines != 0 || a.humanAdded != 0 || a.humanModified != 0 || a.humanRemoved != 0 || a.totalCommitted != 0
}

func (a *checkpointAttributionAggregate) snapshot() *checkpoint.InitialAttribution {
attr := &checkpoint.InitialAttribution{
CalculatedAt: time.Now().UTC(),
AgentLines: a.agentLines,
HumanAdded: a.humanAdded,
HumanModified: a.humanModified,
HumanRemoved: a.humanRemoved,
TotalCommitted: a.totalCommitted,
}
if a.totalCommitted > 0 {
attr.AgentPercentage = float64(a.agentLines) / float64(a.totalCommitted) * 100
}
return attr
}

// postCommitProcessSession handles a single session within the PostCommit loop.
// Pre-resolved git objects (headTree, parentTree) are shared across all sessions;
// per-session shadow ref/tree are resolved once here and threaded through sub-calls.
Expand Down
Loading
Loading