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
815 lines
26 KiB
Go
815 lines
26 KiB
Go
package agenthost
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
storesqlite "github.com/tutti-os/tutti/packages/agent/store-sqlite"
|
|
)
|
|
|
|
const sessionForkCheckpointTimeout = 10 * time.Second
|
|
|
|
// ForkSession forks provider state through an inclusive canonical Turn and
|
|
// then atomically installs the corresponding canonical child root session.
|
|
// RequestID is the replay key; TargetAgentSessionID is reserved at prepare.
|
|
func (h *Host) ForkSession(
|
|
ctx context.Context,
|
|
input ForkSessionInput,
|
|
) (ForkSessionResult, error) {
|
|
normalizeForkSessionInput(&input)
|
|
if h == nil || h.sessionForks == nil || h.sessionForkRuntime == nil ||
|
|
input.WorkspaceID == "" || input.SourceAgentSessionID == "" ||
|
|
input.TargetAgentSessionID == "" || input.RequestID == "" ||
|
|
input.Point.Kind != SessionForkPointThroughTurn ||
|
|
input.Point.TurnID == "" ||
|
|
input.SourceAgentSessionID == input.TargetAgentSessionID {
|
|
return ForkSessionResult{}, ErrInvalidArgument
|
|
}
|
|
return h.forkSessionSerialized(ctx, input)
|
|
}
|
|
|
|
func (h *Host) forkSessionSerialized(
|
|
ctx context.Context,
|
|
input ForkSessionInput,
|
|
) (ForkSessionResult, error) {
|
|
requestHash, err := hashForkSessionInput(input)
|
|
if err != nil {
|
|
return ForkSessionResult{}, err
|
|
}
|
|
if existing, found, err := h.sessionForks.GetSessionForkOperationByRequest(
|
|
ctx, input.WorkspaceID, input.RequestID,
|
|
); err != nil {
|
|
return ForkSessionResult{}, err
|
|
} else if found {
|
|
if existing.RequestHash != requestHash {
|
|
return ForkSessionResult{Operation: existing}, storesqlite.ErrSessionForkRequestConflict
|
|
}
|
|
if input.Asynchronous &&
|
|
existing.Status != storesqlite.SessionForkStatusCommitted {
|
|
return ForkSessionResult{Operation: existing}, nil
|
|
}
|
|
return h.processSessionForkOperation(ctx, existing)
|
|
}
|
|
if _, found, err := h.sessionForks.GetSessionForkSource(
|
|
ctx, input.WorkspaceID, input.SourceAgentSessionID,
|
|
); err != nil {
|
|
return ForkSessionResult{}, err
|
|
} else if !found {
|
|
return ForkSessionResult{}, ErrSessionNotFound
|
|
}
|
|
boundary, supported, err := h.sessionForks.CheckSessionForkThroughTurn(
|
|
ctx, input.WorkspaceID, input.SourceAgentSessionID, input.Point.TurnID,
|
|
)
|
|
if err != nil {
|
|
return ForkSessionResult{}, err
|
|
}
|
|
if !supported &&
|
|
boundary.RejectionReason == storesqlite.SessionForkBoundaryReasonProviderTurnMissing {
|
|
if err := h.recoverSessionForkTurnBinding(
|
|
ctx,
|
|
input.WorkspaceID,
|
|
input.SourceAgentSessionID,
|
|
input.Point.TurnID,
|
|
); err != nil {
|
|
slog.Warn(
|
|
"agent session fork provider turn binding recovery failed",
|
|
"workspace_id", input.WorkspaceID,
|
|
"agent_session_id", input.SourceAgentSessionID,
|
|
"turn_id", input.Point.TurnID,
|
|
"boundary_reason", boundary.RejectionReason,
|
|
"error", err,
|
|
)
|
|
if rejectionErr := boundary.RejectionError(); rejectionErr != nil {
|
|
return ForkSessionResult{}, rejectionErr
|
|
}
|
|
return ForkSessionResult{}, storesqlite.ErrSessionForkTurnState
|
|
}
|
|
boundary, supported, err = h.sessionForks.CheckSessionForkThroughTurn(
|
|
ctx,
|
|
input.WorkspaceID,
|
|
input.SourceAgentSessionID,
|
|
input.Point.TurnID,
|
|
)
|
|
if err != nil {
|
|
return ForkSessionResult{}, err
|
|
}
|
|
}
|
|
if !supported {
|
|
if rejectionErr := boundary.RejectionError(); rejectionErr != nil {
|
|
return ForkSessionResult{}, rejectionErr
|
|
}
|
|
return ForkSessionResult{}, fmt.Errorf(
|
|
"resolve selected provider fork binding for turn %q: %w",
|
|
input.Point.TurnID,
|
|
storesqlite.ErrSessionForkTurnState,
|
|
)
|
|
}
|
|
runtimeSource, err := h.sessionForkRuntimeSource(ctx, boundary.Session)
|
|
if err != nil {
|
|
return ForkSessionResult{}, err
|
|
}
|
|
if strings.TrimSpace(runtimeSource.ProviderSessionID) !=
|
|
strings.TrimSpace(boundary.Session.ProviderSessionID) {
|
|
return ForkSessionResult{}, ErrSessionForkFailed
|
|
}
|
|
forkable, err := h.sessionForkRuntime.CanForkProviderTurn(
|
|
ctx,
|
|
RuntimeProviderTurnForkabilityInput{
|
|
Source: cloneSessionForkRuntimeSource(runtimeSource),
|
|
CanonicalTurnID: boundary.Turn.TurnID,
|
|
ProviderTurnID: boundary.Turn.RootProviderTurnID,
|
|
ProviderTurnBindingJSON: append([]byte(nil), boundary.Turn.ProviderTurnBindingJSON...),
|
|
},
|
|
)
|
|
if err != nil {
|
|
return ForkSessionResult{}, err
|
|
}
|
|
if !forkable {
|
|
if recoveryErr := h.recoverSessionForkTurnBinding(
|
|
ctx,
|
|
input.WorkspaceID,
|
|
input.SourceAgentSessionID,
|
|
input.Point.TurnID,
|
|
); recoveryErr == nil {
|
|
boundary, supported, err = h.sessionForks.CheckSessionForkThroughTurn(
|
|
ctx,
|
|
input.WorkspaceID,
|
|
input.SourceAgentSessionID,
|
|
input.Point.TurnID,
|
|
)
|
|
if err != nil {
|
|
return ForkSessionResult{}, err
|
|
}
|
|
if supported {
|
|
forkable, err = h.sessionForkRuntime.CanForkProviderTurn(
|
|
ctx,
|
|
RuntimeProviderTurnForkabilityInput{
|
|
Source: cloneSessionForkRuntimeSource(runtimeSource),
|
|
CanonicalTurnID: boundary.Turn.TurnID,
|
|
ProviderTurnID: boundary.Turn.RootProviderTurnID,
|
|
ProviderTurnBindingJSON: append([]byte(nil), boundary.Turn.ProviderTurnBindingJSON...),
|
|
},
|
|
)
|
|
if err != nil {
|
|
return ForkSessionResult{}, err
|
|
}
|
|
}
|
|
}
|
|
if !forkable {
|
|
return ForkSessionResult{}, storesqlite.ErrSessionForkTurnState
|
|
}
|
|
}
|
|
descriptor, err := h.sessionForkRuntime.ResolveSessionFork(
|
|
ctx,
|
|
cloneSessionForkRuntimeSource(runtimeSource),
|
|
)
|
|
if err != nil {
|
|
return ForkSessionResult{}, err
|
|
}
|
|
normalizeSessionForkDriverDescriptor(&descriptor)
|
|
if !descriptor.ThroughTurn || descriptor.Kind == "" || descriptor.Version == "" ||
|
|
!validSessionForkStateBindingMode(
|
|
descriptor.StateBindingMode,
|
|
h.sessionForkState,
|
|
runtimeSource.Provider,
|
|
) {
|
|
return ForkSessionResult{}, ErrSessionForkUnsupported
|
|
}
|
|
targetContext, err := h.prepareSessionForkTargetContext(
|
|
ctx, boundary.Session, runtimeSource,
|
|
)
|
|
if err != nil {
|
|
return ForkSessionResult{}, err
|
|
}
|
|
operation, _, err := h.sessionForks.PrepareSessionFork(ctx, storesqlite.SessionForkPrepare{
|
|
OperationID: uuid.NewString(),
|
|
WorkspaceID: input.WorkspaceID,
|
|
RequestID: input.RequestID,
|
|
RequestHash: requestHash,
|
|
SourceAgentSessionID: input.SourceAgentSessionID,
|
|
TargetAgentSessionID: input.TargetAgentSessionID,
|
|
SourceTurnID: input.Point.TurnID,
|
|
PointKind: string(input.Point.Kind),
|
|
DriverKind: descriptor.Kind,
|
|
DriverVersion: descriptor.Version,
|
|
TargetCwd: targetContext.Cwd,
|
|
TargetRuntimeContext: targetContext.RuntimeContext,
|
|
TargetSettings: preparedSessionForkSettings(
|
|
boundary.Session.Settings,
|
|
runtimeSource.Settings,
|
|
),
|
|
OccurredAtUnixMS: h.now().UnixMilli(),
|
|
})
|
|
if err != nil {
|
|
return ForkSessionResult{}, fmt.Errorf("freeze session fork snapshot: %w", err)
|
|
}
|
|
if attachmentStore, ok := h.sessionForks.(SessionForkAttachmentStore); ok {
|
|
bindings, bindingErr := attachmentStore.ListSessionForkAttachmentBindings(
|
|
ctx,
|
|
operation.WorkspaceID,
|
|
operation.OperationID,
|
|
)
|
|
if bindingErr != nil {
|
|
return h.failPreparedSessionFork(
|
|
ctx,
|
|
operation,
|
|
"session fork attachment manifest could not be read",
|
|
bindingErr,
|
|
)
|
|
}
|
|
if len(bindings) != 0 {
|
|
if h.sessionForkAttachments == nil {
|
|
return h.failPreparedSessionFork(
|
|
ctx,
|
|
operation,
|
|
"session fork attachments cannot be staged",
|
|
errors.New("session fork attachment stager is unavailable"),
|
|
)
|
|
}
|
|
if stageErr := h.sessionForkAttachments.StageSessionForkAttachments(
|
|
ctx,
|
|
operation.WorkspaceID,
|
|
operation.SourceAgentSessionID,
|
|
operation.TargetAgentSessionID,
|
|
bindings,
|
|
); stageErr != nil {
|
|
return h.failPreparedSessionFork(
|
|
ctx,
|
|
operation,
|
|
"session fork attachments could not be staged",
|
|
stageErr,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
if input.Asynchronous {
|
|
backgroundCtx := context.WithoutCancel(ctx)
|
|
source := cloneSessionForkRuntimeSource(runtimeSource)
|
|
go func() {
|
|
_, _ = h.processSessionForkOperationWithSource(
|
|
backgroundCtx,
|
|
operation,
|
|
&source,
|
|
)
|
|
}()
|
|
return ForkSessionResult{Operation: operation}, nil
|
|
}
|
|
result, err := h.processSessionForkOperationWithSource(
|
|
ctx,
|
|
operation,
|
|
&runtimeSource,
|
|
)
|
|
if err != nil {
|
|
return result, fmt.Errorf("execute session fork operation: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (h *Host) GetSessionForkOperation(
|
|
ctx context.Context,
|
|
workspaceID, operationID string,
|
|
) (ForkSessionResult, bool, error) {
|
|
if h == nil || h.sessionForks == nil {
|
|
return ForkSessionResult{}, false, nil
|
|
}
|
|
op, found, err := h.sessionForks.GetSessionForkOperation(
|
|
ctx, strings.TrimSpace(workspaceID), strings.TrimSpace(operationID),
|
|
)
|
|
if err != nil || !found {
|
|
return ForkSessionResult{}, found, err
|
|
}
|
|
if op.Status == storesqlite.SessionForkStatusPrepared ||
|
|
op.Status == storesqlite.SessionForkStatusDispatching ||
|
|
op.Status == storesqlite.SessionForkStatusUnknown {
|
|
return ForkSessionResult{Operation: op}, true, nil
|
|
}
|
|
if op.Status == storesqlite.SessionForkStatusProviderAccepted {
|
|
result, processErr := h.processSessionForkOperation(ctx, op)
|
|
if processErr == nil {
|
|
return result, true, nil
|
|
}
|
|
|
|
// The provider result is already durable, so a local commit failure is
|
|
// safe to retry. Return the latest accepted snapshot instead of turning
|
|
// an observable operation into a transport failure; the next GET will
|
|
// reconcile it again.
|
|
current, currentFound, readErr := h.sessionForks.GetSessionForkOperation(
|
|
ctx, op.WorkspaceID, op.OperationID,
|
|
)
|
|
if readErr != nil {
|
|
return ForkSessionResult{}, true, errors.Join(processErr, readErr)
|
|
}
|
|
if !currentFound {
|
|
return ForkSessionResult{}, false, processErr
|
|
}
|
|
result, readErr = h.sessionForkResult(ctx, current)
|
|
return result, true, readErr
|
|
}
|
|
result, err := h.sessionForkResult(ctx, op)
|
|
return result, true, err
|
|
}
|
|
|
|
func (h *Host) AcknowledgeSessionForkOperation(
|
|
ctx context.Context,
|
|
workspaceID, operationID string,
|
|
) (ForkSessionResult, bool, error) {
|
|
if h == nil || h.sessionForks == nil {
|
|
return ForkSessionResult{}, false, nil
|
|
}
|
|
workspaceID = strings.TrimSpace(workspaceID)
|
|
operationID = strings.TrimSpace(operationID)
|
|
if workspaceID == "" || operationID == "" {
|
|
return ForkSessionResult{}, false, ErrInvalidArgument
|
|
}
|
|
operation, found, _, err := h.sessionForks.AcknowledgeSessionForkOperation(
|
|
ctx,
|
|
workspaceID,
|
|
operationID,
|
|
h.now().UnixMilli(),
|
|
)
|
|
if err != nil || !found {
|
|
return ForkSessionResult{}, found, err
|
|
}
|
|
result, err := h.sessionForkResult(ctx, operation)
|
|
return result, true, err
|
|
}
|
|
|
|
func (h *Host) processSessionForkOperation(
|
|
ctx context.Context,
|
|
operation storesqlite.SessionForkOperation,
|
|
) (ForkSessionResult, error) {
|
|
return h.processSessionForkOperationWithSource(
|
|
ctx,
|
|
operation,
|
|
nil,
|
|
)
|
|
}
|
|
|
|
// processSessionForkOperationWithSource reuses one prepared historical runtime
|
|
// identity across the pre-prepare capability attestation, the dispatch-time
|
|
// attestation, and provider dispatch. Live sources are already authoritative
|
|
// runtime observations and enter through the same frozen value.
|
|
func (h *Host) processSessionForkOperationWithSource(
|
|
ctx context.Context,
|
|
operation storesqlite.SessionForkOperation,
|
|
preparedSource *ProviderRuntimeSession,
|
|
) (ForkSessionResult, error) {
|
|
switch operation.Status {
|
|
case storesqlite.SessionForkStatusCommitted:
|
|
return h.sessionForkResult(ctx, operation)
|
|
case storesqlite.SessionForkStatusProviderAccepted:
|
|
if operation.StateBindingMode == string(SessionForkStateBindingHostCopy) {
|
|
sourceSession, found, err := h.sessionForks.GetSessionForkSource(
|
|
ctx, operation.WorkspaceID, operation.SourceAgentSessionID,
|
|
)
|
|
if err != nil || !found {
|
|
return ForkSessionResult{Operation: operation}, errors.Join(
|
|
ErrSessionForkFailed, err,
|
|
)
|
|
}
|
|
if h.sessionForkState == nil {
|
|
return ForkSessionResult{Operation: operation},
|
|
errors.New("provider child state binding is unavailable")
|
|
}
|
|
if err := h.sessionForkState.BindSessionForkProviderState(
|
|
ctx,
|
|
SessionForkProviderStateBinding{
|
|
WorkspaceID: operation.WorkspaceID,
|
|
Provider: sourceSession.Provider,
|
|
SourceAgentSessionID: operation.SourceAgentSessionID,
|
|
TargetAgentSessionID: operation.TargetAgentSessionID,
|
|
SourceProviderSessionID: operation.SourceProviderSessionID,
|
|
TargetProviderSessionID: operation.TargetProviderSessionID,
|
|
},
|
|
); err != nil {
|
|
return ForkSessionResult{Operation: operation}, fmt.Errorf(
|
|
"bind accepted provider child state: %w",
|
|
err,
|
|
)
|
|
}
|
|
}
|
|
now := h.now().UnixMilli()
|
|
commit, err := h.sessionForks.CommitSessionFork(
|
|
ctx, operation.WorkspaceID, operation.OperationID, now,
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, storesqlite.ErrSessionForkMaterializationInconsistent) {
|
|
failed, _, failErr := h.sessionForks.FailAcceptedSessionFork(
|
|
ctx,
|
|
operation.WorkspaceID,
|
|
operation.OperationID,
|
|
err.Error(),
|
|
now,
|
|
)
|
|
if failErr != nil {
|
|
return ForkSessionResult{Operation: operation}, fmt.Errorf(
|
|
"quarantine permanently inconsistent accepted session fork: %w",
|
|
failErr,
|
|
)
|
|
}
|
|
logQuarantinedSessionFork(ctx, failed, err)
|
|
return ForkSessionResult{Operation: failed}, errors.Join(
|
|
ErrSessionForkFailed,
|
|
fmt.Errorf("materialize accepted session fork: %w", err),
|
|
)
|
|
}
|
|
return ForkSessionResult{Operation: operation}, fmt.Errorf(
|
|
"materialize accepted session fork: %w",
|
|
err,
|
|
)
|
|
}
|
|
lineage := commit.Lineage
|
|
return ForkSessionResult{
|
|
Operation: commit.Operation, Session: commit.Session, Lineage: &lineage,
|
|
}, nil
|
|
case storesqlite.SessionForkStatusDispatching:
|
|
return ForkSessionResult{Operation: operation}, ErrSessionForkInProgress
|
|
case storesqlite.SessionForkStatusFailed:
|
|
return ForkSessionResult{Operation: operation}, ErrSessionForkFailed
|
|
case storesqlite.SessionForkStatusUnknown:
|
|
return ForkSessionResult{Operation: operation}, ErrSessionForkDeliveryUnknown
|
|
case storesqlite.SessionForkStatusPrepared:
|
|
default:
|
|
return ForkSessionResult{Operation: operation}, storesqlite.ErrSessionForkTransition
|
|
}
|
|
failBeforeDispatch := func(
|
|
message string,
|
|
cause error,
|
|
) (ForkSessionResult, error) {
|
|
return h.failPreparedSessionFork(ctx, operation, message, cause)
|
|
}
|
|
|
|
boundary, supported, err := h.sessionForks.CheckSessionForkThroughTurn(
|
|
ctx, operation.WorkspaceID, operation.SourceAgentSessionID, operation.SourceTurnID,
|
|
)
|
|
if err != nil {
|
|
return failBeforeDispatch(
|
|
"canonical through-turn boundary could not be verified before dispatch",
|
|
err,
|
|
)
|
|
}
|
|
if !supported {
|
|
cause := storesqlite.ErrSessionForkTurnState
|
|
if rejectionErr := boundary.RejectionError(); rejectionErr != nil {
|
|
cause = rejectionErr
|
|
}
|
|
return failBeforeDispatch(
|
|
"canonical through-turn boundary is no longer forkable",
|
|
cause,
|
|
)
|
|
}
|
|
var source ProviderRuntimeSession
|
|
if preparedSource != nil {
|
|
source = *preparedSource
|
|
} else {
|
|
source, err = h.sessionForkRuntimeSource(ctx, boundary.Session)
|
|
if err != nil {
|
|
return failBeforeDispatch(
|
|
"source runtime could not be prepared before dispatch",
|
|
err,
|
|
)
|
|
}
|
|
}
|
|
if strings.TrimSpace(source.ProviderSessionID) !=
|
|
strings.TrimSpace(operation.SourceProviderSessionID) {
|
|
return failBeforeDispatch(
|
|
"source provider session identity changed before dispatch",
|
|
ErrSessionForkFailed,
|
|
)
|
|
}
|
|
descriptor, err := h.sessionForkRuntime.ResolveSessionFork(
|
|
ctx,
|
|
cloneSessionForkRuntimeSource(source),
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, ErrSessionForkUnsupported) {
|
|
return failBeforeDispatch(
|
|
"provider no longer supports the prepared session fork",
|
|
ErrSessionForkUnsupported,
|
|
)
|
|
}
|
|
return failBeforeDispatch(
|
|
"provider fork capability could not be verified before dispatch",
|
|
err,
|
|
)
|
|
}
|
|
normalizeSessionForkDriverDescriptor(&descriptor)
|
|
if !descriptor.ThroughTurn || descriptor.Kind != operation.DriverKind ||
|
|
descriptor.Version != operation.DriverVersion ||
|
|
!validSessionForkStateBindingMode(
|
|
descriptor.StateBindingMode,
|
|
h.sessionForkState,
|
|
source.Provider,
|
|
) {
|
|
return failBeforeDispatch(
|
|
"provider session fork driver changed before dispatch",
|
|
ErrSessionForkUnsupported,
|
|
)
|
|
}
|
|
var dispatchChanged bool
|
|
operation, dispatchChanged, err = h.sessionForks.MarkSessionForkDispatching(
|
|
ctx, operation.WorkspaceID, operation.OperationID, h.now().UnixMilli(),
|
|
)
|
|
if err != nil {
|
|
return failBeforeDispatch(
|
|
"provider dispatch marker could not be persisted",
|
|
err,
|
|
)
|
|
}
|
|
if !dispatchChanged {
|
|
return h.processSessionForkOperation(ctx, operation)
|
|
}
|
|
providerResult, dispatchErr := h.sessionForkRuntime.ForkSession(
|
|
ctx, RuntimeSessionForkInput{
|
|
Source: cloneSessionForkRuntimeSource(source),
|
|
SourceProviderTurnID: operation.SourceProviderTurnID,
|
|
SourceProviderTurnBindingJSON: append([]byte(nil), operation.SourceProviderTurnBindingJSON...),
|
|
TargetTitle: operation.TargetTitle,
|
|
RequestID: operation.RequestID,
|
|
Driver: descriptor,
|
|
},
|
|
)
|
|
targetProviderSessionID := strings.TrimSpace(providerResult.ProviderSessionID)
|
|
if providerResult.StateBindingMode == "" {
|
|
providerResult.StateBindingMode = SessionForkStateBindingHostCopy
|
|
}
|
|
providerResult.StateBindingReceipt = strings.TrimSpace(providerResult.StateBindingReceipt)
|
|
checkpointCtx, checkpointCancel := context.WithTimeout(
|
|
context.WithoutCancel(ctx),
|
|
sessionForkCheckpointTimeout,
|
|
)
|
|
defer checkpointCancel()
|
|
if dispatchErr != nil ||
|
|
providerResult.DeliveryDisposition != SessionForkDeliveryAccepted ||
|
|
targetProviderSessionID == "" ||
|
|
targetProviderSessionID == operation.SourceProviderSessionID ||
|
|
providerResult.StateBindingMode != descriptor.StateBindingMode ||
|
|
!validSessionForkProviderResult(providerResult) {
|
|
message := "provider fork result was invalid"
|
|
status := storesqlite.SessionForkStatusUnknown
|
|
if dispatchErr != nil {
|
|
message = dispatchErr.Error()
|
|
if errors.Is(dispatchErr, ErrSessionForkUnsupported) ||
|
|
providerResult.DeliveryDisposition == SessionForkDeliveryNotStarted ||
|
|
providerResult.DeliveryDisposition == SessionForkDeliveryRejected {
|
|
status = storesqlite.SessionForkStatusFailed
|
|
}
|
|
} else if providerResult.DeliveryDisposition == SessionForkDeliveryNotStarted ||
|
|
providerResult.DeliveryDisposition == SessionForkDeliveryRejected {
|
|
status = storesqlite.SessionForkStatusFailed
|
|
}
|
|
recorded, _, recordErr := h.sessionForks.RecordSessionForkProviderResult(
|
|
checkpointCtx, storesqlite.SessionForkProviderResult{
|
|
WorkspaceID: operation.WorkspaceID, OperationID: operation.OperationID,
|
|
Status: status, LastError: message,
|
|
OccurredAtUnixMS: h.now().UnixMilli(),
|
|
},
|
|
)
|
|
if recordErr != nil {
|
|
return ForkSessionResult{Operation: operation},
|
|
errors.Join(ErrSessionForkDeliveryUnknown, dispatchErr, recordErr)
|
|
}
|
|
if status == storesqlite.SessionForkStatusFailed {
|
|
return ForkSessionResult{Operation: recorded},
|
|
errors.Join(ErrSessionForkFailed, dispatchErr)
|
|
}
|
|
return ForkSessionResult{Operation: recorded},
|
|
errors.Join(ErrSessionForkDeliveryUnknown, dispatchErr)
|
|
}
|
|
operation, _, err = h.sessionForks.RecordSessionForkProviderResult(
|
|
checkpointCtx, storesqlite.SessionForkProviderResult{
|
|
WorkspaceID: operation.WorkspaceID, OperationID: operation.OperationID,
|
|
Status: storesqlite.SessionForkStatusProviderAccepted,
|
|
TargetProviderSessionID: targetProviderSessionID,
|
|
TargetProviderTurnBindings: storeSessionForkProviderTurnBindings(
|
|
providerResult.TargetProviderTurnBindings,
|
|
),
|
|
StateBindingMode: string(providerResult.StateBindingMode),
|
|
StateBindingReceipt: providerResult.StateBindingReceipt,
|
|
OccurredAtUnixMS: h.now().UnixMilli(),
|
|
},
|
|
)
|
|
if err != nil {
|
|
return ForkSessionResult{Operation: operation},
|
|
errors.Join(ErrSessionForkDeliveryUnknown, err)
|
|
}
|
|
return h.processSessionForkOperation(checkpointCtx, operation)
|
|
}
|
|
|
|
func storeSessionForkProviderTurnBindings(
|
|
bindings []SessionForkProviderTurnBinding,
|
|
) []storesqlite.SessionForkProviderTurnBinding {
|
|
result := make(
|
|
[]storesqlite.SessionForkProviderTurnBinding,
|
|
0,
|
|
len(bindings),
|
|
)
|
|
for _, binding := range bindings {
|
|
result = append(result, storesqlite.SessionForkProviderTurnBinding{
|
|
ProviderTurnID: strings.TrimSpace(binding.ProviderTurnID),
|
|
ProviderTurnBindingJSON: append(
|
|
[]byte(nil),
|
|
binding.ProviderTurnBindingJSON...,
|
|
),
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (h *Host) prepareSessionForkTargetContext(
|
|
ctx context.Context,
|
|
source storesqlite.Session,
|
|
runtimeSource ProviderRuntimeSession,
|
|
) (SessionForkTargetContext, error) {
|
|
if h != nil && h.sessionForkContext != nil {
|
|
target, err := h.sessionForkContext.PrepareSessionForkTargetContext(
|
|
ctx, source, cloneSessionForkRuntimeSource(runtimeSource),
|
|
)
|
|
if err != nil {
|
|
return SessionForkTargetContext{}, err
|
|
}
|
|
target.Cwd = strings.TrimSpace(target.Cwd)
|
|
target.RuntimeContext = cloneMap(target.RuntimeContext)
|
|
return target, nil
|
|
}
|
|
return SessionForkTargetContext{
|
|
Cwd: strings.TrimSpace(runtimeSource.Cwd),
|
|
RuntimeContext: cloneMap(runtimeSource.RuntimeContext),
|
|
}, nil
|
|
}
|
|
|
|
func preparedSessionForkSettings(
|
|
source map[string]any,
|
|
prepared *ComposerSettings,
|
|
) map[string]any {
|
|
result := cloneMap(source)
|
|
if result == nil {
|
|
result = make(map[string]any)
|
|
}
|
|
if prepared == nil {
|
|
return result
|
|
}
|
|
result["model"] = prepared.Model
|
|
result["modelPlanId"] = prepared.ModelPlanID
|
|
result["permissionModeId"] = prepared.PermissionModeID
|
|
result["planMode"] = prepared.PlanMode
|
|
if prepared.BrowserUse != nil {
|
|
result["browserUse"] = *prepared.BrowserUse
|
|
} else {
|
|
delete(result, "browserUse")
|
|
}
|
|
if prepared.ComputerUse != nil {
|
|
result["computerUse"] = *prepared.ComputerUse
|
|
} else {
|
|
delete(result, "computerUse")
|
|
}
|
|
result["reasoningEffort"] = prepared.ReasoningEffort
|
|
result["speed"] = prepared.Speed
|
|
result["conversationDetailMode"] = prepared.ConversationDetailMode
|
|
return result
|
|
}
|
|
|
|
func (h *Host) failPreparedSessionFork(
|
|
ctx context.Context,
|
|
operation storesqlite.SessionForkOperation,
|
|
message string,
|
|
cause error,
|
|
) (ForkSessionResult, error) {
|
|
failed, _, err := h.sessionForks.FailPreparedSessionFork(
|
|
ctx,
|
|
operation.WorkspaceID,
|
|
operation.OperationID,
|
|
message,
|
|
h.now().UnixMilli(),
|
|
)
|
|
if err != nil {
|
|
return ForkSessionResult{Operation: operation}, errors.Join(cause, err)
|
|
}
|
|
return ForkSessionResult{Operation: failed}, cause
|
|
}
|
|
|
|
func (h *Host) sessionForkResult(
|
|
ctx context.Context,
|
|
operation storesqlite.SessionForkOperation,
|
|
) (ForkSessionResult, error) {
|
|
result := ForkSessionResult{Operation: operation}
|
|
if operation.Status != storesqlite.SessionForkStatusCommitted {
|
|
return result, nil
|
|
}
|
|
committed, err := h.sessionForks.CommitSessionFork(
|
|
ctx,
|
|
operation.WorkspaceID,
|
|
operation.OperationID,
|
|
h.now().UnixMilli(),
|
|
)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
result.Session = committed.Session
|
|
lineage := committed.Lineage
|
|
result.Lineage = &lineage
|
|
return result, nil
|
|
}
|
|
|
|
func (h *Host) sessionForkRuntimeSource(
|
|
ctx context.Context,
|
|
session storesqlite.Session,
|
|
) (ProviderRuntimeSession, error) {
|
|
if h != nil && h.runtime != nil {
|
|
if live, found := h.runtime.Session(session.WorkspaceID, session.ID); found {
|
|
cloned := cloneSessionForkRuntimeSource(live)
|
|
var err error
|
|
cloned.Env, err = runtimeEnvironmentForCanonicalSession(cloned.Env, cloned.Cwd, session)
|
|
if err != nil {
|
|
return ProviderRuntimeSession{}, err
|
|
}
|
|
return cloned, nil
|
|
}
|
|
}
|
|
settings := composerSettingsFromMap(session.Settings)
|
|
prepared := PreparedRuntime{Cwd: strings.TrimSpace(session.Cwd)}
|
|
if h != nil && h.preparation != nil {
|
|
var err error
|
|
prepared, err = h.preparation.Prepare(
|
|
ctx,
|
|
resumePreparationInput(session, settings),
|
|
)
|
|
if err != nil {
|
|
return ProviderRuntimeSession{}, err
|
|
}
|
|
}
|
|
if prepared.Settings != nil {
|
|
settings = *prepared.Settings
|
|
}
|
|
runtimeEnv, err := runtimeEnvironmentForCanonicalSession(prepared.Env, prepared.Cwd, session)
|
|
if err != nil {
|
|
return ProviderRuntimeSession{}, err
|
|
}
|
|
prepared.Env = runtimeEnv
|
|
return ProviderRuntimeSession{
|
|
ID: session.ID, WorkspaceID: session.WorkspaceID, UserID: session.UserID,
|
|
AgentTargetID: session.AgentTargetID, Provider: session.Provider,
|
|
ProviderSessionID: session.ProviderSessionID, Resumable: true,
|
|
Cwd: prepared.Cwd, Env: append([]string(nil), prepared.Env...), MCPServers: cloneHostMCPServerBindings(prepared.MCPServers),
|
|
ProviderTargetRef: cloneMap(prepared.ProviderTargetRef), Settings: &settings,
|
|
RuntimeContext: cloneMap(firstMap(
|
|
prepared.RuntimeContext,
|
|
session.InternalRuntimeContext,
|
|
)),
|
|
Status: persistedRuntimeStatus(session.ActiveTurnID), Title: session.Title,
|
|
PinnedAtUnixMS: session.PinnedAtUnixMS, CreatedAtUnixMS: session.CreatedAtUnixMS,
|
|
UpdatedAtUnixMS: session.UpdatedAtUnixMS,
|
|
}, nil
|
|
}
|
|
|
|
func cloneSessionForkRuntimeSource(source ProviderRuntimeSession) ProviderRuntimeSession {
|
|
source.Env = append([]string(nil), source.Env...)
|
|
source.MCPServers = cloneHostMCPServerBindings(source.MCPServers)
|
|
source.ProviderTargetRef = cloneMap(source.ProviderTargetRef)
|
|
source.RuntimeContext = cloneMap(source.RuntimeContext)
|
|
if source.Settings != nil {
|
|
settings := *source.Settings
|
|
source.Settings = &settings
|
|
}
|
|
return source
|
|
}
|
|
|
|
func normalizeForkSessionInput(input *ForkSessionInput) {
|
|
input.WorkspaceID = strings.TrimSpace(input.WorkspaceID)
|
|
input.SourceAgentSessionID = strings.TrimSpace(input.SourceAgentSessionID)
|
|
input.TargetAgentSessionID = strings.TrimSpace(input.TargetAgentSessionID)
|
|
input.RequestID = strings.TrimSpace(input.RequestID)
|
|
input.Point.Kind = SessionForkPointKind(strings.TrimSpace(string(input.Point.Kind)))
|
|
input.Point.TurnID = strings.TrimSpace(input.Point.TurnID)
|
|
}
|
|
|
|
func normalizeSessionForkCapabilityInput(input *SessionForkCapabilityInput) {
|
|
input.WorkspaceID = strings.TrimSpace(input.WorkspaceID)
|
|
input.SourceAgentSessionID = strings.TrimSpace(input.SourceAgentSessionID)
|
|
}
|
|
|
|
func hashForkSessionInput(input ForkSessionInput) (string, error) {
|
|
value, err := json.Marshal(struct {
|
|
WorkspaceID string `json:"workspaceId"`
|
|
SourceAgentSessionID string `json:"sourceAgentSessionId"`
|
|
TargetAgentSessionID string `json:"targetAgentSessionId"`
|
|
Point SessionForkPoint `json:"point"`
|
|
}{
|
|
WorkspaceID: input.WorkspaceID, SourceAgentSessionID: input.SourceAgentSessionID,
|
|
TargetAgentSessionID: input.TargetAgentSessionID, Point: input.Point,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
sum := sha256.Sum256(value)
|
|
return hex.EncodeToString(sum[:]), nil
|
|
}
|