源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,847 @@
|
||||
package agentruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
agentsessionstore "github.com/tutti-os/tutti/packages/agent/daemon/activity"
|
||||
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
|
||||
)
|
||||
|
||||
func (c *Controller) PublishStreamEvent(roomID, agentSessionID string, event StreamEvent) {
|
||||
roomID = strings.TrimSpace(roomID)
|
||||
agentSessionID = strings.TrimSpace(agentSessionID)
|
||||
if c == nil || roomID == "" || agentSessionID == "" || event.EventType == "" {
|
||||
return
|
||||
}
|
||||
c.publishStreamEvents(roomID, agentSessionID, []StreamEvent{event})
|
||||
}
|
||||
|
||||
func (c *Controller) publishSessionStatePatch(session Session, patch agentsessionstore.WorkspaceAgentStatePatch) {
|
||||
if c == nil || c.hub == nil {
|
||||
return
|
||||
}
|
||||
roomID := strings.TrimSpace(session.RoomID)
|
||||
agentSessionID := strings.TrimSpace(session.AgentSessionID)
|
||||
if roomID == "" || agentSessionID == "" || strings.TrimSpace(patch.AgentSessionID) == "" {
|
||||
return
|
||||
}
|
||||
c.publishStreamEvents(roomID, agentSessionID, []StreamEvent{{
|
||||
EventType: StreamEventStatePatch,
|
||||
Data: patch,
|
||||
}})
|
||||
}
|
||||
|
||||
func (c *Controller) Session(roomID, agentSessionID string) (Session, bool) {
|
||||
return c.get(strings.TrimSpace(roomID), strings.TrimSpace(agentSessionID))
|
||||
}
|
||||
|
||||
// HasLiveSession reports provider-process liveness without starting or
|
||||
// resuming one. A Controller session can remain registered after its adapter
|
||||
// releases an idle provider connection.
|
||||
func (c *Controller) HasLiveSession(roomID, agentSessionID string) bool {
|
||||
session, adapter, err := c.sessionAndAdapter(strings.TrimSpace(roomID), strings.TrimSpace(agentSessionID))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
probe, ok := adapter.(LiveSessionProbeAdapter)
|
||||
return !ok || probe.HasLiveSession(session)
|
||||
}
|
||||
|
||||
func (c *Controller) CanResume(input ResumeInput) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
provider := strings.TrimSpace(input.Provider)
|
||||
if provider == "" {
|
||||
return false
|
||||
}
|
||||
extensionTargetRef := providerTargetRefString(input.ProviderTargetRef, "kind") == "agent_extension"
|
||||
if extensionTargetRef {
|
||||
if !authorizedAgentExtensionResumeInput(input, provider) {
|
||||
return false
|
||||
}
|
||||
// The fixed Target binding is durable launch authority. A dynamic adapter
|
||||
// can be resolved from it during Resume even when the process cache is
|
||||
// empty after a daemon restart.
|
||||
if c.adapterResolver != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
adapter := c.adapter(provider)
|
||||
if adapter == nil {
|
||||
return false
|
||||
}
|
||||
if bound, ok := adapter.(ResolveInputBoundAdapter); extensionTargetRef && ok &&
|
||||
!bound.MatchesAdapterResolveInput(AdapterResolveInput{
|
||||
Provider: provider,
|
||||
AgentTargetID: strings.TrimSpace(input.AgentTargetID),
|
||||
CWD: strings.TrimSpace(input.CWD),
|
||||
ProviderTargetRef: clonePayload(input.ProviderTargetRef),
|
||||
}) {
|
||||
return false
|
||||
}
|
||||
probeAdapter, ok := adapter.(ResumeProbeAdapter)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return probeAdapter.CanResume(Session{
|
||||
RoomID: strings.TrimSpace(input.RoomID),
|
||||
AgentSessionID: strings.TrimSpace(input.AgentSessionID),
|
||||
Provider: provider,
|
||||
ProviderSessionID: strings.TrimSpace(input.ProviderSessionID),
|
||||
CWD: strings.TrimSpace(input.CWD),
|
||||
Env: append([]string(nil), input.Env...),
|
||||
MCPServers: cloneMCPServerBindings(input.MCPServers),
|
||||
Status: normalizeSessionStatus(input.Status),
|
||||
Title: strings.TrimSpace(input.Title),
|
||||
Visible: sessionVisible(input.Visible),
|
||||
PermissionModeID: normalizePermissionModeIDWithFallback(provider, input.PermissionModeID, defaultPermissionModeIDForProvider(provider)),
|
||||
Settings: normalizeOptionalSessionSettings(input.Settings, provider, firstNonEmpty(input.PermissionModeID, defaultPermissionModeIDForProvider(provider))),
|
||||
CreatedAtUnixMS: input.CreatedAtUnixMS,
|
||||
UpdatedAtUnixMS: input.UpdatedAtUnixMS,
|
||||
})
|
||||
}
|
||||
|
||||
func authorizedAgentExtensionResumeInput(input ResumeInput, provider string) bool {
|
||||
return strings.TrimSpace(input.ProviderSessionID) != "" &&
|
||||
strings.TrimSpace(input.AgentTargetID) != "" &&
|
||||
providerTargetRefString(input.ProviderTargetRef, "provider") == provider &&
|
||||
providerTargetRefString(input.ProviderTargetRef, "targetId") == strings.TrimSpace(input.AgentTargetID) &&
|
||||
providerTargetRefString(input.ProviderTargetRef, "extensionInstallationId") != ""
|
||||
}
|
||||
|
||||
func providerTargetRefString(ref map[string]any, key string) string {
|
||||
value, _ := ref[key].(string)
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func (c *Controller) Sessions(roomID string) []Session {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
roomID = strings.TrimSpace(roomID)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
result := make([]Session, 0)
|
||||
for key, session := range c.sessions {
|
||||
if strings.TrimSpace(session.RoomID) != roomID {
|
||||
continue
|
||||
}
|
||||
if session.IsSideConversation() {
|
||||
continue
|
||||
}
|
||||
if c.sessionPublicationPendingLocked(key) {
|
||||
continue
|
||||
}
|
||||
session = c.reconcileSessionStatusLocked(key, session)
|
||||
c.sessions[key] = session
|
||||
result = append(result, session)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// RuntimeSessions lists every registered runtime Session in a Workspace,
|
||||
// including sessions still behind the canonical initialization publication
|
||||
// barrier. Lifecycle teardown uses this broader view so a provider process
|
||||
// cannot outlive the Workspace merely because its canonical report is pending.
|
||||
func (c *Controller) RuntimeSessions(ctx context.Context, roomID string) ([]Session, error) {
|
||||
if c == nil {
|
||||
return nil, nil
|
||||
}
|
||||
roomID = strings.TrimSpace(roomID)
|
||||
if err := c.waitForWorkspaceStartupOperations(ctx, roomID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
result := make([]Session, 0)
|
||||
for key, session := range c.sessions {
|
||||
if strings.TrimSpace(session.RoomID) != roomID {
|
||||
continue
|
||||
}
|
||||
session = c.reconcileSessionStatusLocked(key, session)
|
||||
c.sessions[key] = session
|
||||
result = append(result, session)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// waitForWorkspaceStartupOperations waits only for startup operations that had
|
||||
// entered before this call took its snapshot. A later Start is intentionally
|
||||
// outside this barrier; the caller must fence new transport admission first.
|
||||
func (c *Controller) waitForWorkspaceStartupOperations(ctx context.Context, roomID string) error {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
roomID = strings.TrimSpace(roomID)
|
||||
c.mu.Lock()
|
||||
operations := make([]<-chan struct{}, 0)
|
||||
for key, lock := range c.startupLocks {
|
||||
if key.roomID != roomID || lock == nil {
|
||||
continue
|
||||
}
|
||||
for done := range lock.startupOperations {
|
||||
operations = append(operations, done)
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
for _, done := range operations {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-done:
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Controller) adapter(provider string) Adapter {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.adapters[provider]
|
||||
}
|
||||
|
||||
func (c *Controller) resolveAdapter(ctx context.Context, input AdapterResolveInput) (Adapter, error) {
|
||||
provider := strings.TrimSpace(input.Provider)
|
||||
if adapter := c.adapter(provider); adapter != nil {
|
||||
if bound, ok := adapter.(ResolveInputBoundAdapter); ok && !bound.MatchesAdapterResolveInput(input) {
|
||||
return nil, fmt.Errorf("cached adapter binding mismatch for %q", provider)
|
||||
}
|
||||
return adapter, nil
|
||||
}
|
||||
if c == nil || c.adapterResolver == nil {
|
||||
return nil, nil
|
||||
}
|
||||
adapter, err := c.adapterResolver.ResolveAdapter(ctx, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if adapter == nil || strings.TrimSpace(adapter.Provider()) != provider {
|
||||
return nil, fmt.Errorf("resolved adapter provider mismatch for %q", provider)
|
||||
}
|
||||
c.configureAdapter(adapter)
|
||||
c.mu.Lock()
|
||||
if existing := c.adapters[provider]; existing != nil {
|
||||
if bound, ok := existing.(ResolveInputBoundAdapter); ok && !bound.MatchesAdapterResolveInput(input) {
|
||||
c.mu.Unlock()
|
||||
return nil, fmt.Errorf("cached adapter binding mismatch for %q", provider)
|
||||
}
|
||||
adapter = existing
|
||||
} else {
|
||||
c.adapters[provider] = adapter
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return adapter, nil
|
||||
}
|
||||
|
||||
func (c *Controller) sessionAndAdapter(roomID, agentSessionID string) (Session, Adapter, error) {
|
||||
session, ok := c.get(strings.TrimSpace(roomID), strings.TrimSpace(agentSessionID))
|
||||
if !ok {
|
||||
return Session{}, nil, ErrSessionNotFound
|
||||
}
|
||||
adapter := c.adapter(session.Provider)
|
||||
if adapter == nil {
|
||||
return Session{}, nil, fmt.Errorf("unsupported agent session provider %q", session.Provider)
|
||||
}
|
||||
return session, adapter, nil
|
||||
}
|
||||
|
||||
func (c *Controller) get(roomID, agentSessionID string) (Session, bool) {
|
||||
if c == nil {
|
||||
return Session{}, false
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
key := sessionKey(roomID, agentSessionID)
|
||||
session, ok := c.sessions[key]
|
||||
if ok {
|
||||
session = c.reconcileSessionStatusLocked(key, session)
|
||||
c.sessions[key] = session
|
||||
}
|
||||
return session, ok
|
||||
}
|
||||
|
||||
func (c *Controller) acquireLifecycleLock(roomID, agentSessionID string) func() {
|
||||
release, _ := c.acquireLifecycleLockContext(context.Background(), roomID, agentSessionID)
|
||||
return release
|
||||
}
|
||||
|
||||
func (c *Controller) acquireLifecycleLockContext(ctx context.Context, roomID, agentSessionID string) (func(), error) {
|
||||
if c == nil {
|
||||
return func() {}, nil
|
||||
}
|
||||
key := sessionKey(strings.TrimSpace(roomID), strings.TrimSpace(agentSessionID))
|
||||
c.mu.Lock()
|
||||
lock := c.lifecycleLocks[key]
|
||||
if lock == nil {
|
||||
lock = &controllerLifecycleLock{gate: make(chan struct{}, 1)}
|
||||
lock.gate <- struct{}{}
|
||||
c.lifecycleLocks[key] = lock
|
||||
}
|
||||
lock.refs++
|
||||
c.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
c.releaseLifecycleLockReference(key, lock)
|
||||
return func() {}, ctx.Err()
|
||||
case <-lock.gate:
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
lock.gate <- struct{}{}
|
||||
c.releaseLifecycleLockReference(key, lock)
|
||||
return func() {}, err
|
||||
}
|
||||
return func() {
|
||||
lock.gate <- struct{}{}
|
||||
c.releaseLifecycleLockReference(key, lock)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Controller) releaseLifecycleLockReference(key string, lock *controllerLifecycleLock) {
|
||||
c.mu.Lock()
|
||||
lock.refs--
|
||||
if lock.refs <= 0 && c.lifecycleLocks[key] == lock {
|
||||
delete(c.lifecycleLocks, key)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Controller) acquireStartupLockContext(
|
||||
ctx context.Context,
|
||||
roomID string,
|
||||
agentSessionID string,
|
||||
provider string,
|
||||
) (func(), error) {
|
||||
if c == nil {
|
||||
return func() {}, nil
|
||||
}
|
||||
key := startupLockKey{
|
||||
roomID: strings.TrimSpace(roomID),
|
||||
agentSessionID: strings.TrimSpace(agentSessionID),
|
||||
}
|
||||
if key.agentSessionID == "" {
|
||||
key.provider = strings.TrimSpace(provider)
|
||||
}
|
||||
c.mu.Lock()
|
||||
lock := c.startupLocks[key]
|
||||
if lock == nil {
|
||||
lock = &controllerLifecycleLock{
|
||||
gate: make(chan struct{}, 1),
|
||||
startupOperations: make(map[chan struct{}]struct{}),
|
||||
}
|
||||
lock.gate <- struct{}{}
|
||||
c.startupLocks[key] = lock
|
||||
}
|
||||
operationDone := make(chan struct{})
|
||||
lock.startupOperations[operationDone] = struct{}{}
|
||||
lock.refs++
|
||||
c.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
c.releaseStartupLockReference(key, lock, operationDone)
|
||||
return func() {}, ctx.Err()
|
||||
case <-lock.gate:
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
lock.gate <- struct{}{}
|
||||
c.releaseStartupLockReference(key, lock, operationDone)
|
||||
return func() {}, err
|
||||
}
|
||||
return func() {
|
||||
lock.gate <- struct{}{}
|
||||
c.releaseStartupLockReference(key, lock, operationDone)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Controller) releaseStartupLockReference(
|
||||
key startupLockKey,
|
||||
lock *controllerLifecycleLock,
|
||||
operationDone chan struct{},
|
||||
) {
|
||||
c.mu.Lock()
|
||||
if _, ok := lock.startupOperations[operationDone]; ok {
|
||||
delete(lock.startupOperations, operationDone)
|
||||
close(operationDone)
|
||||
}
|
||||
lock.refs--
|
||||
if lock.refs <= 0 && c.startupLocks[key] == lock {
|
||||
delete(c.startupLocks, key)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Controller) findStartSession(
|
||||
roomID,
|
||||
agentTargetID,
|
||||
provider,
|
||||
cwd,
|
||||
title string,
|
||||
settings SessionSettings,
|
||||
providerTargetRef map[string]any,
|
||||
) (Session, bool) {
|
||||
if c == nil {
|
||||
return Session{}, false
|
||||
}
|
||||
roomID = strings.TrimSpace(roomID)
|
||||
agentTargetID = strings.TrimSpace(agentTargetID)
|
||||
provider = strings.TrimSpace(provider)
|
||||
cwd = strings.TrimSpace(cwd)
|
||||
title = strings.TrimSpace(title)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, session := range c.sessions {
|
||||
session = c.reconcileSessionStatusLocked(sessionKey(session.RoomID, session.AgentSessionID), session)
|
||||
if strings.TrimSpace(session.RoomID) != roomID {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(session.Provider) != provider {
|
||||
continue
|
||||
}
|
||||
if agentTargetID != "" {
|
||||
if strings.TrimSpace(session.AgentTargetID) != agentTargetID {
|
||||
continue
|
||||
}
|
||||
} else if strings.TrimSpace(session.AgentTargetID) != "" {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(session.CWD) != cwd {
|
||||
continue
|
||||
}
|
||||
if !providerTargetRefsEqual(session.ProviderTargetRef, providerTargetRef) {
|
||||
continue
|
||||
}
|
||||
if title != "" && strings.TrimSpace(session.Title) != title {
|
||||
continue
|
||||
}
|
||||
existingSettings := normalizeSessionSettings(session.Settings, session.Provider, session.PermissionModeID)
|
||||
if existingSettings.PermissionModeID != settings.PermissionModeID ||
|
||||
existingSettings.Model != settings.Model ||
|
||||
existingSettings.ReasoningEffort != settings.ReasoningEffort ||
|
||||
existingSettings.PlanMode != settings.PlanMode {
|
||||
continue
|
||||
}
|
||||
switch session.Status {
|
||||
case SessionStatusCanceled, SessionStatusFailed, SessionStatusCompleted:
|
||||
continue
|
||||
default:
|
||||
return session, true
|
||||
}
|
||||
}
|
||||
return Session{}, false
|
||||
}
|
||||
|
||||
func providerTargetRefsEqual(left, right map[string]any) bool {
|
||||
if len(left) == 0 && len(right) == 0 {
|
||||
return true
|
||||
}
|
||||
return reflect.DeepEqual(left, right)
|
||||
}
|
||||
|
||||
func (s Session) SettingsValue() SessionSettings {
|
||||
return normalizeSessionSettings(s.Settings, s.Provider, s.PermissionModeID)
|
||||
}
|
||||
|
||||
func (c *Controller) store(session Session) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
key := sessionKey(session.RoomID, session.AgentSessionID)
|
||||
c.sessions[key] = session
|
||||
c.notifySessionAvailableLocked(key)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Controller) advanceLiveConnectionGeneration(roomID, agentSessionID string) uint64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
key := sessionKey(strings.TrimSpace(roomID), strings.TrimSpace(agentSessionID))
|
||||
c.mu.Lock()
|
||||
c.nextLiveConnectionGeneration++
|
||||
generation := c.nextLiveConnectionGeneration
|
||||
c.liveConnectionGenerations[key] = generation
|
||||
c.mu.Unlock()
|
||||
return generation
|
||||
}
|
||||
|
||||
func (c *Controller) notifySessionAvailableLocked(key string) {
|
||||
waiter := c.sessionAvailabilityWaiters[key]
|
||||
if waiter == nil {
|
||||
return
|
||||
}
|
||||
delete(c.sessionAvailabilityWaiters, key)
|
||||
close(waiter.changed)
|
||||
}
|
||||
|
||||
func (c *Controller) publishPendingConfigOptionsUpdates(session Session) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
roomID := strings.TrimSpace(session.RoomID)
|
||||
agentSessionID := strings.TrimSpace(session.AgentSessionID)
|
||||
if roomID == "" || agentSessionID == "" {
|
||||
return
|
||||
}
|
||||
key := sessionKey(roomID, agentSessionID)
|
||||
c.mu.Lock()
|
||||
pending := c.pendingConfigOptionsUpdates[key]
|
||||
if len(pending) > 0 {
|
||||
delete(c.pendingConfigOptionsUpdates, key)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
c.publishConfigOptionsUpdates(session, pending)
|
||||
}
|
||||
|
||||
func (c *Controller) publishConfigOptionsUpdates(
|
||||
session Session,
|
||||
pending []AgentSessionConfigOptionsUpdate,
|
||||
) {
|
||||
if c == nil || len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
events := make([]StreamEvent, 0, len(pending))
|
||||
for _, update := range pending {
|
||||
update = c.completeConfigOptionsUpdate(session, update)
|
||||
c.recordConfigOptionsUpdate(session, update)
|
||||
events = append(events, configOptionsUpdateStreamEvent(update))
|
||||
}
|
||||
c.publishStreamEvents(session.RoomID, session.AgentSessionID, events)
|
||||
c.enqueueSessionSnapshotReport(context.Background(), session)
|
||||
}
|
||||
|
||||
func streamEventTypeCounts(events []StreamEvent) []string {
|
||||
if len(events) == 0 {
|
||||
return nil
|
||||
}
|
||||
types := make([]string, 0, len(events))
|
||||
for _, event := range events {
|
||||
types = append(types, event.EventType)
|
||||
}
|
||||
return summarizeLogValueCounts(types)
|
||||
}
|
||||
|
||||
func (c *Controller) publishAdapterCommandSnapshot(session Session, adapter Adapter) {
|
||||
commandAdapter, ok := adapter.(CommandSnapshotAdapter)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
snapshot, ok := commandAdapter.SessionCommandSnapshot(session)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c.applyCommandSnapshot(session, snapshot)
|
||||
}
|
||||
|
||||
func (c *Controller) publishPendingCommandSnapshot(session Session) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
agentSessionID := strings.TrimSpace(session.AgentSessionID)
|
||||
if agentSessionID == "" {
|
||||
return false
|
||||
}
|
||||
c.mu.Lock()
|
||||
snapshot, ok := c.pendingCommandSnapshots[agentSessionID]
|
||||
if ok {
|
||||
delete(c.pendingCommandSnapshots, agentSessionID)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
c.applyCommandSnapshot(session, snapshot)
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *Controller) applyCommandSnapshot(session Session, snapshot AgentSessionCommandSnapshot) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
roomID := strings.TrimSpace(session.RoomID)
|
||||
agentSessionID := strings.TrimSpace(firstNonEmpty(snapshot.AgentSessionID, session.AgentSessionID))
|
||||
if roomID == "" || agentSessionID == "" {
|
||||
return
|
||||
}
|
||||
snapshot.AgentSessionID = agentSessionID
|
||||
snapshot.Commands = cloneAgentSessionCommands(snapshot.Commands)
|
||||
key := sessionKey(roomID, agentSessionID)
|
||||
c.mu.Lock()
|
||||
if _, ok := c.sessions[key]; !ok {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.commands[key] = snapshot
|
||||
c.mu.Unlock()
|
||||
c.publishStreamEvents(roomID, agentSessionID, []StreamEvent{commandSnapshotStreamEvent(snapshot)})
|
||||
}
|
||||
|
||||
func (c *Controller) applyTurnCommandSnapshot(session Session, turnID string, snapshot AgentSessionCommandSnapshot) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
roomID := strings.TrimSpace(session.RoomID)
|
||||
agentSessionID := strings.TrimSpace(firstNonEmpty(snapshot.AgentSessionID, session.AgentSessionID))
|
||||
turnID = strings.TrimSpace(turnID)
|
||||
if roomID == "" || agentSessionID == "" || turnID == "" {
|
||||
return
|
||||
}
|
||||
snapshot.AgentSessionID = agentSessionID
|
||||
snapshot.Commands = cloneAgentSessionCommands(snapshot.Commands)
|
||||
key := sessionKey(roomID, agentSessionID)
|
||||
c.mu.Lock()
|
||||
active, ok := c.turns[key]
|
||||
if !ok || strings.TrimSpace(active.turnID) != turnID {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if _, ok := c.sessions[key]; !ok {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.commands[key] = snapshot
|
||||
c.mu.Unlock()
|
||||
c.publishStreamEvents(roomID, agentSessionID, []StreamEvent{commandSnapshotStreamEvent(snapshot)})
|
||||
}
|
||||
|
||||
func (c *Controller) applyCommandSnapshotByAgentSessionID(snapshot AgentSessionCommandSnapshot) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
agentSessionID := strings.TrimSpace(snapshot.AgentSessionID)
|
||||
if agentSessionID == "" {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
var session Session
|
||||
found := false
|
||||
publicationPending := false
|
||||
for key, candidate := range c.sessions {
|
||||
if strings.TrimSpace(candidate.AgentSessionID) == agentSessionID {
|
||||
session = candidate
|
||||
found = true
|
||||
publicationPending = c.sessionPublicationPendingLocked(key)
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found || publicationPending {
|
||||
snapshot.AgentSessionID = agentSessionID
|
||||
snapshot.Commands = cloneAgentSessionCommands(snapshot.Commands)
|
||||
c.pendingCommandSnapshots[agentSessionID] = snapshot
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.mu.Unlock()
|
||||
c.applyCommandSnapshot(session, snapshot)
|
||||
}
|
||||
|
||||
func (c *Controller) applySessionEventsByAgentSessionID(agentSessionID string, events []activityshared.Event) {
|
||||
if c == nil || len(events) == 0 {
|
||||
return
|
||||
}
|
||||
agentSessionID = strings.TrimSpace(agentSessionID)
|
||||
if agentSessionID == "" {
|
||||
return
|
||||
}
|
||||
// Read-apply-store atomically: a non-atomic window here lets a background
|
||||
// sink emission overwrite a session another goroutine just settled (lost
|
||||
// update on status/title).
|
||||
c.mu.Lock()
|
||||
var session Session
|
||||
foundKey := ""
|
||||
for key, candidate := range c.sessions {
|
||||
if strings.TrimSpace(candidate.AgentSessionID) == agentSessionID {
|
||||
session = candidate
|
||||
foundKey = key
|
||||
break
|
||||
}
|
||||
}
|
||||
if foundKey == "" {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
stateEvents := eventsOwnedBySession(events, session.AgentSessionID)
|
||||
// Cursor mirrors agent-driven plan entry/exit through a separate settings
|
||||
// path that locks internally. Only break the atomic window when such an
|
||||
// event is actually present, otherwise the unlock re-opens the lost-update
|
||||
// race the surrounding lock guards against.
|
||||
if hasACPCurrentModeUpdatedEvent(stateEvents) {
|
||||
c.mu.Unlock()
|
||||
c.syncCursorPlanModeFromEvents(session, stateEvents)
|
||||
c.mu.Lock()
|
||||
var stillPresent bool
|
||||
session, stillPresent = c.sessions[foundKey]
|
||||
if !stillPresent {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
if session.LifecycleAuthority || eventsCarryAdapterLifecycleSnapshot(stateEvents) {
|
||||
// ADR 0008: copy snapshots and derive purely — no ready-guard, no
|
||||
// reconcile; the snapshot IS the truth.
|
||||
session = applySessionEventsBase(session, stateEvents)
|
||||
session = applyTurnLifecycleSnapshots(session, stateEvents)
|
||||
session.Status = statusForAuthoritySession(session, sessionLevelStatusFromEvents(stateEvents))
|
||||
session.SubmitAvailability = submitAvailabilityForAuthoritySession(session)
|
||||
} else {
|
||||
previousStatus := session.Status
|
||||
session = applySessionEvents(session, stateEvents)
|
||||
session = applyTurnLifecycleFromEvents(session, stateEvents)
|
||||
session.Status = deriveSessionStatusFromEvents(stateEvents, session.Status)
|
||||
// Metadata-only session updates (usage/goal refreshes) default to
|
||||
// ready; while the lifecycle reports an active turn that would flap
|
||||
// the status to idle mid-turn.
|
||||
if session.Status == SessionStatusReady &&
|
||||
session.TurnLifecycle != nil &&
|
||||
session.TurnLifecycle.ActiveTurnID != nil {
|
||||
session.Status = firstNonEmpty(previousStatus, SessionStatusWorking)
|
||||
}
|
||||
if session.TurnLifecycle == nil || session.TurnLifecycle.ActiveTurnID == nil {
|
||||
session = c.reconcileSessionStatusLocked(foundKey, session)
|
||||
}
|
||||
}
|
||||
if shouldAdvanceSessionUpdatedAtFromEvents(stateEvents) {
|
||||
session.UpdatedAtUnixMS = unixMS(now())
|
||||
}
|
||||
c.sessions[foundKey] = session
|
||||
provisional := c.provisionalSessions[foundKey]
|
||||
if provisional && session.IsSideConversation() {
|
||||
c.pendingSideEvents[foundKey] = append(
|
||||
c.pendingSideEvents[foundKey],
|
||||
events...,
|
||||
)
|
||||
}
|
||||
if initialization := c.sessionInitializations[foundKey]; initialization != nil {
|
||||
initialization.events = append(initialization.events, events...)
|
||||
}
|
||||
publicationPending := c.sessionPublicationPendingLocked(foundKey)
|
||||
c.mu.Unlock()
|
||||
if publicationPending {
|
||||
return
|
||||
}
|
||||
// Session-scoped adapter callbacks can carry child terminals after the
|
||||
// owning root Turn emitter has detached. They still cross the same durable
|
||||
// commit-before-publish barrier as terminals emitted by an active Turn.
|
||||
if eventsRequireDurablePublish(events) {
|
||||
if err := c.reportSessionBeforePublish(context.Background(), session, events); err != nil {
|
||||
slog.Error(
|
||||
"agent session sink terminal activity report failed before publish",
|
||||
"event", "agent_session.activity_report.session_sink_terminal_barrier_failed",
|
||||
"room_id", session.RoomID,
|
||||
"agent_session_id", session.AgentSessionID,
|
||||
"error", err,
|
||||
)
|
||||
return
|
||||
}
|
||||
c.publish(session, events)
|
||||
return
|
||||
}
|
||||
c.publish(session, events)
|
||||
c.enqueueSessionReport(context.Background(), session, events)
|
||||
}
|
||||
|
||||
func commandSnapshotStreamEvent(snapshot AgentSessionCommandSnapshot) StreamEvent {
|
||||
return StreamEvent{
|
||||
EventType: StreamEventAvailableCommands,
|
||||
Data: snapshot,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) applyConfigOptionsUpdateByAgentSessionID(update AgentSessionConfigOptionsUpdate) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
agentSessionID := strings.TrimSpace(update.AgentSessionID)
|
||||
if agentSessionID == "" {
|
||||
return
|
||||
}
|
||||
roomID := strings.TrimSpace(update.RoomID)
|
||||
c.mu.Lock()
|
||||
var session Session
|
||||
found := false
|
||||
publicationPending := false
|
||||
if roomID != "" {
|
||||
key := sessionKey(roomID, agentSessionID)
|
||||
if candidate, ok := c.sessions[key]; ok {
|
||||
session = candidate
|
||||
found = true
|
||||
publicationPending = c.sessionPublicationPendingLocked(key)
|
||||
}
|
||||
} else {
|
||||
for key, candidate := range c.sessions {
|
||||
if strings.TrimSpace(candidate.AgentSessionID) == agentSessionID {
|
||||
session = candidate
|
||||
found = true
|
||||
publicationPending = c.sessionPublicationPendingLocked(key)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found || publicationPending {
|
||||
pendingRoomID := firstNonEmpty(roomID, session.RoomID)
|
||||
if pendingRoomID != "" {
|
||||
key := sessionKey(pendingRoomID, agentSessionID)
|
||||
c.pendingConfigOptionsUpdates[key] = append(c.pendingConfigOptionsUpdates[key], update)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.mu.Unlock()
|
||||
update = c.completeConfigOptionsUpdate(session, update)
|
||||
c.recordConfigOptionsUpdate(session, update)
|
||||
c.publishStreamEvents(session.RoomID, session.AgentSessionID, []StreamEvent{
|
||||
configOptionsUpdateStreamEvent(update),
|
||||
})
|
||||
c.enqueueSessionSnapshotReport(context.Background(), session)
|
||||
}
|
||||
|
||||
func (c *Controller) recordConfigOptionsUpdate(session Session, update AgentSessionConfigOptionsUpdate) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
key := sessionKey(session.RoomID, session.AgentSessionID)
|
||||
c.mu.Lock()
|
||||
c.configOptionsUpdates[key] = update
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (*Controller) completeConfigOptionsUpdate(session Session, update AgentSessionConfigOptionsUpdate) AgentSessionConfigOptionsUpdate {
|
||||
if update.RoomID == "" {
|
||||
update.RoomID = session.RoomID
|
||||
}
|
||||
if update.Provider == "" {
|
||||
update.Provider = session.Provider
|
||||
}
|
||||
if update.ProviderSessionID == "" {
|
||||
update.ProviderSessionID = session.ProviderSessionID
|
||||
}
|
||||
if update.OccurredAtUnixMS <= 0 {
|
||||
update.OccurredAtUnixMS = unixMS(now())
|
||||
}
|
||||
return update
|
||||
}
|
||||
|
||||
func configOptionsUpdateStreamEvent(update AgentSessionConfigOptionsUpdate) StreamEvent {
|
||||
return StreamEvent{
|
||||
EventType: StreamEventConfigOptions,
|
||||
Data: update,
|
||||
}
|
||||
}
|
||||
|
||||
func cloneAgentSessionCommands(commands []AgentSessionCommand) []AgentSessionCommand {
|
||||
if len(commands) == 0 {
|
||||
return []AgentSessionCommand{}
|
||||
}
|
||||
out := make([]AgentSessionCommand, len(commands))
|
||||
copy(out, commands)
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user