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