返回 DeepSeek-Reasonix
store.go
1 package autoresearch
2
3 import (
4 "bufio"
5 "crypto/rand"
6 "crypto/sha256"
7 "encoding/hex"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "hash/fnv"
12 "io/fs"
13 "os"
14 "path/filepath"
15 "regexp"
16 "sort"
17 "strings"
18 "sync"
19 "time"
20 "unicode"
21
22 fileencoding "reasonix/internal/fileutil/encoding"
23 )
24
25 var safeTaskID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
26 var explicitTaskPath = regexp.MustCompile(`\.reasonix/autoresearch/([A-Za-z0-9][A-Za-z0-9._-]*)/?`)
27 var safeCreateToken = regexp.MustCompile(`^[a-f0-9]{32}$`)
28
29 // createTokenFile is written immediately after an atomic task-directory
30 // reservation so rollback can prove ownership before RemoveAll.
31 const createTokenFile = ".create_token"
32
33 type Store struct {
34 workspaceRoot string
35 root string
36 mu sync.Mutex
37 taskLocks map[string]*sync.Mutex
38 }
39
40 func NewStore(workspaceRoot string) *Store {
41 if resolved, err := filepath.EvalSymlinks(workspaceRoot); err == nil {
42 workspaceRoot = resolved
43 }
44 return &Store{
45 workspaceRoot: workspaceRoot,
46 root: filepath.Join(workspaceRoot, ".reasonix", "autoresearch"),
47 taskLocks: map[string]*sync.Mutex{},
48 }
49 }
50
51 func (s *Store) lockTask(taskID string) func() {
52 s.mu.Lock()
53 lock := s.taskLocks[taskID]
54 if lock == nil {
55 lock = &sync.Mutex{}
56 s.taskLocks[taskID] = lock
57 }
58 s.mu.Unlock()
59 lock.Lock()
60 return lock.Unlock
61 }
62
63 func (s *Store) CreateTask(goal string, opts CreateOptions) (*Task, error) {
64 goal = strings.TrimSpace(goal)
65 if goal == "" {
66 return nil, errors.New("autoresearch: goal is required")
67 }
68 now := time.Now().UTC()
69 if opts.Now != nil {
70 now = opts.Now().UTC()
71 }
72 id, createToken, err := s.reserveTaskID(now, goal, opts.CreateToken)
73 if err != nil {
74 return nil, err
75 }
76 storeRoot, err := os.OpenRoot(s.root)
77 if err != nil {
78 _ = s.RemoveTask(id, createToken)
79 return nil, fmt.Errorf("autoresearch: open root dir: %w", err)
80 }
81 defer storeRoot.Close()
82 taskRel, err := s.taskRel(id)
83 if err != nil {
84 _ = s.RemoveTask(id, createToken)
85 return nil, err
86 }
87
88 cleanup := func() {
89 _ = s.RemoveTask(id, createToken)
90 }
91
92 if err := storeRoot.MkdirAll(filepath.Join(taskRel, "state"), 0o755); err != nil {
93 cleanup()
94 return nil, fmt.Errorf("autoresearch: create state dir: %w", err)
95 }
96 if err := storeRoot.MkdirAll(filepath.Join(taskRel, "logs"), 0o755); err != nil {
97 cleanup()
98 return nil, fmt.Errorf("autoresearch: create logs dir: %w", err)
99 }
100
101 spec := TaskSpec{
102 TaskID: id,
103 Goal: goal,
104 Scope: append([]string(nil), opts.Scope...),
105 NonGoals: append([]string(nil), opts.NonGoals...),
106 AllowedOperations: opts.AllowedOperations,
107 SuccessCriteria: cloneCriteria(opts.SuccessCriteria),
108 }
109 progress := Progress{
110 Status: StatusRunning,
111 UpdatedAt: now,
112 }
113
114 if err := writeJSONFile(storeRoot, filepath.Join(taskRel, "state", "task_spec.json"), spec); err != nil {
115 cleanup()
116 return nil, err
117 }
118 if err := writeJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), progress); err != nil {
119 cleanup()
120 return nil, err
121 }
122 for _, path := range []string{
123 filepath.Join(taskRel, "state", "directions_tried.json"),
124 filepath.Join(taskRel, "state", "findings.jsonl"),
125 filepath.Join(taskRel, "state", "iteration_log.jsonl"),
126 filepath.Join(taskRel, "logs", "heartbeat.jsonl"),
127 } {
128 if err := storeRoot.WriteFile(path, nil, 0o644); err != nil {
129 cleanup()
130 return nil, fmt.Errorf("autoresearch: initialize %s: %w", path, err)
131 }
132 }
133 return &Task{ID: id, Root: s.taskRoot(id), Spec: spec, CreateToken: createToken}, nil
134 }
135
136 // RemoveTask deletes a task directory within the store only when createToken
137 // matches the ownership token written during reservation. It is intended for
138 // rolling back a task this process created as part of a larger transaction.
139 func (s *Store) RemoveTask(taskID, createToken string) error {
140 if err := validateTaskID(taskID); err != nil {
141 return err
142 }
143 createToken = strings.TrimSpace(createToken)
144 if createToken == "" {
145 return errors.New("autoresearch: create token is required to remove a task")
146 }
147 unlock := s.lockTask(taskID)
148 defer unlock()
149 storeRoot, err := os.OpenRoot(s.root)
150 if err != nil {
151 if os.IsNotExist(err) {
152 return nil
153 }
154 return fmt.Errorf("autoresearch: open root dir: %w", err)
155 }
156 defer storeRoot.Close()
157 taskRel, err := s.taskRel(taskID)
158 if err != nil {
159 return err
160 }
161 tokenPath := filepath.Join(taskRel, createTokenFile)
162 stored, err := storeRoot.ReadFile(tokenPath)
163 if err != nil {
164 if os.IsNotExist(err) {
165 return fmt.Errorf("autoresearch: refuse to remove task %s without matching create token", taskID)
166 }
167 return fmt.Errorf("autoresearch: read create token for %s: %w", taskID, err)
168 }
169 if strings.TrimSpace(string(stored)) != createToken {
170 return fmt.Errorf("autoresearch: refuse to remove task %s: create token mismatch", taskID)
171 }
172 if err := storeRoot.RemoveAll(taskRel); err != nil && !os.IsNotExist(err) {
173 return fmt.Errorf("autoresearch: remove task %s: %w", taskID, err)
174 }
175 return nil
176 }
177
178 // RemoveTaskByCreateToken removes the unique task owned by createToken. Parent
179 // transactions use it after a crash, when the token was durable before task
180 // creation but the task ID may not have been returned to the caller.
181 func (s *Store) RemoveTaskByCreateToken(createToken string) error {
182 createToken = strings.TrimSpace(createToken)
183 if err := validateCreateToken(createToken); err != nil {
184 return err
185 }
186 storeRoot, err := os.OpenRoot(s.root)
187 if err != nil {
188 if os.IsNotExist(err) {
189 return nil
190 }
191 return fmt.Errorf("autoresearch: open root dir: %w", err)
192 }
193 defer storeRoot.Close()
194 entries, err := fs.ReadDir(storeRoot.FS(), ".")
195 if err != nil {
196 return fmt.Errorf("autoresearch: list tasks for create token: %w", err)
197 }
198 matches := make([]string, 0, 1)
199 marker := createTokenTaskIDMarker(createToken)
200 for _, entry := range entries {
201 if !entry.IsDir() {
202 continue
203 }
204 taskID := entry.Name()
205 if validateTaskID(taskID) != nil {
206 continue
207 }
208 if strings.Contains(taskID, marker) {
209 // Transaction-owned task IDs carry a hash of the token so recovery
210 // still owns a directory if the process died between Mkdir and the
211 // create-token file write.
212 matches = append(matches, taskID)
213 }
214 }
215 if len(matches) > 1 {
216 return fmt.Errorf("autoresearch: create token unexpectedly owns %d tasks", len(matches))
217 }
218 if len(matches) == 0 {
219 return nil
220 }
221 unlock := s.lockTask(matches[0])
222 defer unlock()
223 if err := storeRoot.RemoveAll(matches[0]); err != nil && !os.IsNotExist(err) {
224 return fmt.Errorf("autoresearch: remove transaction-owned task %s: %w", matches[0], err)
225 }
226 return nil
227 }
228
229 func (s *Store) ListSummaries() ([]Summary, error) {
230 entries, err := os.ReadDir(s.root)
231 if err != nil {
232 if os.IsNotExist(err) {
233 return []Summary{}, nil
234 }
235 return nil, fmt.Errorf("autoresearch: list tasks: %w", err)
236 }
237 ids := make([]string, 0, len(entries))
238 for _, entry := range entries {
239 if !entry.IsDir() {
240 continue
241 }
242 id := entry.Name()
243 if validateTaskID(id) != nil {
244 continue
245 }
246 ids = append(ids, id)
247 }
248 sort.Sort(sort.Reverse(sort.StringSlice(ids)))
249 out := make([]Summary, 0, len(ids))
250 for _, id := range ids {
251 summary, err := s.Summary(id)
252 if err != nil {
253 return nil, err
254 }
255 out = append(out, *summary)
256 }
257 return out, nil
258 }
259
260 func (s *Store) LoadTask(taskID string) (*Task, error) {
261 storeRoot, taskRel, err := s.openTaskRoot(taskID)
262 if err != nil {
263 return nil, err
264 }
265 defer storeRoot.Close()
266 info, err := storeRoot.Lstat(taskRel)
267 if err != nil {
268 if os.IsNotExist(err) {
269 return nil, fmt.Errorf("autoresearch: task %s not found", taskID)
270 }
271 return nil, fmt.Errorf("autoresearch: stat task %s: %w", taskID, err)
272 }
273 if info.Mode()&os.ModeSymlink != 0 {
274 return nil, fmt.Errorf("autoresearch: task %s is a symlink", taskID)
275 }
276 if !info.IsDir() {
277 return nil, fmt.Errorf("autoresearch: task %s is not a directory", taskID)
278 }
279 var spec TaskSpec
280 if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "task_spec.json"), &spec); err != nil {
281 return nil, err
282 }
283 return &Task{ID: taskID, Root: s.taskRoot(taskID), Spec: spec}, nil
284 }
285
286 func (s *Store) ResumeFromGoalText(goal string) (*Task, bool, error) {
287 match := explicitTaskPath.FindStringSubmatch(goal)
288 if len(match) < 2 {
289 return nil, false, nil
290 }
291 task, err := s.LoadTask(match[1])
292 if err != nil {
293 return nil, true, err
294 }
295 if report, err := s.ValidateTask(task.ID); err != nil {
296 return nil, true, err
297 } else if !report.Valid {
298 return nil, true, fmt.Errorf("autoresearch: task %s is invalid: %v", task.ID, report.Errors)
299 }
300 return task, true, nil
301 }
302
303 func (s *Store) AppendFinding(taskID string, f Finding) error {
304 if err := validateTaskID(taskID); err != nil {
305 return err
306 }
307 unlock := s.lockTask(taskID)
308 defer unlock()
309 storeRoot, taskRel, err := s.openTaskRoot(taskID)
310 if err != nil {
311 return err
312 }
313 defer storeRoot.Close()
314 if err := validateFinding(f); err != nil {
315 return err
316 }
317 data, err := json.Marshal(f)
318 if err != nil {
319 return fmt.Errorf("autoresearch: marshal finding: %w", err)
320 }
321 return appendJSONL(storeRoot, filepath.Join(taskRel, "state", "findings.jsonl"), data)
322 }
323
324 func (s *Store) RecordEvidence(taskID, criterionID string, f Finding) error {
325 if err := validateTaskID(taskID); err != nil {
326 return err
327 }
328 criterionID = strings.TrimSpace(criterionID)
329 if criterionID == "" {
330 return errors.New("autoresearch: criterion id is required")
331 }
332 unlock := s.lockTask(taskID)
333 defer unlock()
334 storeRoot, taskRel, err := s.openTaskRoot(taskID)
335 if err != nil {
336 return err
337 }
338 defer storeRoot.Close()
339 if err := validateFinding(f); err != nil {
340 return err
341 }
342 specPath := filepath.Join(taskRel, "state", "task_spec.json")
343 var spec TaskSpec
344 if err := readJSONFile(storeRoot, specPath, &spec); err != nil {
345 return err
346 }
347 found := false
348 for i := range spec.SuccessCriteria {
349 if spec.SuccessCriteria[i].ID != criterionID {
350 continue
351 }
352 found = true
353 if !stringSliceContains(spec.SuccessCriteria[i].EvidenceIDs, f.ID) {
354 spec.SuccessCriteria[i].EvidenceIDs = append(spec.SuccessCriteria[i].EvidenceIDs, f.ID)
355 }
356 break
357 }
358 if !found {
359 return fmt.Errorf("autoresearch: criterion %q not found", criterionID)
360 }
361 if err := writeJSONFile(storeRoot, specPath, spec); err != nil {
362 return err
363 }
364 data, err := json.Marshal(f)
365 if err != nil {
366 return fmt.Errorf("autoresearch: marshal finding: %w", err)
367 }
368 return appendJSONL(storeRoot, filepath.Join(taskRel, "state", "findings.jsonl"), data)
369 }
370
371 func (s *Store) Findings(taskID string, limit int) ([]Finding, error) {
372 storeRoot, taskRel, err := s.openTaskRoot(taskID)
373 if err != nil {
374 return nil, err
375 }
376 defer storeRoot.Close()
377 path := filepath.Join(taskRel, "state", "findings.jsonl")
378 // Bounded requests (the newest-N views) read only the file tail; limit 0
379 // keeps the full scan because accepted-evidence lookups need every entry.
380 lines, err := tailJSONLLines(storeRoot, path, limit)
381 if err != nil {
382 return nil, err
383 }
384 var findings []Finding
385 for _, line := range lines {
386 var f Finding
387 if err := json.Unmarshal(fileencoding.DecodeToUTF8(line), &f); err != nil {
388 return nil, fmt.Errorf("autoresearch: parse %s: %w", path, err)
389 }
390 findings = append(findings, f)
391 }
392 for i, j := 0, len(findings)-1; i < j; i, j = i+1, j-1 {
393 findings[i], findings[j] = findings[j], findings[i]
394 }
395 if limit > 0 && len(findings) > limit {
396 findings = findings[:limit]
397 }
398 return findings, nil
399 }
400
401 func (s *Store) AppendHeartbeat(taskID string, h Heartbeat) error {
402 if err := validateTaskID(taskID); err != nil {
403 return err
404 }
405 unlock := s.lockTask(taskID)
406 defer unlock()
407 storeRoot, taskRel, err := s.openTaskRoot(taskID)
408 if err != nil {
409 return err
410 }
411 defer storeRoot.Close()
412 if err := validateHeartbeat(h); err != nil {
413 return err
414 }
415 data, err := json.Marshal(h)
416 if err != nil {
417 return fmt.Errorf("autoresearch: marshal heartbeat: %w", err)
418 }
419 return appendJSONL(storeRoot, filepath.Join(taskRel, "logs", "heartbeat.jsonl"), data)
420 }
421
422 func (s *Store) Heartbeats(taskID string, limit int) ([]Heartbeat, error) {
423 storeRoot, taskRel, err := s.openTaskRoot(taskID)
424 if err != nil {
425 return nil, err
426 }
427 defer storeRoot.Close()
428 path := filepath.Join(taskRel, "logs", "heartbeat.jsonl")
429 // Bounded requests only need the file tail; heartbeat logs grow one line
430 // per turn for the life of a task, so a full scan per read is a per-turn
431 // cost that keeps rising.
432 lines, err := tailJSONLLines(storeRoot, path, limit)
433 if err != nil {
434 return nil, err
435 }
436 var heartbeats []Heartbeat
437 for _, line := range lines {
438 var h Heartbeat
439 if err := json.Unmarshal(fileencoding.DecodeToUTF8(line), &h); err != nil {
440 return nil, fmt.Errorf("autoresearch: parse %s: %w", path, err)
441 }
442 heartbeats = append(heartbeats, h)
443 }
444 if limit > 0 && len(heartbeats) > limit {
445 heartbeats = heartbeats[len(heartbeats)-limit:]
446 }
447 return heartbeats, nil
448 }
449
450 func (s *Store) LastHeartbeat(taskID string) (Heartbeat, bool, error) {
451 heartbeats, err := s.Heartbeats(taskID, 1)
452 if err != nil {
453 return Heartbeat{}, false, err
454 }
455 if len(heartbeats) == 0 {
456 return Heartbeat{}, false, nil
457 }
458 return heartbeats[0], true, nil
459 }
460
461 func (s *Store) RecordDirection(taskID string, d Direction) (*Progress, error) {
462 if err := validateTaskID(taskID); err != nil {
463 return nil, err
464 }
465 unlock := s.lockTask(taskID)
466 defer unlock()
467 storeRoot, taskRel, err := s.openTaskRoot(taskID)
468 if err != nil {
469 return nil, err
470 }
471 defer storeRoot.Close()
472 d.Summary = strings.TrimSpace(d.Summary)
473 if d.Summary == "" {
474 return nil, errors.New("autoresearch: direction summary is required")
475 }
476 now := d.Now.UTC()
477 if now.IsZero() {
478 now = time.Now().UTC()
479 }
480 var progress Progress
481 if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), &progress); err != nil {
482 return nil, err
483 }
484 progress.Iteration++
485 progress.CurrentDirection = d.Summary
486 progress.UpdatedAt = now
487
488 directions, err := s.loadDirections(storeRoot, taskRel)
489 if err != nil {
490 return nil, err
491 }
492 fp := directionFingerprint(d.Summary)
493 repeated := false
494 for i := range directions {
495 // Legacy entries carry pre-hash fingerprints; recompute from the
496 // stored summary so repeats recorded by older versions still match,
497 // then migrate the entry in place.
498 if directions[i].Fingerprint != fp && directionFingerprint(directions[i].Summary) != fp {
499 continue
500 }
501 repeated = true
502 directions[i].Fingerprint = fp
503 directions[i].Count++
504 directions[i].LastSeenIteration = progress.Iteration
505 break
506 }
507 if !repeated {
508 directions = append(directions, DirectionTried{
509 Fingerprint: fp,
510 Summary: d.Summary,
511 FirstSeenIteration: progress.Iteration,
512 LastSeenIteration: progress.Iteration,
513 Count: 1,
514 })
515 }
516 if repeated || len(d.AcceptedEvidenceIDs) == 0 {
517 before := progress.StaleCount
518 progress.StaleCount++
519 if before < 2 && progress.StaleCount >= 2 {
520 progress.PivotCount++
521 }
522 } else {
523 progress.StaleCount = 0
524 }
525 if err := writeJSONFile(storeRoot, filepath.Join(taskRel, "state", "directions_tried.json"), directions); err != nil {
526 return nil, err
527 }
528 if err := writeJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), progress); err != nil {
529 return nil, err
530 }
531 return &progress, nil
532 }
533
534 func (s *Store) UpdateProgress(taskID string, patch ProgressPatch) (*Progress, error) {
535 if err := validateTaskID(taskID); err != nil {
536 return nil, err
537 }
538 unlock := s.lockTask(taskID)
539 defer unlock()
540 storeRoot, taskRel, err := s.openTaskRoot(taskID)
541 if err != nil {
542 return nil, err
543 }
544 defer storeRoot.Close()
545 var progress Progress
546 if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), &progress); err != nil {
547 return nil, err
548 }
549 if patch.Status != nil {
550 progress.Status = strings.TrimSpace(*patch.Status)
551 }
552 if patch.CurrentDirection != nil {
553 progress.CurrentDirection = strings.TrimSpace(*patch.CurrentDirection)
554 }
555 if patch.BlockedReason != nil {
556 progress.BlockedReason = strings.TrimSpace(*patch.BlockedReason)
557 }
558 progress.UpdatedAt = time.Now().UTC()
559 report := &ValidationReport{Valid: true}
560 validateProgress(report, progress)
561 if len(report.Errors) > 0 {
562 return nil, fmt.Errorf("autoresearch: invalid progress patch: %v", report.Errors)
563 }
564 if err := writeJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), progress); err != nil {
565 return nil, err
566 }
567 return &progress, nil
568 }
569
570 func (s *Store) ValidateTask(taskID string) (*ValidationReport, error) {
571 storeRoot, taskRel, err := s.openTaskRoot(taskID)
572 if err != nil {
573 return nil, err
574 }
575 defer storeRoot.Close()
576 report := &ValidationReport{Valid: true}
577 info, err := storeRoot.Lstat(taskRel)
578 if err != nil {
579 report.add("task", "", err.Error())
580 report.Valid = false
581 return report, nil
582 }
583 if info.Mode()&os.ModeSymlink != 0 {
584 report.add("task", "", "task directory must not be a symlink")
585 report.Valid = false
586 return report, nil
587 }
588 if !info.IsDir() {
589 report.add("task", "", "task path is not a directory")
590 report.Valid = false
591 return report, nil
592 }
593 var spec TaskSpec
594 if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "task_spec.json"), &spec); err != nil {
595 report.add("task_spec.json", "", err.Error())
596 } else {
597 validateTaskSpec(report, taskID, spec)
598 }
599 var progress Progress
600 if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), &progress); err != nil {
601 report.add("progress.json", "", err.Error())
602 } else {
603 validateProgress(report, progress)
604 }
605 for _, rel := range []string{
606 "state/directions_tried.json",
607 "state/findings.jsonl",
608 "state/iteration_log.jsonl",
609 "logs/heartbeat.jsonl",
610 } {
611 if _, err := storeRoot.Stat(filepath.Join(taskRel, rel)); err != nil {
612 report.add(filepath.Base(rel), "", err.Error())
613 }
614 }
615 report.Valid = len(report.Errors) == 0
616 return report, nil
617 }
618
619 func (s *Store) taskRoot(taskID string) string {
620 return filepath.Join(s.root, taskID)
621 }
622
623 func (s *Store) taskRel(taskID string, parts ...string) (string, error) {
624 if err := validateTaskID(taskID); err != nil {
625 return "", err
626 }
627 all := append([]string{taskID}, parts...)
628 rel := filepath.Join(all...)
629 if !filepath.IsLocal(rel) {
630 return "", fmt.Errorf("autoresearch: unsafe task-relative path %q", rel)
631 }
632 return rel, nil
633 }
634
635 func (s *Store) openTaskRoot(taskID string) (*os.Root, string, error) {
636 taskRel, err := s.taskRel(taskID)
637 if err != nil {
638 return nil, "", err
639 }
640 storeRoot, err := os.OpenRoot(s.root)
641 if err != nil {
642 if os.IsNotExist(err) {
643 return nil, "", fmt.Errorf("autoresearch: task %s not found", taskID)
644 }
645 return nil, "", fmt.Errorf("autoresearch: open root dir: %w", err)
646 }
647 return storeRoot, taskRel, nil
648 }
649
650 // reserveTaskID atomically claims a task directory with non-recursive Mkdir
651 // and writes a create-token ownership marker. Concurrent creators sharing the
652 // same workspace therefore never adopt the same ID: EEXIST advances the
653 // candidate, and only the Mkdir winner may later roll the directory back.
654 func (s *Store) reserveTaskID(now time.Time, goal, requestedCreateToken string) (id, createToken string, err error) {
655 if err := os.MkdirAll(s.root, 0o755); err != nil {
656 return "", "", fmt.Errorf("autoresearch: create root dir: %w", err)
657 }
658 storeRoot, err := os.OpenRoot(s.root)
659 if err != nil {
660 return "", "", fmt.Errorf("autoresearch: open root dir: %w", err)
661 }
662 defer storeRoot.Close()
663 token := strings.TrimSpace(requestedCreateToken)
664 callerSuppliedToken := token != ""
665 if token == "" {
666 token, err = newCreateToken()
667 if err != nil {
668 return "", "", err
669 }
670 } else if err := validateCreateToken(token); err != nil {
671 return "", "", err
672 }
673 base := now.Format("20060102-150405") + "-" + slugify(goal)
674 if base == now.Format("20060102-150405")+"-" {
675 base += "task"
676 }
677 if callerSuppliedToken {
678 base += createTokenTaskIDMarker(token)
679 }
680 id = base
681 for i := 2; ; i++ {
682 taskRel, err := s.taskRel(id)
683 if err != nil {
684 return "", "", err
685 }
686 if err := storeRoot.Mkdir(taskRel, 0o755); err != nil {
687 if os.IsExist(err) {
688 id = fmt.Sprintf("%s-%d", base, i)
689 continue
690 }
691 return "", "", fmt.Errorf("autoresearch: reserve task id %s: %w", id, err)
692 }
693 tokenPath := filepath.Join(taskRel, createTokenFile)
694 if err := storeRoot.WriteFile(tokenPath, []byte(token+"\n"), 0o600); err != nil {
695 _ = storeRoot.RemoveAll(taskRel)
696 return "", "", fmt.Errorf("autoresearch: write create token for %s: %w", id, err)
697 }
698 return id, token, nil
699 }
700 }
701
702 func newCreateToken() (string, error) {
703 var buf [16]byte
704 if _, err := rand.Read(buf[:]); err != nil {
705 return "", fmt.Errorf("autoresearch: generate create token: %w", err)
706 }
707 return hex.EncodeToString(buf[:]), nil
708 }
709
710 func validateCreateToken(token string) error {
711 if !safeCreateToken.MatchString(token) {
712 return errors.New("autoresearch: create token must be 32 lowercase hexadecimal characters")
713 }
714 return nil
715 }
716
717 func createTokenTaskIDMarker(token string) string {
718 sum := sha256.Sum256([]byte(token))
719 return "-txn-" + hex.EncodeToString(sum[:16])
720 }
721
722 func validateTaskID(id string) error {
723 id = strings.TrimSpace(id)
724 if id == "" {
725 return errors.New("autoresearch: task id is required")
726 }
727 if !safeTaskID.MatchString(id) || strings.Contains(id, "..") || strings.ContainsAny(id, `/\`) {
728 return fmt.Errorf("autoresearch: unsafe task id %q", id)
729 }
730 return nil
731 }
732
733 func writeJSONFile(root *os.Root, path string, v any) error {
734 data, err := json.MarshalIndent(v, "", " ")
735 if err != nil {
736 return fmt.Errorf("autoresearch: marshal %s: %w", path, err)
737 }
738 data = append(data, '\n')
739 if err := root.WriteFile(path, data, 0o644); err != nil {
740 return fmt.Errorf("autoresearch: write %s: %w", path, err)
741 }
742 return nil
743 }
744
745 func readJSONFile(root *os.Root, path string, out any) error {
746 data, err := root.ReadFile(path)
747 if err != nil {
748 return fmt.Errorf("read %s: %w", path, err)
749 }
750 data = fileencoding.DecodeToUTF8(data)
751 if err := json.Unmarshal(data, out); err != nil {
752 return fmt.Errorf("parse %s: %w", path, err)
753 }
754 return nil
755 }
756
757 func appendJSONL(root *os.Root, path string, data []byte) error {
758 if err := root.MkdirAll(filepath.Dir(path), 0o755); err != nil {
759 return fmt.Errorf("autoresearch: create jsonl dir: %w", err)
760 }
761 f, err := root.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
762 if err != nil {
763 return fmt.Errorf("autoresearch: open %s: %w", path, err)
764 }
765 defer f.Close()
766 if _, err := f.Write(append(data, '\n')); err != nil {
767 return fmt.Errorf("autoresearch: append %s: %w", path, err)
768 }
769 return nil
770 }
771
772 func readJSONL(root *os.Root, path string, each func([]byte) error) error {
773 f, err := root.Open(path)
774 if err != nil {
775 return fmt.Errorf("autoresearch: open %s: %w", path, err)
776 }
777 defer f.Close()
778 scanner := bufio.NewScanner(f)
779 for scanner.Scan() {
780 line := strings.TrimSpace(scanner.Text())
781 if line == "" {
782 continue
783 }
784 if err := each([]byte(line)); err != nil {
785 return fmt.Errorf("autoresearch: parse %s: %w", path, err)
786 }
787 }
788 if err := scanner.Err(); err != nil {
789 return fmt.Errorf("autoresearch: scan %s: %w", path, err)
790 }
791 return nil
792 }
793
794 // tailJSONLLines returns the last limit non-empty lines of a JSONL file in
795 // file order, reading backward in fixed-size chunks so per-turn readers do not
796 // rescan an append-only log that grows for the life of a task. limit <= 0
797 // reads the whole file (legacy unbounded behavior).
798 func tailJSONLLines(root *os.Root, path string, limit int) ([][]byte, error) {
799 if limit <= 0 {
800 var lines [][]byte
801 if err := readJSONL(root, path, func(data []byte) error {
802 line := make([]byte, len(data))
803 copy(line, data)
804 lines = append(lines, line)
805 return nil
806 }); err != nil {
807 return nil, err
808 }
809 return lines, nil
810 }
811 f, err := root.Open(path)
812 if err != nil {
813 return nil, fmt.Errorf("autoresearch: open %s: %w", path, err)
814 }
815 defer f.Close()
816 info, err := f.Stat()
817 if err != nil {
818 return nil, fmt.Errorf("autoresearch: stat %s: %w", path, err)
819 }
820 const chunkSize = 64 * 1024
821 var (
822 buf []byte
823 off = info.Size()
824 )
825 for off > 0 {
826 readLen := int64(chunkSize)
827 if off < readLen {
828 readLen = off
829 }
830 off -= readLen
831 chunk := make([]byte, readLen)
832 if _, err := f.ReadAt(chunk, off); err != nil {
833 return nil, fmt.Errorf("autoresearch: read %s: %w", path, err)
834 }
835 buf = append(chunk, buf...)
836 // Stop once the buffered tail holds enough complete lines. Count
837 // newline-separated non-empty segments after the first newline (the
838 // first segment may be a partial line unless we reached offset 0).
839 if countCompleteTailLines(buf, off == 0) > limit {
840 break
841 }
842 }
843 segments := strings.Split(string(buf), "\n")
844 if off > 0 && len(segments) > 0 {
845 segments = segments[1:] // drop the leading partial line
846 }
847 var lines [][]byte
848 for _, seg := range segments {
849 seg = strings.TrimSpace(seg)
850 if seg == "" {
851 continue
852 }
853 lines = append(lines, []byte(seg))
854 }
855 if len(lines) > limit {
856 lines = lines[len(lines)-limit:]
857 }
858 return lines, nil
859 }
860
861 func countCompleteTailLines(buf []byte, atStart bool) int {
862 segments := strings.Split(string(buf), "\n")
863 if !atStart && len(segments) > 0 {
864 segments = segments[1:]
865 }
866 count := 0
867 for _, seg := range segments {
868 if strings.TrimSpace(seg) != "" {
869 count++
870 }
871 }
872 return count
873 }
874
875 func (s *Store) loadDirections(root *os.Root, taskRel string) ([]DirectionTried, error) {
876 path := filepath.Join(taskRel, "state", "directions_tried.json")
877 data, err := root.ReadFile(path)
878 if err != nil {
879 return nil, fmt.Errorf("read %s: %w", path, err)
880 }
881 data = fileencoding.DecodeToUTF8(data)
882 if strings.TrimSpace(string(data)) == "" {
883 return nil, nil
884 }
885 var directions []DirectionTried
886 if err := json.Unmarshal(data, &directions); err != nil {
887 return nil, fmt.Errorf("parse %s: %w", path, err)
888 }
889 return directions, nil
890 }
891
892 func validateFinding(f Finding) error {
893 if strings.TrimSpace(f.ID) == "" {
894 return errors.New("autoresearch: finding id is required")
895 }
896 switch f.Kind {
897 case FindingKindCommand, FindingKindFile, FindingKindTest, FindingKindBenchmark, FindingKindManual, FindingKindReview:
898 default:
899 return fmt.Errorf("autoresearch: finding kind %q is invalid", f.Kind)
900 }
901 if strings.TrimSpace(f.Summary) == "" {
902 return errors.New("autoresearch: finding summary is required")
903 }
904 if f.CreatedAt.IsZero() {
905 return errors.New("autoresearch: finding created_at is required")
906 }
907 return nil
908 }
909
910 func validateHeartbeat(h Heartbeat) error {
911 switch h.Status {
912 case HeartbeatStartingTurn, HeartbeatTurnDone, HeartbeatWarning:
913 default:
914 return fmt.Errorf("autoresearch: heartbeat status %q is invalid", h.Status)
915 }
916 if h.Iteration < 0 {
917 return errors.New("autoresearch: heartbeat iteration must not be negative")
918 }
919 if h.CreatedAt.IsZero() {
920 return errors.New("autoresearch: heartbeat created_at is required")
921 }
922 return nil
923 }
924
925 func stringSliceContains(values []string, want string) bool {
926 for _, value := range values {
927 if value == want {
928 return true
929 }
930 }
931 return false
932 }
933
934 func cloneCriteria(in []SuccessCriterion) []SuccessCriterion {
935 out := make([]SuccessCriterion, len(in))
936 for i, c := range in {
937 out[i] = c
938 out[i].EvidenceIDs = append([]string(nil), c.EvidenceIDs...)
939 }
940 return out
941 }
942
943 func slugify(s string) string {
944 s = strings.ToLower(strings.TrimSpace(s))
945 var b strings.Builder
946 lastDash := false
947 for _, r := range s {
948 switch {
949 case r <= unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)):
950 b.WriteRune(r)
951 lastDash = false
952 default:
953 if !lastDash && b.Len() > 0 {
954 b.WriteByte('-')
955 lastDash = true
956 }
957 }
958 }
959 slug := strings.Trim(b.String(), "-")
960 const maxSlugLen = 56
961 if len(slug) > maxSlugLen {
962 slug = strings.Trim(slug[:maxSlugLen], "-")
963 }
964 if slug == "" {
965 return "task"
966 }
967 return slug
968 }
969
970 // directionFingerprint identifies a direction for repeat detection. slugify
971 // alone truncates to 56 chars and drops non-ASCII runes, so two different
972 // directions sharing a long ASCII prefix (or differing only in CJK text)
973 // collapsed to one fingerprint and wrongly inflated StaleCount/PivotCount on
974 // long tasks. Append a hash of the full normalized text when slugify lost
975 // distinguishing content (truncation or non-ASCII letters/digits); keep the
976 // bare slug otherwise so fingerprints recorded by older versions still match,
977 // and punctuation-only differences stay fuzzy-matched as before.
978 func directionFingerprint(summary string) string {
979 slug := slugify(summary)
980 if slug == slugifyUnbounded(summary) && !containsNonASCIIWord(summary) {
981 return slug
982 }
983 normalized := strings.Join(strings.Fields(strings.ToLower(summary)), " ")
984 h := fnv.New32a()
985 _, _ = h.Write([]byte(normalized))
986 return fmt.Sprintf("%s-%08x", slug, h.Sum32())
987 }
988
989 // slugifyUnbounded matches slugify without the 56-char cap, used to detect
990 // whether truncation dropped content.
991 func slugifyUnbounded(s string) string {
992 s = strings.ToLower(strings.TrimSpace(s))
993 var b strings.Builder
994 lastDash := false
995 for _, r := range s {
996 switch {
997 case r <= unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)):
998 b.WriteRune(r)
999 lastDash = false
1000 default:
1001 if !lastDash && b.Len() > 0 {
1002 b.WriteByte('-')
1003 lastDash = true
1004 }
1005 }
1006 }
1007 slug := strings.Trim(b.String(), "-")
1008 if slug == "" {
1009 return "task"
1010 }
1011 return slug
1012 }
1013
1014 // containsNonASCIIWord reports whether s carries letters/digits that slugify
1015 // discards entirely (e.g. CJK), meaning distinct summaries could share a slug.
1016 func containsNonASCIIWord(s string) bool {
1017 for _, r := range s {
1018 if r > unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)) {
1019 return true
1020 }
1021 }
1022 return false
1023 }
1024
1024 lines GO