package orchestration import ( "context" "sync" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // SubAgent is a single spawned sub-agent instance. The identity fields (ID, Goal, // RoleID, Mode, AttachInfo, Data, Context, WorkingMemory) are set once at Spawn // and never mutated. The lifecycle fields (Status, Summary, Result, Err) are // written by the sub-agent's background goroutine and read through the // synchronized accessors below, so reading them before the sub-agent finishes is // safe. A sub-agent is terminal exactly once its Done reports true; Join returns // once every sub-agent is terminal. // // SubAgent is created by (*Scheduler).Spawn; it should not be built directly. type SubAgent struct { // ID uniquely identifies the sub-agent within its scheduler. ID string // Goal is the objective the sub-agent pursues. Goal string // RoleID is the bound role/persona. RoleID string // Mode is the bound behavior mode. Mode string // AttachInfo is the free-form attachment text supplied at spawn. AttachInfo string // Data is the optional structured payload supplied at spawn. Data any // Context is the isolated conversation context. It is a deep copy of the // AgentSpec.Context and is never shared with the front session. Context []model.Message // WorkingMemory is the sub-agent's own working-memory window, created fresh // at Spawn so it never overlaps another sub-agent or the front session. WorkingMemory *memory.WorkingMemory mu sync.RWMutex status Status summary string result *loop.Result runErr error started time.Time finished time.Time done chan struct{} cancel context.CancelFunc abortReq bool doneClosed bool launchOnce sync.Once finishOnce sync.Once } // Status returns the sub-agent's current lifecycle status. func (s *SubAgent) Status() Status { s.mu.RLock() defer s.mu.RUnlock() return s.status } // Summary returns the execution summary written when the sub-agent finalized: // a completion summary on success, or a failure/abort reason otherwise. It is // empty until the sub-agent is terminal. func (s *SubAgent) Summary() string { s.mu.RLock() defer s.mu.RUnlock() return s.summary } // Result returns the loop result on a successful run, or nil when the run failed // or was aborted. func (s *SubAgent) Result() *loop.Result { s.mu.RLock() defer s.mu.RUnlock() return s.result } // Err returns the error that ended a failed or aborted run (nil on success). func (s *SubAgent) Err() error { s.mu.RLock() defer s.mu.RUnlock() return s.runErr } // StartedAt returns when the sub-agent transitioned to running (zero if it never // started). func (s *SubAgent) StartedAt() time.Time { s.mu.RLock() defer s.mu.RUnlock() return s.started } // FinishedAt returns when the sub-agent reached a terminal state (zero if not // terminal yet). func (s *SubAgent) FinishedAt() time.Time { s.mu.RLock() defer s.mu.RUnlock() return s.finished } // Done reports whether the sub-agent reached a terminal state. func (s *SubAgent) Done() bool { return s.Status().Terminal() } // Snapshot returns a point-in-time copy of the sub-agent's state, safe to read // while the sub-agent is still running. func (s *SubAgent) Snapshot() SubAgentResult { s.mu.RLock() defer s.mu.RUnlock() snap := SubAgentResult{ ID: s.ID, Goal: s.Goal, RoleID: s.RoleID, Mode: s.Mode, AttachInfo: s.AttachInfo, Status: s.status, Summary: s.summary, Err: s.runErr, StartedAt: s.started, FinishedAt: s.finished, } if s.result != nil { snap.Text = s.result.Text snap.Steps = s.result.Steps } return snap } // transitionRunning moves a pending sub-agent to running. It returns false when // the sub-agent was already aborted (so it must not run) or is not pending. func (s *SubAgent) transitionRunning() bool { s.mu.Lock() defer s.mu.Unlock() if s.abortReq || s.status != StatusPending { return false } s.status = StatusRunning s.started = time.Now() return true } // progress returns the current status snapshot for a progress event. func (s *SubAgent) progress() Progress { s.mu.RLock() defer s.mu.RUnlock() return Progress{ AgentID: s.ID, Goal: s.Goal, RoleID: s.RoleID, Mode: s.Mode, Status: s.status, } } // setOutcome records the terminal status, summary and error after the run ends. // It is called at most once per sub-agent, guarded by finishOnce. func (s *SubAgent) setOutcome(status Status, summary string, err error) { s.mu.Lock() defer s.mu.Unlock() s.status = status s.summary = summary s.runErr = err s.finished = time.Now() } // setResult records the successful loop result. func (s *SubAgent) setResult(res *loop.Result) { s.mu.Lock() defer s.mu.Unlock() s.result = res } // setCancel stores the run context's cancel function so Abort can interrupt the // loop. If the sub-agent was already aborted (cancelRun raced ahead), the stored // cancel is invoked immediately. func (s *SubAgent) setCancel(c context.CancelFunc) { s.mu.Lock() s.cancel = c aborted := s.abortReq s.mu.Unlock() if aborted { c() } } // cancelRun requests cancellation of the sub-agent. It is idempotent and safe for // concurrent use: the done channel is closed once and the stored cancel (when set) // is invoked once. A pending sub-agent unblocks from its parallel slot via done; a // running sub-agent is interrupted via the cancel func. func (s *SubAgent) cancelRun() { s.mu.Lock() s.abortReq = true c := s.cancel first := !s.doneClosed s.doneClosed = true s.mu.Unlock() if first { close(s.done) } if c != nil { c() } } // aborted reports whether a cancellation was requested for the sub-agent. func (s *SubAgent) aborted() bool { s.mu.RLock() defer s.mu.RUnlock() return s.abortReq }