Files
agent-radar 2ed3415fb7
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
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

337 lines
12 KiB
Go

package agentruntime
import (
"context"
"errors"
"strings"
"sync"
"time"
agentsessionstore "github.com/tutti-os/tutti/packages/agent/daemon/activity"
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
replay "github.com/tutti-os/tutti/packages/agent/session-replay"
)
var (
ErrSessionNotFound = errors.New("agent session not found")
ErrSessionSettingsRequireNewSession = errors.New("agent session settings update requires a new session to preserve context")
ErrSessionActiveTurn = errors.New("agent session already has an active turn")
ErrSessionForkUnsupported = errors.New("agent session fork is unsupported")
ErrSideConversationUnsupported = errors.New("agent side conversation is unsupported")
ErrSideConversationConflict = errors.New("agent side conversation identity conflicts with an existing session")
ErrSideConversationExpired = errors.New("agent side conversation has expired")
)
const defaultStreamingReportCoalesceWindow = 50 * time.Millisecond
type execMetadataContextKey struct{}
type Controller struct {
mu sync.Mutex
streamObserverMu sync.RWMutex
providerObservationMu sync.RWMutex
goalControlObserverMu sync.RWMutex
sessions map[string]Session
liveConnectionGenerations map[string]uint64
nextLiveConnectionGeneration uint64
sessionAvailabilityWaiters map[string]*sessionAvailabilityWaiter
adapters map[string]Adapter
adapterResolver AdapterResolver
turns map[string]activeTurn
commands map[string]AgentSessionCommandSnapshot
pendingCommandSnapshots map[string]AgentSessionCommandSnapshot
configOptionsUpdates map[string]AgentSessionConfigOptionsUpdate
pendingConfigOptionsUpdates map[string][]AgentSessionConfigOptionsUpdate
provisionalSessions map[string]bool
pendingSideEvents map[string][]activityshared.Event
sessionInitializations map[string]*controllerSessionInitialization
goalGenerationFences map[string]*controllerGoalGenerationFenceRegistry
startupLocks map[startupLockKey]*controllerLifecycleLock
lifecycleLocks map[string]*controllerLifecycleLock
hub *EventHub
reporter DurableActivityReporter
reportQueue *reportRequestQueue
providerGoalAdoptionSink ProviderGoalAdoptionSink
terminalInteractions terminalInteractiveDispositionStore
streamObserver RuntimeStreamEventObserver
providerObservationObserver ProviderObservationObserver
goalControlObserver GoalControlLifecycleObserver
sideStreamObserver SideStreamEventObserver
}
// RuntimeStreamEventObserver receives the ordered precommit stream projection
// synchronously with the per-session EventHub fan-out. Implementations must
// remain lightweight: the ordering guarantee prevents a durable terminal
// confirmation from overtaking its preceding optimistic deltas.
type RuntimeStreamEventObserver interface {
ObserveRuntimeStreamEvents(
context.Context,
string,
string,
[]StreamEvent,
) error
}
// SideStreamEventObserver receives transient Side events and must release
// bridge-local ordering state when the ephemeral identity expires or closes.
type SideStreamEventObserver interface {
RuntimeStreamEventObserver
ForgetSideConversation(string, string)
}
// RuntimeStreamEventFilter can be implemented by an observer that validates
// event identity before the same stream is delivered to daemon-local
// subscribers. Observation and filtering are separate so an external
// projection can still emit a reconcile signal while the invalid event is
// withheld from the local session fan-out.
type RuntimeStreamEventFilter interface {
FilterRuntimeStreamEvents(string, string, []StreamEvent) []StreamEvent
}
// ProviderObservationObserver receives capture-only provider observations
// synchronously before their durable activity report is queued.
type ProviderObservationObserver interface {
ObserveProviderObservations(
context.Context,
string,
string,
[]replay.ProviderObservationBatch,
) error
}
// GoalControlAppliedObservation is exact provider evidence that one durable
// Goal operation was consumed by the runtime. The Host validates every fence
// before completing the operation.
type GoalControlAppliedObservation struct {
WorkspaceID string
AgentSessionID string
OperationID string
Revision int64
RepairEpoch int64
Action string
ProviderTurnID string
Observed map[string]any
OccurredAtUnixMS int64
ExecutionPending bool
}
type GoalControlLifecycleObserver interface {
ObserveGoalControlApplied(context.Context, GoalControlAppliedObservation) error
}
type controllerLifecycleLock struct {
gate chan struct{}
refs int
startupOperations map[chan struct{}]struct{}
}
// controllerSessionInitialization retains every provider observation emitted
// between Runtime start and Host's canonical initialization commit. The map
// entry itself is the publication barrier; it remains present while Publish
// drains events and side-channel snapshots so later observations cannot
// overtake the initial Session report.
type controllerSessionInitialization struct {
events []activityshared.Event
initialEventsPublished bool
commandSnapshotResolved bool
}
// startupLockKey uses agentSessionID for normal Host calls. Provider is set
// only for the legacy path that asks Controller.Start to allocate the ID.
type startupLockKey struct {
roomID string
agentSessionID string
provider string
}
type sessionAvailabilityWaiter struct {
changed chan struct{}
refs int
}
type activeTurn struct {
turnID string
cancel context.CancelFunc
tuttiModeSnapshot *TuttiModeTurnSnapshot
openCallIDs map[string]struct{}
pendingTerminalEvents []activityshared.Event
}
type reportRequest struct {
ctx context.Context
report agentsessionstore.ReportActivityInput
submitProvenance bool
barrier bool
done chan error
}
type ReleaseIdleLiveSessionsInput struct {
IdleAfter time.Duration
Now time.Time
Limit int
}
type ReleaseIdleLiveSessionsResult struct {
Scanned int
Released int
SkippedFresh int
SkippedActiveTurn int
SkippedUnsupported int
SkippedNotLive int
SkippedBusy int
SkippedCleanupBudget int
Failed int
ResourceCleanupAttempted int
ResourceCleanupCleaned int
ResourceCleanupFailed int
}
// CloseAllLiveSessionsResult reports the outcome of CloseAllLiveSessions.
type CloseAllLiveSessionsResult struct {
// Scanned counts sessions whose adapter reported a live provider process.
Scanned int
Closed int
SkippedCleanupBudget int
Failed int
ResourceCleanupAttempted int
ResourceCleanupCleaned int
ResourceCleanupFailed int
}
// DisconnectRuntimeSessionResult reports whether a live provider connection
// was released. The Controller session record and provider session id remain.
type DisconnectRuntimeSessionResult struct {
Disconnected bool
}
// RuntimeDisconnectTarget identifies one exact live-connection incarnation.
// A stale target must never disconnect a later Resume for the same Session.
type RuntimeDisconnectTarget struct {
RoomID string
AgentSessionID string
ConnectionGeneration uint64
}
type asyncActivityReporter interface {
DurableActivityReporter
AsyncActivityReporter()
}
func NewController(adapters []Adapter, reporter DurableActivityReporter) *Controller {
return NewControllerWithAdapterResolver(adapters, reporter, nil)
}
func NewControllerWithAdapterResolver(adapters []Adapter, reporter DurableActivityReporter, resolver AdapterResolver) *Controller {
byProvider := make(map[string]Adapter, len(adapters))
for _, adapter := range adapters {
if adapter == nil {
continue
}
provider := strings.TrimSpace(adapter.Provider())
if provider != "" {
byProvider[provider] = adapter
}
}
controller := &Controller{
sessions: make(map[string]Session),
liveConnectionGenerations: make(map[string]uint64),
sessionAvailabilityWaiters: make(map[string]*sessionAvailabilityWaiter),
adapters: byProvider,
adapterResolver: resolver,
turns: make(map[string]activeTurn),
commands: make(map[string]AgentSessionCommandSnapshot),
pendingCommandSnapshots: make(map[string]AgentSessionCommandSnapshot),
configOptionsUpdates: make(map[string]AgentSessionConfigOptionsUpdate),
pendingConfigOptionsUpdates: make(map[string][]AgentSessionConfigOptionsUpdate),
provisionalSessions: make(map[string]bool),
pendingSideEvents: make(map[string][]activityshared.Event),
sessionInitializations: make(map[string]*controllerSessionInitialization),
goalGenerationFences: make(map[string]*controllerGoalGenerationFenceRegistry),
startupLocks: make(map[startupLockKey]*controllerLifecycleLock),
lifecycleLocks: make(map[string]*controllerLifecycleLock),
hub: NewEventHub(),
reporter: reporter,
}
if reporter != nil {
if _, ok := reporter.(asyncActivityReporter); !ok {
controller.reportQueue = newReportRequestQueue()
go controller.runReportWorker()
}
}
for _, adapter := range byProvider {
controller.configureAdapter(adapter)
}
return controller
}
func (c *Controller) sessionPublicationPendingLocked(key string) bool {
if c == nil {
return false
}
if c.provisionalSessions[key] {
return true
}
_, pending := c.sessionInitializations[key]
return pending
}
func (c *Controller) configureAdapter(adapter Adapter) {
if adapter == nil {
return
}
if sinkAdapter, ok := adapter.(CommandSnapshotSinkAdapter); ok {
sinkAdapter.SetCommandSnapshotSink(c.applyCommandSnapshotByAgentSessionID)
}
if sinkAdapter, ok := adapter.(SessionEventSinkAdapter); ok {
sinkAdapter.SetSessionEventSink(c.applySessionEventsByAgentSessionID)
}
if sinkAdapter, ok := adapter.(GoalReconcileDurableSinkAdapter); ok {
sinkAdapter.SetGoalReconcileDurableSink(c.reportGoalReconcileDurable)
}
if sinkAdapter, ok := adapter.(GoalProvenanceDurableSinkAdapter); ok {
// Always install the controller boundary. If the configured reporter
// cannot durably bind/lookup provenance, the controller returns an
// explicit error and Codex fails closed instead of silently falling back
// to a restart-unsafe process-local cache.
sinkAdapter.SetGoalProvenanceDurableSink(c)
}
if sinkAdapter, ok := adapter.(ProviderGoalAdoptionSinkAdapter); ok {
sinkAdapter.SetProviderGoalAdoptionSink(c.adoptProviderGoal)
}
if sinkAdapter, ok := adapter.(ConfigOptionsUpdateSinkAdapter); ok {
sinkAdapter.SetConfigOptionsUpdateSink(c.applyConfigOptionsUpdateByAgentSessionID)
}
if sinkAdapter, ok := adapter.(InteractiveDispositionSinkAdapter); ok {
sinkAdapter.SetInteractiveDispositionSink(c.recordTerminalInteractiveDisposition)
}
}
func NewDefaultController(reporter DurableActivityReporter) *Controller {
return NewDefaultControllerWithProcessTransport(reporter, nil)
}
func NewDefaultControllerWithProcessTransport(
reporter DurableActivityReporter,
transport ProcessTransport,
) *Controller {
return NewDefaultControllerWithOptions(reporter, transport, ControllerOptions{
HostMetadata: LegacyHostMetadata(),
})
}
func NewDefaultControllerWithOptions(
reporter DurableActivityReporter,
transport ProcessTransport,
options ControllerOptions,
) *Controller {
host := options.HostMetadata
adapters := newMigratedProviderAdapters(
transport,
host,
options.ProviderCommandResolver,
options.CommandNetworkAccessPolicy,
)
setProviderLaunchPreparer(adapters, options.ProviderLaunchPreparer)
return NewControllerWithAdapterResolver(adapters, reporter, options.AdapterResolver)
}