返回 DeepSeek-Reasonix
subagent_store.go
根目录 / internal / agent / subagent_store.go
1 package agent
2
3 import (
4 "crypto/rand"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "os"
11 "path/filepath"
12 "sort"
13 "strings"
14 "sync"
15 "time"
16
17 "reasonix/internal/fileutil"
18 fileencoding "reasonix/internal/fileutil/encoding"
19 "reasonix/internal/store"
20 "reasonix/internal/tool"
21 )
22
23 type SubagentStatus string
24
25 const (
26 SubagentRunning SubagentStatus = "running"
27 SubagentCompleted SubagentStatus = "completed"
28 SubagentFailed SubagentStatus = "failed"
29 SubagentInterrupted SubagentStatus = "interrupted"
30 )
31
32 // SubagentMeta is the sidecar for a persisted sub-agent transcript. It captures
33 // the execution identity that must stay stable for continuation/fork.
34 type SubagentMeta struct {
35 Ref string `json:"ref"`
36 CreatedAt time.Time `json:"createdAt"`
37 UpdatedAt time.Time `json:"updatedAt"`
38 Status SubagentStatus `json:"status"`
39 Kind string `json:"kind"` // task | skill
40 Name string `json:"name"`
41 WorkspaceRoot string `json:"workspaceRoot"`
42 ParentSession string `json:"parentSession,omitempty"`
43 ParentToolCallID string `json:"parentToolCallId,omitempty"`
44 ForkedFrom string `json:"forkedFrom,omitempty"`
45 SystemPromptHash string `json:"systemPromptHash"`
46 ToolScope []string `json:"toolScope"`
47 ToolSchemaHash string `json:"toolSchemaHash"`
48 Model string `json:"model"`
49 Effort string `json:"effort"`
50 }
51
52 // subagentMetaDecodeError distinguishes malformed metadata content from file
53 // I/O failures. Cleanup may safely skip one undecodable record, but storage
54 // errors must remain visible because they can affect every subagent record.
55 type subagentMetaDecodeError struct {
56 ref string
57 err error
58 }
59
60 func (e *subagentMetaDecodeError) Error() string {
61 return fmt.Sprintf("decode subagent metadata %q: %v", e.ref, e.err)
62 }
63
64 func (e *subagentMetaDecodeError) Unwrap() error { return e.err }
65
66 func isSubagentMetaDecodeError(err error) bool {
67 var decodeErr *subagentMetaDecodeError
68 return errors.As(err, &decodeErr)
69 }
70
71 // SubagentSpec describes the current invocation identity.
72 type SubagentSpec struct {
73 Kind string
74 Name string
75 WorkspaceRoot string
76 ParentSession string
77 ParentToolCallID string
78 SystemPrompt string
79 Registry *tool.Registry
80 Model string
81 Effort string
82 }
83
84 // SubagentRun is a prepared transcript run. Call Release exactly once.
85 type SubagentRun struct {
86 Ref string
87 Session *Session
88 Meta SubagentMeta
89 ForkedFrom string
90
91 store *SubagentStore
92 release func()
93 }
94
95 // SubagentArtifact is a persisted sub-agent transcript and metadata pair owned
96 // by a parent session. One file may be missing after a crash; lifecycle cleanup
97 // should operate on the paths that exist.
98 type SubagentArtifact struct {
99 Ref string
100 SessionPath string
101 MetaPath string
102 Meta SubagentMeta
103 }
104
105 func (r *SubagentRun) Release() {
106 if r != nil && r.release != nil {
107 r.release()
108 r.release = nil
109 }
110 }
111
112 // EphemeralSubagentRun is a non-persisted run for callers without an owning
113 // parent session — e.g. headless `reasonix run`, which never mints a session
114 // path. Its empty Ref makes the store's MarkRunning/SaveCompleted/SaveFailed
115 // methods no-op and keeps FormatSubagentResult from emitting a transcript
116 // reference, so the sub-agent behaves exactly as it did before persisted
117 // transcripts existed. It holds no lock, so Release is a no-op.
118 func EphemeralSubagentRun(systemPrompt string) *SubagentRun {
119 return &SubagentRun{Session: NewSession(systemPrompt)}
120 }
121
122 // SubagentStore persists sub-agent transcripts under config.SessionDir()/subagents.
123 // Its locks are process-local; cross-process mutation is intentionally out of v1.
124 type SubagentStore struct {
125 dir string
126 destroyed func(parentSession string) bool
127 parentSessionProbe func(sessionPath string) bool
128
129 // cleanupBeforeReread is a test seam for deterministic lease interleavings.
130 cleanupBeforeReread func(parentSession, ref string)
131
132 mu sync.Mutex
133 locked map[string]bool
134 }
135
136 func NewSubagentStore(dir string) *SubagentStore {
137 if strings.TrimSpace(dir) == "" {
138 return nil
139 }
140 return &SubagentStore{dir: dir, locked: map[string]bool{}}
141 }
142
143 // WithDestroyedChecker makes saves for destroyed parent sessions no-op. This is
144 // used when a background sub-agent is cancelled because its parent session was
145 // cleared or moved out of active history.
146 func (s *SubagentStore) WithDestroyedChecker(fn func(parentSession string) bool) *SubagentStore {
147 if s != nil {
148 s.destroyed = fn
149 }
150 return s
151 }
152
153 // WithParentSessionProbe installs a process-local liveness check used before
154 // stale cleanup probes a parent transcript lease. Desktop supplies this for
155 // tabs and builds that are live before their durable lease is bound. A nil
156 // probe preserves the lease-only behavior used by CLI and server frontends.
157 func (s *SubagentStore) WithParentSessionProbe(fn func(sessionPath string) bool) *SubagentStore {
158 if s != nil {
159 s.parentSessionProbe = fn
160 }
161 return s
162 }
163
164 // ListSubagentsByParent returns persisted sub-agent artifacts whose metadata
165 // declares the given parent session owner.
166 func ListSubagentsByParent(sessionDir, parentSession string) ([]SubagentArtifact, error) {
167 parentSession = strings.TrimSpace(parentSession)
168 if strings.TrimSpace(sessionDir) == "" || parentSession == "" {
169 return nil, nil
170 }
171 dir := filepath.Join(sessionDir, "subagents")
172 entries, err := os.ReadDir(dir)
173 if err != nil {
174 if os.IsNotExist(err) {
175 return nil, nil
176 }
177 return nil, err
178 }
179 out := []SubagentArtifact{}
180 for _, entry := range entries {
181 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".meta.json") {
182 continue
183 }
184 ref := strings.TrimSuffix(entry.Name(), ".meta.json")
185 if !validSubagentRef(ref) {
186 continue
187 }
188 metaPath := filepath.Join(dir, entry.Name())
189 data, err := fileencoding.ReadFileUTF8(metaPath)
190 if err != nil {
191 return nil, err
192 }
193 var meta SubagentMeta
194 if err := json.Unmarshal(data, &meta); err != nil {
195 continue
196 }
197 if strings.TrimSpace(meta.ParentSession) != parentSession {
198 continue
199 }
200 out = append(out, SubagentArtifact{
201 Ref: ref,
202 SessionPath: filepath.Join(dir, ref+".jsonl"),
203 MetaPath: metaPath,
204 Meta: meta,
205 })
206 }
207 return out, nil
208 }
209
210 // DeleteSubagentsByParent permanently removes sub-agent artifacts owned by a
211 // parent session. Missing counterpart files are ignored.
212 func DeleteSubagentsByParent(sessionDir, parentSession string) error {
213 artifacts, err := ListSubagentsByParent(sessionDir, parentSession)
214 if err != nil {
215 return err
216 }
217 for _, artifact := range artifacts {
218 paths := []string{artifact.SessionPath, artifact.MetaPath}
219 // Sub-agent saves are single-file today, but sweep transcript sidecars
220 // (event log, event index, …) so no earlier build's artifacts survive
221 // the delete.
222 paths = append(paths, store.SessionSidecarFiles(artifact.SessionPath)...)
223 for _, path := range paths {
224 if path == "" {
225 continue
226 }
227 if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
228 return err
229 }
230 }
231 }
232 return nil
233 }
234
235 // CleanupStaleRunning marks persisted running sub-agents as interrupted while
236 // holding each parent transcript's session lease. A live parent in this or
237 // another process therefore keeps its children untouched, while crash leftovers
238 // remain repairable before this process accepts new background work.
239 func (s *SubagentStore) CleanupStaleRunning() (int, error) {
240 if s == nil {
241 return 0, nil
242 }
243 // On Windows, os.ReadDir can report ERROR_DIRECTORY as an IsNotExist
244 // error when the store path exists but is a regular file. Check the leaf
245 // first so a malformed store remains a startup error instead of being
246 // mistaken for an absent store.
247 info, err := os.Stat(s.dir)
248 if err != nil {
249 if os.IsNotExist(err) {
250 return 0, nil
251 }
252 return 0, err
253 }
254 if !info.IsDir() {
255 return 0, fmt.Errorf("subagent store path %q is not a directory", s.dir)
256 }
257 entries, err := os.ReadDir(s.dir)
258 if err != nil {
259 // Windows reports a non-directory at this path as ENOENT, so a plain
260 // IsNotExist check would silently accept a corrupt store instead of
261 // surfacing it. Only treat it as "no store yet" when nothing is there.
262 if os.IsNotExist(err) {
263 if _, statErr := os.Lstat(s.dir); statErr != nil {
264 return 0, nil
265 }
266 }
267 return 0, err
268 }
269 type staleParent struct {
270 sessionPath string
271 refs []string
272 }
273 parents := map[string]*staleParent{}
274 for _, entry := range entries {
275 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".meta.json") {
276 continue
277 }
278 ref := strings.TrimSuffix(entry.Name(), ".meta.json")
279 if !validSubagentRef(ref) {
280 continue
281 }
282 meta, err := s.LoadMeta(ref)
283 if err != nil {
284 // A corrupt metadata file (truncated write, killed process) must
285 // not abort startup. Skip all content decode failures, including
286 // errors from custom field decoders such as time.Time, while genuine
287 // file I/O errors remain fatal so storage problems stay visible.
288 if isSubagentMetaDecodeError(err) {
289 continue
290 }
291 return 0, err
292 }
293 if meta.Status != SubagentRunning {
294 continue
295 }
296 parentSession := strings.TrimSpace(meta.ParentSession)
297 sessionPath, ok := s.parentSessionPath(parentSession)
298 if !ok {
299 // Old or malformed metadata without a provable parent cannot
300 // authorize a destructive lifecycle rewrite.
301 continue
302 }
303 parent := parents[parentSession]
304 if parent == nil {
305 parent = &staleParent{sessionPath: sessionPath}
306 parents[parentSession] = parent
307 }
308 parent.refs = append(parent.refs, ref)
309 }
310
311 parentIDs := make([]string, 0, len(parents))
312 for parentID := range parents {
313 parentIDs = append(parentIDs, parentID)
314 }
315 sort.Strings(parentIDs)
316
317 now := time.Now().UTC()
318 cleaned := 0
319 for _, parentID := range parentIDs {
320 parent := parents[parentID]
321 if s.parentSessionProbe != nil && s.parentSessionProbe(parent.sessionPath) {
322 continue
323 }
324 lease, err := TryAcquireSessionLease(parent.sessionPath)
325 if errors.Is(err, ErrSessionLeaseHeld) {
326 continue
327 }
328 if err != nil {
329 return cleaned, fmt.Errorf("acquire parent session lease %q: %w", parentID, err)
330 }
331 for _, ref := range parent.refs {
332 if s.cleanupBeforeReread != nil {
333 s.cleanupBeforeReread(parentID, ref)
334 }
335 // Re-read after acquiring the parent lease: the former owner may
336 // have completed the child between the initial scan and handoff.
337 meta, err := s.LoadMeta(ref)
338 if err != nil {
339 if isSubagentMetaDecodeError(err) {
340 continue
341 }
342 lease.Release()
343 return cleaned, err
344 }
345 if meta.Status != SubagentRunning || strings.TrimSpace(meta.ParentSession) != parentID {
346 continue
347 }
348 meta.Status = SubagentInterrupted
349 meta.UpdatedAt = now
350 if err := s.saveMeta(meta); err != nil {
351 lease.Release()
352 return cleaned, err
353 }
354 cleaned++
355 }
356 lease.Release()
357 }
358 return cleaned, nil
359 }
360
361 func (s *SubagentStore) parentSessionPath(parentSession string) (string, bool) {
362 parentSession = strings.TrimSpace(parentSession)
363 if parentSession == "" || parentSession == "." || parentSession == ".." || filepath.Base(parentSession) != parentSession {
364 return "", false
365 }
366 return filepath.Join(filepath.Dir(s.dir), parentSession+".jsonl"), true
367 }
368
369 func (s *SubagentStore) PrepareFresh(spec SubagentSpec) (*SubagentRun, error) {
370 if s == nil {
371 return nil, fmt.Errorf("subagent transcript store is required")
372 }
373 if err := requireParentSession(spec); err != nil {
374 return nil, err
375 }
376 ref, err := s.newRef()
377 if err != nil {
378 return nil, err
379 }
380 release, err := s.lock(ref)
381 if err != nil {
382 return nil, err
383 }
384 now := time.Now().UTC()
385 meta := metaFromSpec(ref, SubagentRunning, now, now, spec)
386 return &SubagentRun{Ref: ref, Session: NewSession(spec.SystemPrompt), Meta: meta, store: s, release: release}, nil
387 }
388
389 func (s *SubagentStore) PrepareContinue(ref string, spec SubagentSpec) (*SubagentRun, error) {
390 if s == nil {
391 return nil, fmt.Errorf("subagent continuation is not available in this session")
392 }
393 if err := requireParentSession(spec); err != nil {
394 return nil, err
395 }
396 ref = strings.TrimSpace(ref)
397 if ref == "" {
398 return nil, fmt.Errorf("continue_from requires a subagent reference")
399 }
400 release, err := s.lock(ref)
401 if err != nil {
402 return nil, err
403 }
404 meta, err := s.LoadMeta(ref)
405 if err != nil {
406 release()
407 return nil, err
408 }
409 if strings.TrimSpace(meta.ParentSession) != strings.TrimSpace(spec.ParentSession) {
410 release()
411 return s.prepareContinueFromAncestor(ref, spec)
412 }
413 if err := validateContinueOwner(meta, spec); err != nil {
414 release()
415 return nil, err
416 }
417 if err := validateMeta(meta, spec); err != nil {
418 release()
419 return nil, err
420 }
421 sess, err := LoadSession(s.sessionPath(ref))
422 if err != nil {
423 release()
424 return nil, fmt.Errorf("load subagent transcript %q: %w", ref, err)
425 }
426 meta.ParentSession = spec.ParentSession
427 meta.ParentToolCallID = spec.ParentToolCallID
428 return &SubagentRun{Ref: ref, Session: sess, Meta: meta, store: s, release: release}, nil
429 }
430
431 func (s *SubagentStore) PrepareLegacyForkFrom(ref string, spec SubagentSpec) (*SubagentRun, error) {
432 if s == nil {
433 return nil, fmt.Errorf("subagent continuation is not available in this session")
434 }
435 if err := requireParentSession(spec); err != nil {
436 return nil, err
437 }
438 ref = strings.TrimSpace(ref)
439 if ref == "" {
440 return nil, fmt.Errorf("fork_from requires a subagent reference")
441 }
442 release, err := s.lock(ref)
443 if err != nil {
444 return nil, err
445 }
446 meta, err := s.LoadMeta(ref)
447 if err != nil {
448 release()
449 return nil, err
450 }
451 owner := strings.TrimSpace(meta.ParentSession)
452 current := strings.TrimSpace(spec.ParentSession)
453 if owner == "" {
454 release()
455 return nil, fmt.Errorf("subagent reference %q has no parent session; run a fresh subagent instead", ref)
456 }
457 if owner == current {
458 release()
459 if err := validateMeta(meta, spec); err != nil {
460 return nil, err
461 }
462 return nil, fmt.Errorf("fork_from cannot be safely converted for subagent reference %q in the current conversation; use continue_from to continue it in place or start a fresh subagent for independent work", ref)
463 }
464 release()
465 return s.PrepareContinue(ref, spec)
466 }
467
468 func (s *SubagentStore) prepareContinueFromAncestor(sourceRef string, spec SubagentSpec) (*SubagentRun, error) {
469 sourceRef, err := s.nearestLineageSource(sourceRef, spec)
470 if err != nil {
471 return nil, err
472 }
473 copies, err := s.compatibleCopiesFromSource(sourceRef, spec)
474 if err != nil {
475 return nil, err
476 }
477 switch len(copies) {
478 case 0:
479 return s.prepareFork(sourceRef, spec)
480 case 1:
481 run, err := s.PrepareContinue(copies[0].Ref, spec)
482 if err != nil {
483 return nil, err
484 }
485 run.ForkedFrom = sourceRef
486 return run, nil
487 default:
488 return nil, fmt.Errorf("subagent reference %q has multiple copied transcripts in current parent session %q", sourceRef, spec.ParentSession)
489 }
490 }
491
492 func (s *SubagentStore) nearestLineageSource(requestedRef string, spec SubagentSpec) (string, error) {
493 ancestors, err := s.sessionAncestors(spec.ParentSession)
494 if err != nil {
495 return "", err
496 }
497 for _, ancestor := range ancestors {
498 artifacts, err := ListSubagentsByParent(filepath.Dir(s.dir), ancestor)
499 if err != nil {
500 return "", err
501 }
502 var candidates []SubagentArtifact
503 for _, artifact := range artifacts {
504 if artifact.Ref == requestedRef || s.derivesFrom(artifact.Meta, requestedRef) {
505 candidates = append(candidates, artifact)
506 }
507 }
508 if len(candidates) > 1 {
509 return "", fmt.Errorf("subagent reference %q has multiple candidate transcripts in ancestor parent session %q", requestedRef, ancestor)
510 }
511 if len(candidates) == 1 {
512 if err := validateMeta(candidates[0].Meta, spec); err != nil {
513 return "", err
514 }
515 return candidates[0].Ref, nil
516 }
517 }
518 return "", fmt.Errorf("subagent reference %q is not in current parent session %q lineage", requestedRef, spec.ParentSession)
519 }
520
521 func (s *SubagentStore) sessionAncestors(current string) ([]string, error) {
522 current = strings.TrimSpace(current)
523 if current == "" {
524 return nil, nil
525 }
526 var ancestors []string
527 seen := map[string]bool{}
528 for cursor := current; cursor != ""; {
529 if seen[cursor] {
530 return nil, fmt.Errorf("cycle at session %q", cursor)
531 }
532 seen[cursor] = true
533 // Route through parentSessionPath so both the caller-provided id and
534 // every parent id read from disk metadata get the same bare-filename
535 // validation — a raw Join would let "../"-shaped ids escape the
536 // session directory.
537 metaPath, valid := s.parentSessionPath(cursor)
538 if !valid {
539 return nil, fmt.Errorf("invalid session identifier %q", cursor)
540 }
541 meta, ok, err := LoadBranchMeta(metaPath)
542 if err != nil {
543 return nil, err
544 }
545 if !ok {
546 return nil, fmt.Errorf("missing branch metadata for session %q", cursor)
547 }
548 if strings.TrimSpace(meta.ID) != cursor {
549 return nil, fmt.Errorf("branch metadata for session %q declares id %q", cursor, meta.ID)
550 }
551 parent := strings.TrimSpace(meta.ParentID)
552 if parent == "" {
553 break
554 }
555 ancestors = append(ancestors, parent)
556 cursor = parent
557 }
558 return ancestors, nil
559 }
560
561 func (s *SubagentStore) derivesFrom(meta SubagentMeta, sourceRef string) bool {
562 sourceRef = strings.TrimSpace(sourceRef)
563 seen := map[string]bool{}
564 for cursor := strings.TrimSpace(meta.ForkedFrom); cursor != ""; {
565 if cursor == sourceRef {
566 return true
567 }
568 if seen[cursor] {
569 return false
570 }
571 seen[cursor] = true
572 parent, err := s.LoadMeta(cursor)
573 if err != nil {
574 return false
575 }
576 cursor = strings.TrimSpace(parent.ForkedFrom)
577 }
578 return false
579 }
580
581 func (s *SubagentStore) compatibleCopiesFromSource(sourceRef string, spec SubagentSpec) ([]SubagentArtifact, error) {
582 artifacts, err := ListSubagentsByParent(filepath.Dir(s.dir), spec.ParentSession)
583 if err != nil {
584 return nil, err
585 }
586 var copies []SubagentArtifact
587 for _, artifact := range artifacts {
588 if strings.TrimSpace(artifact.Meta.ForkedFrom) != sourceRef {
589 continue
590 }
591 if err := validateMeta(artifact.Meta, spec); err != nil {
592 return nil, err
593 }
594 copies = append(copies, artifact)
595 }
596 return copies, nil
597 }
598
599 func (s *SubagentStore) prepareFork(ref string, spec SubagentSpec) (*SubagentRun, error) {
600 if s == nil {
601 return nil, fmt.Errorf("subagent continuation is not available in this session")
602 }
603 if err := requireParentSession(spec); err != nil {
604 return nil, err
605 }
606 sourceRef := strings.TrimSpace(ref)
607 if sourceRef == "" {
608 return nil, fmt.Errorf("subagent copy requires a source reference")
609 }
610 sourceRelease, err := s.lock(sourceRef)
611 if err != nil {
612 return nil, err
613 }
614 meta, err := s.LoadMeta(sourceRef)
615 if err != nil {
616 sourceRelease()
617 return nil, err
618 }
619 if strings.TrimSpace(meta.ParentSession) == "" {
620 sourceRelease()
621 return nil, fmt.Errorf("subagent reference %q has no parent session; run a fresh subagent instead", sourceRef)
622 }
623 if err := validateMeta(meta, spec); err != nil {
624 sourceRelease()
625 return nil, err
626 }
627 if err := s.validateForkOwner(meta, spec); err != nil {
628 sourceRelease()
629 return nil, err
630 }
631 sess, err := LoadSession(s.sessionPath(sourceRef))
632 if err != nil {
633 sourceRelease()
634 return nil, fmt.Errorf("load subagent transcript %q: %w", sourceRef, err)
635 }
636 sourceRelease()
637 newRef, err := s.newRef()
638 if err != nil {
639 return nil, err
640 }
641 newRelease, err := s.lock(newRef)
642 if err != nil {
643 return nil, err
644 }
645 now := time.Now().UTC()
646 newMeta := metaFromSpec(newRef, SubagentRunning, now, now, spec)
647 newMeta.ForkedFrom = sourceRef
648 return &SubagentRun{Ref: newRef, Session: sess, Meta: newMeta, ForkedFrom: sourceRef, store: s, release: newRelease}, nil
649 }
650
651 func (s *SubagentStore) MarkRunning(run *SubagentRun) error {
652 if s == nil || run == nil || run.Ref == "" {
653 return nil
654 }
655 if s.parentDestroyed(run) {
656 return nil
657 }
658 meta := run.Meta
659 meta.Status = SubagentRunning
660 meta.UpdatedAt = time.Now().UTC()
661 return s.saveMeta(meta)
662 }
663
664 func (s *SubagentStore) SaveCompleted(run *SubagentRun) error {
665 if s == nil || run == nil || run.Ref == "" {
666 return nil
667 }
668 if s.parentDestroyed(run) {
669 return nil
670 }
671 if err := s.ensureBranchCreatedAt(run); err != nil {
672 return err
673 }
674 if err := run.Session.Save(s.sessionPath(run.Ref)); err != nil {
675 return err
676 }
677 meta := run.Meta
678 meta.Status = SubagentCompleted
679 meta.UpdatedAt = time.Now().UTC()
680 run.Meta = meta
681 return s.saveMeta(meta)
682 }
683
684 func (s *SubagentStore) SaveFailed(run *SubagentRun) error {
685 if s == nil || run == nil || run.Ref == "" {
686 return nil
687 }
688 if s.parentDestroyed(run) {
689 return nil
690 }
691 // Terminal status is independent from transcript persistence. Keep going so
692 // a sidecar failure cannot leave a failed run marked as running on disk.
693 branchErr := s.ensureBranchCreatedAt(run)
694 var sessionErr error
695 if run.Session != nil {
696 sessionErr = run.Session.Save(s.sessionPath(run.Ref))
697 }
698 meta := run.Meta
699 meta.Status = SubagentFailed
700 meta.UpdatedAt = time.Now().UTC()
701 run.Meta = meta
702 return errors.Join(branchErr, sessionErr, s.saveMeta(meta))
703 }
704
705 // ensureBranchCreatedAt seeds the session list sidecar before the first
706 // transcript save. Subagent transcripts are written only on completion, so
707 // Session.Save would otherwise backfill BranchMeta.CreatedAt with the save
708 // moment (completion time). The real start time already lives on run.Meta.
709 func (s *SubagentStore) ensureBranchCreatedAt(run *SubagentRun) error {
710 if s == nil || run == nil || run.Ref == "" {
711 return nil
712 }
713 path := s.sessionPath(run.Ref)
714 if _, ok, err := LoadBranchMeta(path); err != nil {
715 return err
716 } else if ok {
717 return nil
718 }
719 created := run.Meta.CreatedAt.UTC()
720 if created.IsZero() {
721 created = time.Now().UTC()
722 }
723 return SaveBranchMetaPreserveUpdated(path, BranchMeta{
724 ID: BranchID(path),
725 CreatedAt: created,
726 })
727 }
728
729 func (s *SubagentStore) LoadMeta(ref string) (SubagentMeta, error) {
730 var meta SubagentMeta
731 if !validSubagentRef(ref) {
732 return meta, fmt.Errorf("invalid subagent reference %q", ref)
733 }
734 data, err := fileencoding.ReadFileUTF8(s.metaPath(ref))
735 if err != nil {
736 return meta, fmt.Errorf("load subagent metadata %q: %w", ref, err)
737 }
738 if err := json.Unmarshal(data, &meta); err != nil {
739 return meta, &subagentMetaDecodeError{ref: ref, err: err}
740 }
741 return meta, nil
742 }
743
744 func metaFromSpec(ref string, status SubagentStatus, created, updated time.Time, spec SubagentSpec) SubagentMeta {
745 scope, schemaHash := toolIdentity(spec.Registry)
746 return SubagentMeta{
747 Ref: ref,
748 CreatedAt: created,
749 UpdatedAt: updated,
750 Status: status,
751 Kind: strings.TrimSpace(spec.Kind),
752 Name: strings.TrimSpace(spec.Name),
753 WorkspaceRoot: strings.TrimSpace(spec.WorkspaceRoot),
754 ParentSession: strings.TrimSpace(spec.ParentSession),
755 ParentToolCallID: strings.TrimSpace(spec.ParentToolCallID),
756 SystemPromptHash: bytesHash([]byte(spec.SystemPrompt)),
757 ToolScope: scope,
758 ToolSchemaHash: schemaHash,
759 Model: strings.TrimSpace(spec.Model),
760 Effort: strings.TrimSpace(spec.Effort),
761 }
762 }
763
764 func validateMeta(meta SubagentMeta, spec SubagentSpec) error {
765 if meta.Status == SubagentRunning {
766 return fmt.Errorf("subagent reference %q is still in progress", meta.Ref)
767 }
768 if meta.Status == SubagentFailed {
769 return fmt.Errorf("subagent reference %q failed and cannot be continued", meta.Ref)
770 }
771 if meta.Status == SubagentInterrupted {
772 return fmt.Errorf("subagent reference %q was interrupted by a previous shutdown or crash and cannot be continued or forked; run a fresh subagent instead", meta.Ref)
773 }
774 want := metaFromSpec(meta.Ref, meta.Status, meta.CreatedAt, meta.UpdatedAt, spec)
775 switch {
776 case meta.Kind != want.Kind:
777 return fmt.Errorf("subagent reference %q has kind %q, want %q", meta.Ref, meta.Kind, want.Kind)
778 case meta.Name != want.Name:
779 return fmt.Errorf("subagent reference %q has name %q, want %q", meta.Ref, meta.Name, want.Name)
780 case meta.WorkspaceRoot != want.WorkspaceRoot:
781 return fmt.Errorf("subagent reference %q belongs to workspace %q, current workspace is %q", meta.Ref, meta.WorkspaceRoot, want.WorkspaceRoot)
782 case meta.SystemPromptHash != want.SystemPromptHash:
783 return fmt.Errorf("subagent reference %q uses a different subagent persona; run a fresh subagent to use the current persona", meta.Ref)
784 case !sameStrings(meta.ToolScope, want.ToolScope):
785 return fmt.Errorf("subagent reference %q uses a different tool scope", meta.Ref)
786 case meta.ToolSchemaHash != want.ToolSchemaHash:
787 return fmt.Errorf("subagent reference %q uses different tool schemas", meta.Ref)
788 case meta.Model != want.Model || meta.Effort != want.Effort:
789 return fmt.Errorf("subagent reference %q uses model/effort %q/%q, current run would use %q/%q", meta.Ref, meta.Model, meta.Effort, want.Model, want.Effort)
790 }
791 return nil
792 }
793
794 func requireParentSession(spec SubagentSpec) error {
795 if strings.TrimSpace(spec.ParentSession) == "" {
796 return fmt.Errorf("subagent transcript parent session is required")
797 }
798 return nil
799 }
800
801 func validateContinueOwner(meta SubagentMeta, spec SubagentSpec) error {
802 current := strings.TrimSpace(spec.ParentSession)
803 owner := strings.TrimSpace(meta.ParentSession)
804 if owner == current {
805 return nil
806 }
807 if owner == "" {
808 return fmt.Errorf("subagent reference %q has no parent session; run a fresh subagent instead", meta.Ref)
809 }
810 return fmt.Errorf("subagent reference %q belongs to parent session %q, current parent session is %q", meta.Ref, owner, current)
811 }
812
813 func (s *SubagentStore) validateForkOwner(meta SubagentMeta, spec SubagentSpec) error {
814 current := strings.TrimSpace(spec.ParentSession)
815 owner := strings.TrimSpace(meta.ParentSession)
816 if owner == current {
817 return nil
818 }
819 if owner == "" {
820 return fmt.Errorf("subagent reference %q has no parent session; run a fresh subagent instead", meta.Ref)
821 }
822 ok, err := s.isAncestorSession(owner, current)
823 if err != nil {
824 return fmt.Errorf("subagent reference %q belongs to parent session %q, but current parent session %q lineage could not be verified: %w", meta.Ref, owner, current, err)
825 }
826 if ok {
827 return nil
828 }
829 return fmt.Errorf("subagent reference %q belongs to parent session %q, which is not in current parent session %q lineage", meta.Ref, owner, current)
830 }
831
832 func (s *SubagentStore) isAncestorSession(ancestor, current string) (bool, error) {
833 ancestor = strings.TrimSpace(ancestor)
834 current = strings.TrimSpace(current)
835 if ancestor == "" || current == "" {
836 return false, nil
837 }
838 seen := map[string]bool{}
839 for cursor := current; cursor != ""; {
840 if seen[cursor] {
841 return false, fmt.Errorf("cycle at session %q", cursor)
842 }
843 seen[cursor] = true
844 // Route through parentSessionPath so both the caller-provided id and
845 // every parent id read from disk metadata get the same bare-filename
846 // validation — a raw Join would let "../"-shaped ids escape the
847 // session directory.
848 metaPath, valid := s.parentSessionPath(cursor)
849 if !valid {
850 return false, fmt.Errorf("invalid session identifier %q", cursor)
851 }
852 meta, ok, err := LoadBranchMeta(metaPath)
853 if err != nil {
854 return false, err
855 }
856 if !ok {
857 return false, fmt.Errorf("missing branch metadata for session %q", cursor)
858 }
859 if strings.TrimSpace(meta.ID) != cursor {
860 return false, fmt.Errorf("branch metadata for session %q declares id %q", cursor, meta.ID)
861 }
862 if cursor == ancestor {
863 return true, nil
864 }
865 parent := strings.TrimSpace(meta.ParentID)
866 cursor = parent
867 }
868 return false, nil
869 }
870
871 func (s *SubagentStore) lock(ref string) (func(), error) {
872 if !validSubagentRef(ref) {
873 return nil, fmt.Errorf("invalid subagent reference %q", ref)
874 }
875 s.mu.Lock()
876 defer s.mu.Unlock()
877 if s.locked[ref] {
878 return nil, fmt.Errorf("subagent reference %q is already running; retry after it finishes", ref)
879 }
880 s.locked[ref] = true
881 return func() {
882 s.mu.Lock()
883 delete(s.locked, ref)
884 s.mu.Unlock()
885 }, nil
886 }
887
888 func (s *SubagentStore) newRef() (string, error) {
889 var b [6]byte
890 if _, err := rand.Read(b[:]); err != nil {
891 return "", err
892 }
893 return "sa_" + time.Now().UTC().Format("20060102_150405_000000000") + "_" + hex.EncodeToString(b[:]), nil
894 }
895
896 func (s *SubagentStore) sessionPath(ref string) string { return filepath.Join(s.dir, ref+".jsonl") }
897 func (s *SubagentStore) metaPath(ref string) string { return filepath.Join(s.dir, ref+".meta.json") }
898
899 func (s *SubagentStore) saveMeta(meta SubagentMeta) error {
900 if err := os.MkdirAll(s.dir, 0o755); err != nil {
901 return err
902 }
903 data, err := json.MarshalIndent(meta, "", " ")
904 if err != nil {
905 return err
906 }
907 data = append(data, '\n')
908 tmp, err := os.CreateTemp(s.dir, ".subagent-meta.*.tmp")
909 if err != nil {
910 return err
911 }
912 tmpPath := tmp.Name()
913 if _, err := tmp.Write(data); err != nil {
914 tmp.Close()
915 os.Remove(tmpPath)
916 return err
917 }
918 if err := tmp.Close(); err != nil {
919 os.Remove(tmpPath)
920 return err
921 }
922 return fileutil.ReplaceFile(tmpPath, s.metaPath(meta.Ref))
923 }
924
925 func (s *SubagentStore) parentDestroyed(run *SubagentRun) bool {
926 if s == nil || s.destroyed == nil || run == nil {
927 return false
928 }
929 return s.destroyed(run.Meta.ParentSession)
930 }
931
932 func validSubagentRef(ref string) bool {
933 if !strings.HasPrefix(ref, "sa_") {
934 return false
935 }
936 for _, r := range ref {
937 if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
938 continue
939 }
940 return false
941 }
942 return true
943 }
944
945 func toolIdentity(reg *tool.Registry) ([]string, string) {
946 if reg == nil {
947 return nil, bytesHash(nil)
948 }
949 names := reg.Names()
950 sort.Strings(names)
951 schemas := normalizeToolSchemas(reg.Schemas())
952 data, _ := json.Marshal(schemas)
953 return names, bytesHash(data)
954 }
955
956 func bytesHash(data []byte) string {
957 h := sha256.Sum256(data)
958 return hex.EncodeToString(h[:])
959 }
960
961 func sameStrings(a, b []string) bool {
962 if len(a) != len(b) {
963 return false
964 }
965 for i := range a {
966 if a[i] != b[i] {
967 return false
968 }
969 }
970 return true
971 }
972
972 lines GO