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

272 lines
9.0 KiB
Go

package agentdaemon
import (
"context"
"errors"
"log/slog"
"strings"
"sync"
"time"
agentruntime "github.com/tutti-os/tutti/packages/agent/daemon/runtime"
)
var ErrHostMetadataRequired = errors.New("agent daemon host metadata is required")
var ErrProcessTransportRequired = errors.New("agent daemon process transport is required")
const (
defaultLiveSessionReaperIdleAfter = 30 * time.Minute
defaultLiveSessionReaperSweepInterval = 5 * time.Minute
// shutdownCloseAllLiveSessionsTimeout bounds how long Runtime.Close waits
// for CloseAllLiveSessions to force-terminate every live provider
// process. Each process close is already internally bounded (SIGTERM,
// then SIGKILL after a short grace period; see localProcessConnection),
// so this is a backstop against an unexpectedly large number of live
// sessions, not the primary timeout mechanism.
shutdownCloseAllLiveSessionsTimeout = 15 * time.Second
)
type ActivityReporter = agentruntime.ActivityReporter
type DurableActivityReporter = agentruntime.DurableActivityReporter
type Adapter = agentruntime.Adapter
type ClientInfo = agentruntime.ClientInfo
type Controller = agentruntime.Controller
type HostMetadata = agentruntime.HostMetadata
type ProcessTransport = agentruntime.ProcessTransport
type RecordingProcessTransport = agentruntime.RecordingProcessTransport
type ReplayPlaybackState = agentruntime.ReplayPlaybackState
type ReplayProcessTransport = agentruntime.ReplayProcessTransport
type SessionReplayProcessRegistration = agentruntime.SessionReplayProcessRegistration
type SessionReplayProcessTransport = agentruntime.SessionReplayProcessTransport
type SessionRecordingProcessTransport = agentruntime.SessionRecordingProcessTransport
type ProviderCommand = agentruntime.ProviderCommand
type ProviderCommandResolver = agentruntime.ProviderCommandResolver
type ProviderLaunchPrepareInput = agentruntime.ProviderLaunchPrepareInput
type ProviderLaunchPrepareResult = agentruntime.ProviderLaunchPrepareResult
type ProviderLaunchPreparer = agentruntime.ProviderLaunchPreparer
type ProviderLaunchPreparerAdapter = agentruntime.ProviderLaunchPreparerAdapter
type AdapterResolver = agentruntime.AdapterResolver
type CommandNetworkAccessPolicy = agentruntime.CommandNetworkAccessPolicy
type Config struct {
Reporter DurableActivityReporter
ProcessTransport ProcessTransport
HostMetadata HostMetadata
ProviderCommandResolver ProviderCommandResolver
ProviderLaunchPreparer ProviderLaunchPreparer
AdapterResolver AdapterResolver
CommandNetworkAccessPolicy CommandNetworkAccessPolicy
Adapters []Adapter
LiveSessionReaper LiveSessionReaperConfig
}
type LiveSessionReaperConfig struct {
Enabled *bool
IdleAfter time.Duration
SweepInterval time.Duration
}
type Runtime struct {
controller *Controller
processTransport ProcessTransport
cancel context.CancelFunc
done chan struct{}
closeOnce sync.Once
}
func NewRuntime(config Config) (*Runtime, error) {
var controller *Controller
if len(config.Adapters) > 0 {
agentruntime.ApplyProviderLaunchPreparer(config.Adapters, config.ProviderLaunchPreparer)
controller = agentruntime.NewController(config.Adapters, config.Reporter)
} else {
if !hasCompleteHostMetadata(config.HostMetadata) {
return nil, ErrHostMetadataRequired
}
if config.ProcessTransport == nil {
return nil, ErrProcessTransportRequired
}
controller = agentruntime.NewDefaultControllerWithOptions(
config.Reporter,
config.ProcessTransport,
agentruntime.ControllerOptions{
HostMetadata: config.HostMetadata,
ProviderCommandResolver: config.ProviderCommandResolver,
ProviderLaunchPreparer: config.ProviderLaunchPreparer,
AdapterResolver: config.AdapterResolver,
CommandNetworkAccessPolicy: config.CommandNetworkAccessPolicy,
},
)
}
runtime := &Runtime{
controller: controller,
processTransport: config.ProcessTransport,
}
runtime.startLiveSessionReaper(config.LiveSessionReaper)
return runtime, nil
}
func NewLocalProcessTransport() ProcessTransport {
return agentruntime.NewLocalProcessTransport()
}
func NewRecordingProcessTransport(
base ProcessTransport,
directory string,
) (*RecordingProcessTransport, error) {
return agentruntime.NewRecordingProcessTransport(base, directory)
}
func NewReplayProcessTransport(directory string) (*ReplayProcessTransport, error) {
return agentruntime.NewReplayProcessTransport(directory)
}
func NewSessionReplayProcessTransport(
registrations []SessionReplayProcessRegistration,
) (*SessionReplayProcessTransport, error) {
return agentruntime.NewSessionReplayProcessTransport(registrations)
}
func NewSessionRecordingProcessTransport(
base ProcessTransport,
) (*SessionRecordingProcessTransport, error) {
return agentruntime.NewSessionRecordingProcessTransport(base)
}
func MustRuntime(config Config) *Runtime {
runtime, err := NewRuntime(config)
if err != nil {
panic(err)
}
return runtime
}
func (r *Runtime) Controller() *Controller {
if r == nil {
return nil
}
return r.controller
}
func (r *Runtime) Close() {
if r == nil {
return
}
r.closeOnce.Do(func() {
r.closeAllLiveSessions()
if finalizer, ok := r.processTransport.(interface{ Finalize() error }); ok {
if err := finalizer.Finalize(); err != nil {
slog.Error(
"agent process transport finalization failed",
"event", "agent_session.process_transport.finalize_failed",
"error", err,
)
}
}
if r.cancel != nil {
r.cancel()
}
if r.done != nil {
<-r.done
}
})
}
// closeAllLiveSessions force-terminates every live provider process, including
// Codex app-server, the Claude Code SDK sidecar, and subprocess adapters for
// providers that use ACP,
// before the daemon process exits. A spawned subprocess is not killed automatically
// just because tuttid exits — it is reparented to init and keeps running —
// so without this step, every daemon shutdown (or a desktop-parent-monitor
// triggered shutdown after the host app disappears) would orphan any
// in-flight provider processes, leaving them running unmanaged against the
// session's working directory indefinitely.
func (r *Runtime) closeAllLiveSessions() {
if r == nil || r.controller == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), shutdownCloseAllLiveSessionsTimeout)
defer cancel()
result := r.controller.CloseAllLiveSessions(ctx)
if result.Scanned == 0 && result.ResourceCleanupAttempted == 0 {
return
}
slog.Info("agent live session shutdown close completed",
"event", "agent_session.shutdown_close.completed",
"scanned", result.Scanned,
"closed", result.Closed,
"skipped_cleanup_budget", result.SkippedCleanupBudget,
"failed", result.Failed,
"resource_cleanup_attempted", result.ResourceCleanupAttempted,
"resource_cleanup_cleaned", result.ResourceCleanupCleaned,
"resource_cleanup_failed", result.ResourceCleanupFailed,
)
}
func (r *Runtime) startLiveSessionReaper(config LiveSessionReaperConfig) {
if r == nil || r.controller == nil || !liveSessionReaperEnabled(config) {
return
}
idleAfter := config.IdleAfter
if idleAfter <= 0 {
idleAfter = defaultLiveSessionReaperIdleAfter
}
sweepInterval := config.SweepInterval
if sweepInterval <= 0 {
sweepInterval = defaultLiveSessionReaperSweepInterval
}
ctx, cancel := context.WithCancel(context.Background())
r.cancel = cancel
r.done = make(chan struct{})
go func() {
defer close(r.done)
ticker := time.NewTicker(sweepInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
result := r.controller.ReleaseIdleLiveSessions(ctx, agentruntime.ReleaseIdleLiveSessionsInput{
IdleAfter: idleAfter,
Now: time.Now(),
})
if result.Scanned == 0 && result.ResourceCleanupAttempted == 0 {
continue
}
slog.Info("agent live session reaper sweep completed",
"event", "agent_session.live_reaper.sweep_completed",
"scanned", result.Scanned,
"released", result.Released,
"skipped_fresh", result.SkippedFresh,
"skipped_active_turn", result.SkippedActiveTurn,
"skipped_unsupported", result.SkippedUnsupported,
"skipped_not_live", result.SkippedNotLive,
"skipped_busy", result.SkippedBusy,
"skipped_cleanup_budget", result.SkippedCleanupBudget,
"failed", result.Failed,
"resource_cleanup_attempted", result.ResourceCleanupAttempted,
"resource_cleanup_cleaned", result.ResourceCleanupCleaned,
"resource_cleanup_failed", result.ResourceCleanupFailed,
)
}
}
}()
}
func liveSessionReaperEnabled(config LiveSessionReaperConfig) bool {
if config.Enabled == nil {
return true
}
return *config.Enabled
}
func hasCompleteHostMetadata(host HostMetadata) bool {
return strings.TrimSpace(host.ClientInfo.Name) != "" &&
strings.TrimSpace(host.ClientInfo.Title) != "" &&
strings.TrimSpace(host.ClientInfo.Version) != "" &&
strings.TrimSpace(host.WorkspaceEnvName) != "" &&
strings.TrimSpace(host.OpenClawSessionKeyPrefix) != ""
}