Skip to content

Commit c9c1352

Browse files
committed
feat: persist task list
1 parent dcda781 commit c9c1352

4 files changed

Lines changed: 197 additions & 12 deletions

File tree

internal/bootstrap/boot.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,14 +155,20 @@ func Boot(opts Options) (*Runtime, error) {
155155

156156
builtTools := buildTools(cwd, opts.ToolFactories)
157157
askTool := localtools.NewAskUser()
158-
_, taskTools := localtools.NewTaskTools()
158+
taskStore, taskTools := localtools.NewTaskTools()
159159
builtTools = append(builtTools,
160160
localtools.NewWebFetch(settings.SearchProvider, settings.SearchAPIKey),
161161
localtools.NewWebSearch(settings.SearchProvider, settings.SearchAPIKey),
162162
askTool,
163163
)
164164
builtTools = append(builtTools, taskTools...)
165165

166+
// Enable task persistence scoped to this session.
167+
taskDir := filepath.Join(config.TasksDir(cwd), store.Header().SessionID)
168+
if err := taskStore.SetDir(taskDir); err != nil {
169+
fmt.Fprintf(os.Stderr, "warning: task persistence: %v\n", err)
170+
}
171+
166172
// SubAgent tool: delegate tasks to isolated sub-agents (explore, plan, coder).
167173
subagentTool := buildSubAgentTool(subAgentDeps{
168174
Cwd: cwd,

internal/config/settings.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,11 @@ func PlansDir(cwd string) string {
172172
return filepath.Join(cwd, ConfigDir, "plans")
173173
}
174174

175+
// TasksDir returns <cwd>/.codebot/tasks/.
176+
func TasksDir(cwd string) string {
177+
return filepath.Join(cwd, ConfigDir, "tasks")
178+
}
179+
175180
// SkillsDir returns <cwd>/.codebot/skills/.
176181
func SkillsDir(cwd string) string {
177182
return filepath.Join(cwd, ConfigDir, "skills")

internal/tools/task.go

Lines changed: 181 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"encoding/json"
66
"fmt"
77
"maps"
8+
"os"
9+
"path/filepath"
810
"sort"
911
"strconv"
1012
"strings"
@@ -31,8 +33,8 @@ type Task struct {
3133
ActiveForm string `json:"activeForm,omitempty"`
3234
Status TaskStatus `json:"status"`
3335
Owner string `json:"owner,omitempty"`
34-
Blocks []string `json:"blocks,omitempty"`
35-
BlockedBy []string `json:"blockedBy,omitempty"`
36+
Blocks []string `json:"blocks"`
37+
BlockedBy []string `json:"blockedBy"`
3638
Metadata map[string]any `json:"metadata,omitempty"`
3739
}
3840

@@ -48,12 +50,13 @@ type TaskSnapshot struct {
4850
// TaskNotifyFn is called after each store mutation with the latest snapshot.
4951
type TaskNotifyFn func(TaskSnapshot)
5052

51-
// TaskStore is an in-memory, thread-safe task store with auto-increment IDs.
53+
// TaskStore is a thread-safe task store with optional file persistence.
5254
type TaskStore struct {
5355
mu sync.RWMutex
5456
tasks map[string]*Task
5557
nextID int
5658
notifyFn TaskNotifyFn
59+
dir string // persistence directory; empty = in-memory only
5760
}
5861

5962
// NewTaskStore creates an empty store.
@@ -82,13 +85,21 @@ func (s *TaskStore) Create(subject, description, activeForm string, metadata map
8285
Description: description,
8386
ActiveForm: activeForm,
8487
Status: TaskPending,
88+
Blocks: []string{},
89+
BlockedBy: []string{},
8590
Metadata: metadata,
8691
}
8792
s.tasks[id] = t
88-
snap := s.snapshot()
93+
cp := copyTask(t)
94+
dir := s.dir
95+
hwm := s.nextID - 1
8996
s.mu.Unlock()
90-
s.notify(snap)
91-
return copyTask(t)
97+
if dir != "" {
98+
s.persist(cp)
99+
s.writeHighWaterMark(hwm)
100+
}
101+
s.notify()
102+
return cp
92103
}
93104

94105
// Get returns a copy of the task or false if not found.
@@ -126,9 +137,9 @@ func (s *TaskStore) Update(id string, opts UpdateOpts) (*Task, error) {
126137
if opts.Status != nil {
127138
if *opts.Status == "deleted" {
128139
delete(s.tasks, id)
129-
snap := s.snapshot()
130140
s.mu.Unlock()
131-
s.notify(snap)
141+
s.removeFile(id)
142+
s.notify()
132143
return nil, nil
133144
}
134145
t.Status = *opts.Status
@@ -159,11 +170,13 @@ func (s *TaskStore) Update(id string, opts UpdateOpts) (*Task, error) {
159170
}
160171
// Dependency tracking: bidirectional, matching Claude Code's addDependency().
161172
// addBlocks: this task blocks others → add id to each target's blockedBy.
173+
var touched []*Task // tasks modified by dependency updates
162174
if len(opts.AddBlocks) > 0 {
163175
t.Blocks = appendUnique(t.Blocks, opts.AddBlocks...)
164176
for _, blockedID := range opts.AddBlocks {
165177
if other, exists := s.tasks[blockedID]; exists {
166178
other.BlockedBy = appendUnique(other.BlockedBy, id)
179+
touched = append(touched, other)
167180
}
168181
}
169182
}
@@ -173,14 +186,29 @@ func (s *TaskStore) Update(id string, opts UpdateOpts) (*Task, error) {
173186
for _, blockerID := range opts.AddBlockedBy {
174187
if other, exists := s.tasks[blockerID]; exists {
175188
other.Blocks = appendUnique(other.Blocks, id)
189+
touched = append(touched, other)
176190
}
177191
}
178192
}
179193

180194
cp := copyTask(t)
181-
snap := s.snapshot()
195+
var touchedCopies []*Task
196+
for _, other := range touched {
197+
touchedCopies = append(touchedCopies, copyTask(other))
198+
}
199+
200+
// If all tasks are now completed, clean up.
201+
allDone := s.allCompleted()
202+
182203
s.mu.Unlock()
183-
s.notify(snap)
204+
s.persist(cp)
205+
for _, tc := range touchedCopies {
206+
s.persist(tc)
207+
}
208+
if allDone {
209+
s.clearAll()
210+
}
211+
s.notify()
184212
return cp, nil
185213
}
186214

@@ -225,9 +253,10 @@ func (s *TaskStore) snapshot() TaskSnapshot {
225253
return snap
226254
}
227255

228-
func (s *TaskStore) notify(snap TaskSnapshot) {
256+
func (s *TaskStore) notify() {
229257
s.mu.RLock()
230258
fn := s.notifyFn
259+
snap := s.snapshot()
231260
s.mu.RUnlock()
232261
if fn != nil {
233262
fn(snap)
@@ -268,6 +297,144 @@ func appendUnique(base []string, vals ...string) []string {
268297
return base
269298
}
270299

300+
// ---------------------------------------------------------------------------
301+
// Persistence helpers
302+
// ---------------------------------------------------------------------------
303+
304+
const highWaterMarkFile = ".highwatermark"
305+
306+
// SetDir enables file persistence. It creates the directory if needed and
307+
// loads any existing tasks from disk. Call before the store is used.
308+
func (s *TaskStore) SetDir(dir string) error {
309+
if err := os.MkdirAll(dir, 0o755); err != nil {
310+
return fmt.Errorf("create task dir: %w", err)
311+
}
312+
s.mu.Lock()
313+
s.dir = dir
314+
s.mu.Unlock()
315+
return s.loadFromDir()
316+
}
317+
318+
// Snapshot returns the current read-only snapshot (public, for initial TUI state).
319+
func (s *TaskStore) Snapshot() TaskSnapshot {
320+
s.mu.RLock()
321+
defer s.mu.RUnlock()
322+
return s.snapshot()
323+
}
324+
325+
// loadFromDir reads all {id}.json files and .highwatermark from s.dir.
326+
func (s *TaskStore) loadFromDir() error {
327+
entries, err := os.ReadDir(s.dir)
328+
if err != nil {
329+
return fmt.Errorf("read task dir: %w", err)
330+
}
331+
332+
s.mu.Lock()
333+
defer s.mu.Unlock()
334+
335+
hwm := s.readHighWaterMark()
336+
maxID := hwm
337+
338+
for _, entry := range entries {
339+
name := entry.Name()
340+
if !strings.HasSuffix(name, ".json") {
341+
continue
342+
}
343+
idStr := strings.TrimSuffix(name, ".json")
344+
id, err := strconv.Atoi(idStr)
345+
if err != nil {
346+
continue // skip non-numeric files
347+
}
348+
349+
data, err := os.ReadFile(filepath.Join(s.dir, name))
350+
if err != nil {
351+
fmt.Fprintf(os.Stderr, "warning: read task %s: %v\n", name, err)
352+
continue
353+
}
354+
var t Task
355+
if err := json.Unmarshal(data, &t); err != nil {
356+
fmt.Fprintf(os.Stderr, "warning: parse task %s: %v\n", name, err)
357+
continue
358+
}
359+
s.tasks[t.ID] = &t
360+
if id > maxID {
361+
maxID = id
362+
}
363+
}
364+
s.nextID = maxID + 1
365+
return nil
366+
}
367+
368+
// persist writes a single task to {dir}/{id}.json.
369+
func (s *TaskStore) persist(t *Task) {
370+
if s.dir == "" {
371+
return
372+
}
373+
data, err := json.MarshalIndent(t, "", " ")
374+
if err != nil {
375+
fmt.Fprintf(os.Stderr, "warning: marshal task %s: %v\n", t.ID, err)
376+
return
377+
}
378+
path := filepath.Join(s.dir, t.ID+".json")
379+
if err := os.WriteFile(path, data, 0o644); err != nil {
380+
fmt.Fprintf(os.Stderr, "warning: write task %s: %v\n", t.ID, err)
381+
}
382+
}
383+
384+
// removeFile deletes {dir}/{id}.json.
385+
func (s *TaskStore) removeFile(id string) {
386+
if s.dir == "" {
387+
return
388+
}
389+
_ = os.Remove(filepath.Join(s.dir, id+".json"))
390+
}
391+
392+
// readHighWaterMark reads the .highwatermark file (must be called with mu held).
393+
func (s *TaskStore) readHighWaterMark() int {
394+
data, err := os.ReadFile(filepath.Join(s.dir, highWaterMarkFile))
395+
if err != nil {
396+
return 0
397+
}
398+
n, _ := strconv.Atoi(strings.TrimSpace(string(data)))
399+
return n
400+
}
401+
402+
// writeHighWaterMark writes the .highwatermark file.
403+
func (s *TaskStore) writeHighWaterMark(id int) {
404+
if s.dir == "" {
405+
return
406+
}
407+
_ = os.WriteFile(
408+
filepath.Join(s.dir, highWaterMarkFile),
409+
[]byte(strconv.Itoa(id)),
410+
0o644,
411+
)
412+
}
413+
414+
// allCompleted reports whether all tasks are completed (must be called with mu held).
415+
func (s *TaskStore) allCompleted() bool {
416+
if len(s.tasks) == 0 {
417+
return false
418+
}
419+
for _, t := range s.tasks {
420+
if t.Status != TaskCompleted {
421+
return false
422+
}
423+
}
424+
return true
425+
}
426+
427+
// clearAll removes all tasks from memory and deletes the task directory.
428+
func (s *TaskStore) clearAll() {
429+
s.mu.Lock()
430+
clear(s.tasks)
431+
dir := s.dir
432+
s.mu.Unlock()
433+
if dir != "" {
434+
_ = os.RemoveAll(dir)
435+
}
436+
}
437+
271438
// ---------------------------------------------------------------------------
272439
// TaskCreateTool
273440
// ---------------------------------------------------------------------------
@@ -293,6 +460,9 @@ func (t *TaskCreateTool) SetNotifyFn(fn TaskNotifyFn) {
293460
t.store.SetNotifyFn(fn)
294461
}
295462

463+
// Store returns the underlying TaskStore (used for persistence wiring).
464+
func (t *TaskCreateTool) Store() *TaskStore { return t.store }
465+
296466
type taskCreateArgs struct {
297467
Subject string `json:"subject"`
298468
Description string `json:"description"`

internal/ui/tui_mode.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ func RunTUI(sess *agent.Session, cwd, gitBranch, modelName string, profile polic
5555
ct.SetNotifyFn(func(snap tools.TaskSnapshot) {
5656
p.Send(tui.TaskListUpdateMsg{Snapshot: snap})
5757
})
58+
// Send initial snapshot for resumed sessions with persisted tasks.
59+
if snap := ct.Store().Snapshot(); snap.Total > 0 {
60+
p.Send(tui.TaskListUpdateMsg{Snapshot: snap})
61+
}
5862
}
5963
}
6064

0 commit comments

Comments
 (0)