package main import ( "context" "encoding/json" "errors" "fmt" "io" "io/fs" "log/slog" "os" "path/filepath" "slices" "strings" "time" ) // rebuildCallLimit is the most model calls of a rebuild. A rebuild summarizes // the whole merged log, which can need more calls than a probe sample, and the // quota stop of the build run still guards the account. const rebuildCallLimit = 1500 // rebuild merges added messages with the log of agent, in date order, and // builds a new memory beside it. The caller holds the token lock. The model // uses the original agent prompt and cache key. A failed or stopped build // removes the new directory and leaves the original directory unchanged. // Success copies the other agent files, adds this run's usage, and swaps the // directories, with the original kept as a timestamped backup. Message IDs are // new positions in the merged log, but kind, text, and date stay exact. func rebuild(ctx context.Context, agent *Agent, added []DatedMessage, client *ModelClient, summarizer Summarizer, out io.Writer) (backup string, err error) { err = rebuildIdle(agent.dir) if err != nil { return "", fmt.Errorf("check rebuild work: %w", err) } old, err := rebuildReadLog(ctx, agent.dir) if err != nil { return "", fmt.Errorf("read original log: %w", err) } entries, err := rebuildMerge(old, added) if err != nil { return "", fmt.Errorf("merge rebuild messages: %w", err) } root, err := os.MkdirTemp(filepath.Dir(agent.dir), ".agent-rebuild-") if err != nil { return "", fmt.Errorf("create rebuild directory: %w", err) } defer func() { closeErr := os.RemoveAll(root) if closeErr != nil { err = errors.Join(err, fmt.Errorf("remove rebuild directory: %w", closeErr)) } }() memory, err := openMemory(ctx, root, time.Now, summarizer, slog.New(slog.DiscardHandler)) if err != nil { return "", fmt.Errorf("open rebuilt memory: %w", err) } client.LimitCalls(rebuildCallLimit) run := BuildRun{ memory: memory, client: client, usagePath: client.UsagePath(), } appended, stop := run.Run(ctx, entries) sizes, err := memory.buildModelLines() if stop == "" { err = errors.Join(err, memory.Settle(ctx), memory.rebuildComplete(), memory.Flush(ctx)) } err = errors.Join(err, memory.Close()) records, usageErr := buildReadUsage(client.UsagePath()) err = errors.Join(err, usageErr, buildWriteReport(out, appended, stop, records, sizes)) if stop != "" { err = errors.Join(err, fmt.Errorf("rebuild stopped: %s", stop)) } if err != nil { return "", fmt.Errorf("finish rebuilt memory: %w", err) } err = rebuildCopy(agent.dir, root) if err != nil { return "", fmt.Errorf("copy other agent files: %w", err) } usage, err := os.ReadFile(client.UsagePath()) if errors.Is(err, os.ErrNotExist) { err = nil } if err != nil { return "", fmt.Errorf("read rebuild usage: %w", err) } if len(usage) > 0 { err = rebuildAddUsage(filepath.Join(root, "usage.jsonl"), usage) if err != nil { return "", fmt.Errorf("append rebuild usage: %w", err) } } if ctx.Err() != nil { return "", ctx.Err() } backup = agent.dir + "-backup-" + time.Now().Format("20060102-150405.000000000") err = os.Rename(agent.dir, backup) if err != nil { return "", fmt.Errorf("back up agent: %w", err) } err = os.Rename(root, agent.dir) if err != nil { restoreErr := os.Rename(backup, agent.dir) if restoreErr != nil { err = errors.Join(err, fmt.Errorf("restore original agent: %w", restoreErr)) } return "", fmt.Errorf("install rebuilt agent: %w", err) } return backup, nil } // rebuildIdle refuses an agent with unfinished subagents, including ones that a // daemon restart must resume. A missing work file lists no work. func rebuildIdle(root string) error { data, err := os.ReadFile(filepath.Join(root, subagentWorkFileName)) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return fmt.Errorf("read running subagents: %w", err) } var work SubagentWorkFile err = json.Unmarshal(data, &work) if err != nil { return fmt.Errorf("decode running subagents: %w", err) } if len(work.Running) > 0 { return errors.New("rebuild refused: subagents have unfinished work") } return nil } // rebuildReadLog reads and validates the day files without a write or an index // repair. Even an incomplete final line is an error, not a repair. func rebuildReadLog(ctx context.Context, root string) ([]DatedMessage, error) { paths, err := filepath.Glob(filepath.Join(root, "main", "*.jsonl")) if err != nil { return nil, fmt.Errorf("list message files: %w", err) } slices.Sort(paths) var entries []DatedMessage for _, path := range paths { var day uint32 day, err = journalDayNumber(strings.TrimSuffix(filepath.Base(path), ".jsonl")) if err != nil { return nil, fmt.Errorf("parse message day: %w", err) } err = journalScanFile(ctx, path, day, 0, false, func(line []byte, _ Position) error { var message Message err = json.Unmarshal(line, &message) if err != nil { return fmt.Errorf("decode message: %w", err) } err = logValidateMessage(message) if err != nil { return fmt.Errorf("validate message: %w", err) } if message.ID != len(entries) { return fmt.Errorf("expected message %d, found %d", len(entries), message.ID) } entries = append(entries, DatedMessage{ Kind: message.Kind, Text: message.Text, Date: message.Date, }) return nil }) if err != nil { return nil, fmt.Errorf("read message file: %w", err) } } return entries, nil } // rebuildMerge preserves the input order for equal dates, with old messages // before added messages. Each date must be explicit and valid. func rebuildMerge(old, added []DatedMessage) ([]DatedMessage, error) { entries := append(slices.Clone(old), added...) dates := make(map[string]time.Time) for _, entry := range entries { err := logValidateMessage(Message{ Kind: entry.Kind, Text: entry.Text, Size: len(entry.Text), Date: entry.Date, }) if err != nil { return nil, fmt.Errorf("validate dated message: %w", err) } var date time.Time date, err = time.Parse(time.RFC3339Nano, entry.Date) if err != nil { return nil, fmt.Errorf("parse merge date: %w", err) } dates[entry.Date] = date } slices.SortStableFunc(entries, func(a, b DatedMessage) int { return dates[a.Date].Compare(dates[b.Date]) }) return entries, nil } // rebuildCopy copies the other agent directories and regular files. It excludes // the memory and its lock, and refuses all other file types. func rebuildCopy(source, target string) error { err := filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error { if err != nil { return fmt.Errorf("read agent entry: %w", err) } relative, err := filepath.Rel(source, path) if err != nil { return fmt.Errorf("find agent relative path: %w", err) } if relative == "." { return nil } switch relative { case "main", "tree", "index": return filepath.SkipDir case "lock", "view.json": return nil } destination := filepath.Join(target, relative) switch { case entry.IsDir(): err = os.Mkdir(destination, 0o700) if err != nil { return fmt.Errorf("create agent directory: %w", err) } case entry.Type().IsRegular(): var data []byte data, err = os.ReadFile(path) if err != nil { return fmt.Errorf("read agent file: %w", err) } err = os.WriteFile(destination, data, 0o600) if err != nil { return fmt.Errorf("copy agent file: %w", err) } default: return fmt.Errorf("copy agent: unsupported file type for %s", relative) } return nil }) if err != nil { return fmt.Errorf("copy agent files: %w", err) } return nil } // rebuildAddUsage keeps both the original usage and the calls of this run. func rebuildAddUsage(path string, data []byte) (err error) { file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { return fmt.Errorf("open rebuilt usage: %w", err) } defer func() { closeErr := file.Close() if closeErr != nil { err = errors.Join(err, fmt.Errorf("close rebuilt usage: %w", closeErr)) } }() _, err = file.Write(data) if err != nil { return fmt.Errorf("append rebuilt usage: %w", err) } return nil }