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
169 lines
4.4 KiB
Go
169 lines
4.4 KiB
Go
package runtime
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"time"
|
|
|
|
agentruntime "github.com/tutti-os/tutti/packages/agent/daemon/runtime"
|
|
)
|
|
|
|
type trackedProcess struct {
|
|
connection agentruntime.ProcessConnection
|
|
cancel context.CancelFunc
|
|
closing bool
|
|
}
|
|
|
|
// ProcessGroup fences process starts and owns every process launched for one
|
|
// connector route. It prevents a late start from escaping route retirement.
|
|
type ProcessGroup struct {
|
|
mu sync.Mutex
|
|
processes map[uint64]trackedProcess
|
|
pendingStarts map[uint64]context.CancelFunc
|
|
nextProcessID uint64
|
|
fenced bool
|
|
}
|
|
|
|
func NewProcessGroup() *ProcessGroup {
|
|
return &ProcessGroup{processes: make(map[uint64]trackedProcess), pendingStarts: make(map[uint64]context.CancelFunc)}
|
|
}
|
|
|
|
func (group *ProcessGroup) Begin(parent context.Context) (context.Context, uint64, bool) {
|
|
group.mu.Lock()
|
|
defer group.mu.Unlock()
|
|
if group.fenced {
|
|
return nil, 0, false
|
|
}
|
|
processContext, cancel := context.WithCancel(parent)
|
|
group.nextProcessID++
|
|
group.pendingStarts[group.nextProcessID] = cancel
|
|
return processContext, group.nextProcessID, true
|
|
}
|
|
|
|
func (group *ProcessGroup) FailStart(processID uint64) {
|
|
group.mu.Lock()
|
|
cancel := group.pendingStarts[processID]
|
|
delete(group.pendingStarts, processID)
|
|
group.mu.Unlock()
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
}
|
|
|
|
func (group *ProcessGroup) CommitStart(processID uint64, connection agentruntime.ProcessConnection) bool {
|
|
group.mu.Lock()
|
|
defer group.mu.Unlock()
|
|
cancel := group.pendingStarts[processID]
|
|
delete(group.pendingStarts, processID)
|
|
if group.fenced || cancel == nil {
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
return false
|
|
}
|
|
group.processes[processID] = trackedProcess{connection: connection, cancel: cancel}
|
|
return true
|
|
}
|
|
|
|
func (group *ProcessGroup) Release(processID uint64, connection agentruntime.ProcessConnection) {
|
|
_ = group.ReleaseWithError(processID, connection)
|
|
}
|
|
|
|
// ReleaseWithError releases an owned process and reports the transport close
|
|
// result. Release remains source-compatible for existing lifecycle callers.
|
|
func (group *ProcessGroup) ReleaseWithError(processID uint64, connection agentruntime.ProcessConnection) error {
|
|
group.mu.Lock()
|
|
current, owned := group.processes[processID]
|
|
if owned && current.connection == connection && !current.closing {
|
|
delete(group.processes, processID)
|
|
} else {
|
|
owned = false
|
|
}
|
|
group.mu.Unlock()
|
|
if owned {
|
|
current.cancel()
|
|
return connection.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (group *ProcessGroup) Fence() {
|
|
group.mu.Lock()
|
|
group.fenced = true
|
|
for processID, cancel := range group.pendingStarts {
|
|
cancel()
|
|
delete(group.pendingStarts, processID)
|
|
}
|
|
group.mu.Unlock()
|
|
}
|
|
|
|
func (group *ProcessGroup) IsFenced() bool {
|
|
group.mu.Lock()
|
|
defer group.mu.Unlock()
|
|
return group.fenced
|
|
}
|
|
|
|
func (group *ProcessGroup) ActiveCount() int {
|
|
group.mu.Lock()
|
|
defer group.mu.Unlock()
|
|
return len(group.processes)
|
|
}
|
|
|
|
func (group *ProcessGroup) Close(deadline time.Time) error {
|
|
if group == nil {
|
|
return nil
|
|
}
|
|
group.Fence()
|
|
group.mu.Lock()
|
|
processes := make(map[uint64]trackedProcess, len(group.processes))
|
|
for processID, process := range group.processes {
|
|
process.closing = true
|
|
group.processes[processID] = process
|
|
processes[processID] = process
|
|
}
|
|
group.mu.Unlock()
|
|
type closeResult struct {
|
|
processID uint64
|
|
err error
|
|
}
|
|
results := make(chan closeResult, len(processes))
|
|
for processID, process := range processes {
|
|
process.cancel()
|
|
go func(processID uint64, connection agentruntime.ProcessConnection) {
|
|
results <- closeResult{processID: processID, err: connection.Close()}
|
|
}(processID, process.connection)
|
|
}
|
|
var closeErrors []error
|
|
for range processes {
|
|
var result closeResult
|
|
if deadline.IsZero() {
|
|
result = <-results
|
|
} else {
|
|
remaining := time.Until(deadline)
|
|
if remaining <= 0 {
|
|
return errors.Join(append(closeErrors, context.DeadlineExceeded)...)
|
|
}
|
|
timer := time.NewTimer(remaining)
|
|
select {
|
|
case result = <-results:
|
|
if !timer.Stop() {
|
|
<-timer.C
|
|
}
|
|
case <-timer.C:
|
|
return errors.Join(append(closeErrors, context.DeadlineExceeded)...)
|
|
}
|
|
}
|
|
if result.err != nil {
|
|
closeErrors = append(closeErrors, result.err)
|
|
continue
|
|
}
|
|
group.mu.Lock()
|
|
if current, exists := group.processes[result.processID]; exists && current.connection == processes[result.processID].connection {
|
|
delete(group.processes, result.processID)
|
|
}
|
|
group.mu.Unlock()
|
|
}
|
|
return errors.Join(closeErrors...)
|
|
}
|