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
280 lines
8.8 KiB
Go
280 lines
8.8 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
|
|
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
|
|
)
|
|
|
|
func (c *Controller) Cancel(ctx context.Context, input CancelInput) (CancelResult, error) {
|
|
rootAgentSessionID := strings.TrimSpace(input.RootAgentSessionID)
|
|
if rootAgentSessionID == "" {
|
|
return CancelResult{}, fmt.Errorf("root agent session id is required")
|
|
}
|
|
targets, err := normalizeCancelTargets(input.Targets)
|
|
if err != nil {
|
|
return CancelResult{}, err
|
|
}
|
|
releaseLifecycleLock := c.acquireLifecycleLock(input.RoomID, rootAgentSessionID)
|
|
defer releaseLifecycleLock()
|
|
|
|
session, adapter, err := c.sessionAndAdapter(input.RoomID, rootAgentSessionID)
|
|
if err != nil {
|
|
return CancelResult{}, err
|
|
}
|
|
reason := strings.TrimSpace(input.Reason)
|
|
requestedRootTurnID := cancelTargetTurnID(targets, rootAgentSessionID)
|
|
slog.Info("agent session cancel requested",
|
|
"event", "agent_session.cancel.requested",
|
|
"room_id", session.RoomID,
|
|
"agent_session_id", session.AgentSessionID,
|
|
"provider", session.Provider,
|
|
"status", session.Status,
|
|
"reason", reason,
|
|
"target_count", len(targets),
|
|
)
|
|
active, ok := c.activeTurn(session.RoomID, session.AgentSessionID)
|
|
adapterTargets := targets
|
|
if requestedRootTurnID != "" && ok && active.turnID != requestedRootTurnID {
|
|
slog.Info("agent session exact turn cancel found a different active turn",
|
|
"event", "agent_session.cancel.turn_mismatch",
|
|
"room_id", session.RoomID,
|
|
"agent_session_id", session.AgentSessionID,
|
|
"provider", session.Provider,
|
|
"requested_turn_id", requestedRootTurnID,
|
|
"active_turn_id", func() string {
|
|
if ok {
|
|
return active.turnID
|
|
}
|
|
return ""
|
|
}(),
|
|
)
|
|
adapterTargets = cancelTargetsWithoutSession(targets, rootAgentSessionID)
|
|
}
|
|
cancelLocalActiveTurn := ok && requestedRootTurnID != "" && active.turnID == requestedRootTurnID && active.cancel != nil
|
|
if len(adapterTargets) == 0 {
|
|
return CancelResult{
|
|
AgentSessionID: session.AgentSessionID,
|
|
TargetAbsent: true,
|
|
}, nil
|
|
}
|
|
adapterResult, err := cancelAdapterTargets(ctx, adapter, session, adapterTargets, reason)
|
|
// Provider cancellation must run while the adapter still owns the live
|
|
// root turn handle. Canceling the controller context first can make an
|
|
// adapter settle and unregister its local turn before it sends the native
|
|
// interrupt, leaving the provider turn running after the canonical turn is
|
|
// canceled. Once the bounded provider call returns, cancel the local Exec
|
|
// context as cleanup regardless of provider success.
|
|
if cancelLocalActiveTurn {
|
|
active.cancel()
|
|
}
|
|
if err != nil {
|
|
if errors.Is(err, ErrProviderStateLost) {
|
|
return CancelResult{
|
|
AgentSessionID: session.AgentSessionID,
|
|
ProviderStateLost: true,
|
|
}, nil
|
|
}
|
|
if errors.Is(err, ErrSessionNoActiveTurn) {
|
|
if ok {
|
|
c.clearActiveTurnIfMatches(session.RoomID, session.AgentSessionID, active.turnID)
|
|
}
|
|
return CancelResult{AgentSessionID: session.AgentSessionID, TargetAbsent: true}, nil
|
|
}
|
|
if errors.Is(err, ErrCancelTargetMismatch) {
|
|
slog.Info("agent session exact cancel delivery is unconfirmed",
|
|
"event", "agent_session.cancel.delivery_unconfirmed",
|
|
"room_id", session.RoomID,
|
|
"agent_session_id", session.AgentSessionID,
|
|
"provider", session.Provider,
|
|
"turn_id", requestedRootTurnID,
|
|
"reason", reason,
|
|
)
|
|
return CancelResult{}, err
|
|
}
|
|
slog.Warn("agent session cancel adapter failed",
|
|
"event", "agent_session.cancel.adapter_failed",
|
|
"room_id", session.RoomID,
|
|
"agent_session_id", session.AgentSessionID,
|
|
"provider", session.Provider,
|
|
"turn_id", requestedRootTurnID,
|
|
"reason", reason,
|
|
"error", err.Error(),
|
|
)
|
|
return CancelResult{}, err
|
|
}
|
|
if len(adapterResult.Events) > 0 {
|
|
c.applySessionEventsByAgentSessionID(session.AgentSessionID, adapterResult.Events)
|
|
}
|
|
confirmedTargets := confirmedCancelTargets(adapterTargets, adapterResult.ConfirmedTargets)
|
|
slog.Info("agent session cancel accepted",
|
|
"event", "agent_session.cancel.accepted",
|
|
"room_id", session.RoomID,
|
|
"agent_session_id", session.AgentSessionID,
|
|
"provider", session.Provider,
|
|
"turn_id", requestedRootTurnID,
|
|
"confirmed_target_count", len(confirmedTargets),
|
|
"reason", reason,
|
|
)
|
|
return CancelResult{
|
|
AgentSessionID: session.AgentSessionID,
|
|
Canceled: len(confirmedTargets) > 0,
|
|
TargetAbsent: len(confirmedTargets) == 0,
|
|
ConfirmedTargets: confirmedTargets,
|
|
}, nil
|
|
}
|
|
|
|
func normalizeCancelTargets(targets []CancelTarget) ([]CancelTarget, error) {
|
|
result := make([]CancelTarget, 0, len(targets))
|
|
seen := make(map[string]struct{}, len(targets))
|
|
for _, target := range targets {
|
|
target.AgentSessionID = strings.TrimSpace(target.AgentSessionID)
|
|
target.TurnID = strings.TrimSpace(target.TurnID)
|
|
if target.AgentSessionID == "" || target.TurnID == "" {
|
|
return nil, fmt.Errorf("cancel target session and turn ids are required")
|
|
}
|
|
key := target.AgentSessionID + "\x00" + target.TurnID
|
|
if _, exists := seen[key]; exists {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
result = append(result, target)
|
|
}
|
|
if len(result) == 0 {
|
|
return nil, fmt.Errorf("at least one cancel target is required")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func cancelTargetTurnID(targets []CancelTarget, agentSessionID string) string {
|
|
for _, target := range targets {
|
|
if target.AgentSessionID == agentSessionID {
|
|
return target.TurnID
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func cancelTargetsWithoutSession(targets []CancelTarget, agentSessionID string) []CancelTarget {
|
|
result := make([]CancelTarget, 0, len(targets))
|
|
for _, target := range targets {
|
|
if target.AgentSessionID != agentSessionID {
|
|
result = append(result, target)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func cancelAdapterTargets(ctx context.Context, adapter Adapter, rootSession Session, targets []CancelTarget, reason string) (TargetedCancelResult, error) {
|
|
if targeted, ok := adapter.(TargetedCancelAdapter); ok {
|
|
return targeted.CancelTargets(ctx, rootSession, targets, reason)
|
|
}
|
|
if len(targets) != 1 || targets[0].AgentSessionID != rootSession.AgentSessionID {
|
|
return TargetedCancelResult{}, fmt.Errorf("agent provider %q does not support child turn cancellation", rootSession.Provider)
|
|
}
|
|
events, err := adapter.Cancel(ctx, rootSession, reason)
|
|
if err != nil {
|
|
return TargetedCancelResult{}, err
|
|
}
|
|
return TargetedCancelResult{Events: events, ConfirmedTargets: append([]CancelTarget(nil), targets...)}, nil
|
|
}
|
|
|
|
func confirmedCancelTargets(requested []CancelTarget, confirmed []CancelTarget) []CancelTarget {
|
|
confirmedSet := make(map[string]struct{}, len(confirmed))
|
|
for _, target := range confirmed {
|
|
key := strings.TrimSpace(target.AgentSessionID) + "\x00" + strings.TrimSpace(target.TurnID)
|
|
confirmedSet[key] = struct{}{}
|
|
}
|
|
result := make([]CancelTarget, 0, len(requested))
|
|
for _, target := range requested {
|
|
key := target.AgentSessionID + "\x00" + target.TurnID
|
|
if _, ok := confirmedSet[key]; ok {
|
|
result = append(result, target)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (c *Controller) cancelActiveTurn(roomID, agentSessionID string) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
key := sessionKey(strings.TrimSpace(roomID), strings.TrimSpace(agentSessionID))
|
|
c.mu.Lock()
|
|
active, ok := c.turns[key]
|
|
c.mu.Unlock()
|
|
if ok && active.cancel != nil {
|
|
active.cancel()
|
|
}
|
|
}
|
|
|
|
func (c *Controller) clearActiveTurnIfMatches(roomID, agentSessionID, turnID string) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
key := sessionKey(strings.TrimSpace(roomID), strings.TrimSpace(agentSessionID))
|
|
turnID = strings.TrimSpace(turnID)
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if active, ok := c.turns[key]; ok && strings.TrimSpace(active.turnID) == turnID {
|
|
delete(c.turns, key)
|
|
}
|
|
}
|
|
|
|
func (c *Controller) activeTurn(roomID, agentSessionID string) (activeTurn, bool) {
|
|
if c == nil {
|
|
return activeTurn{}, false
|
|
}
|
|
key := sessionKey(strings.TrimSpace(roomID), strings.TrimSpace(agentSessionID))
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
active, ok := c.turns[key]
|
|
return active, ok
|
|
}
|
|
|
|
func (c *Controller) reconcileSessionStatusLocked(key string, session Session) Session {
|
|
if c == nil {
|
|
return session
|
|
}
|
|
if _, hasActiveTurn := c.turns[key]; hasActiveTurn {
|
|
return session
|
|
}
|
|
if sessionHasLiveTurnLifecycle(session) {
|
|
return session
|
|
}
|
|
return reconcileFinishedTurnStatus(session)
|
|
}
|
|
|
|
func reconcileFinishedTurnStatus(session Session) Session {
|
|
if sessionHasLiveTurnLifecycle(session) {
|
|
return session
|
|
}
|
|
if sessionStatusShouldReconcileToReady(session.Status) {
|
|
session.Status = SessionStatusReady
|
|
}
|
|
return session
|
|
}
|
|
|
|
func sessionStatusShouldReconcileToReady(status string) bool {
|
|
switch strings.TrimSpace(strings.ToLower(status)) {
|
|
case "", "created", "submitted", "running", "streaming", SessionStatusWorking:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func turnEventsAreTerminal(events []activityshared.Event) bool {
|
|
for _, event := range events {
|
|
switch event.Type {
|
|
case activityshared.EventTurnCompleted, activityshared.EventTurnFailed:
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|