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
818 lines
29 KiB
Go
818 lines
29 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
|
|
"github.com/tutti-os/tutti/packages/agent/daemon/providerregistry"
|
|
)
|
|
|
|
// Codex may synchronously finish resource and thread initialization before
|
|
// replying to thread/start; keep this budget separate from generic ACP calls.
|
|
const codexAppServerThreadStartTimeout = 90 * time.Second
|
|
|
|
func (a *CodexAppServerAdapter) Start(ctx context.Context, session Session) (events []activityshared.Event, err error) {
|
|
unlockLifecycle := a.lockSessionLifecycle(session.AgentSessionID)
|
|
defer unlockLifecycle()
|
|
if err := a.admitCodexReplacementLocked(session.AgentSessionID); err != nil {
|
|
return nil, err
|
|
}
|
|
trace := newCodexAppServerStartupTrace(session, a.startupSpanObserver, a.startupObserver)
|
|
defer func() {
|
|
trace.Finish(err)
|
|
}()
|
|
extraSkillRoots, err := tuttiAgentExtraSkillRoots(a.config.skillRootsStrategy, session.Env)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stableSystemSkillsRoot, err := tuttiAgentStableSystemSkillsRoot(a.config.skillRootsStrategy, session.Env)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// One session owns at most one live app-server process. Starting over a
|
|
// session that already holds a live client replaces it: stop the old
|
|
// client first, then spawn the new process.
|
|
if existing := a.getSession(session.AgentSessionID); existing != nil && existing.client != nil {
|
|
a.rejectPendingRequests(session.AgentSessionID, errPermissionRequestCanceled)
|
|
_ = a.closeLiveSession(session.AgentSessionID)
|
|
}
|
|
client, initializeResult, _, err := a.startClient(ctx, session, trace, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
started := false
|
|
keepSession := false
|
|
startedSession := &codexAppServerSession{
|
|
client: client,
|
|
pendingRequests: make(map[string]*pendingInteractiveRequest),
|
|
}
|
|
defer func() {
|
|
if !started {
|
|
a.closeOrRetainCodexSession(session.AgentSessionID, startedSession)
|
|
}
|
|
if !keepSession {
|
|
a.removeSession(session.AgentSessionID)
|
|
}
|
|
}()
|
|
serverInfo := a.appServerInfo(initializeResult)
|
|
a.storeSession(session.AgentSessionID, &codexAppServerSession{
|
|
client: client,
|
|
serverInfo: serverInfo,
|
|
acpLiveState: newACPLiveState(),
|
|
pendingRequests: make(map[string]*pendingInteractiveRequest),
|
|
})
|
|
if err := a.stabilizeSystemSkillPaths(session, stableSystemSkillsRoot, trace); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := a.configureExtraSkillRoots(ctx, client, session, extraSkillRoots, trace); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
account, authRequired := a.fetchAccount(ctx, client, session, trace)
|
|
if authRequired {
|
|
a.storeSession(session.AgentSessionID, &codexAppServerSession{
|
|
serverInfo: serverInfo,
|
|
account: account,
|
|
authState: "auth_required",
|
|
authMessage: a.config.authRequiredMessage,
|
|
acpLiveState: newACPLiveState(),
|
|
pendingRequests: make(map[string]*pendingInteractiveRequest),
|
|
})
|
|
keepSession = true
|
|
return []activityshared.Event{newSessionActivityEvent(session, EventSessionStarted, SessionStatusReady, map[string]any{
|
|
"adapter": a.commandString(),
|
|
"command": a.commandString(),
|
|
"agent": serverInfo,
|
|
"permissionModeId": session.PermissionModeID,
|
|
"authState": "auth_required",
|
|
"authMessage": a.config.authRequiredMessage,
|
|
})}, nil
|
|
}
|
|
models := []map[string]any(nil)
|
|
if codexAppServerNeedsSynchronousModels(session) {
|
|
models = a.fetchModels(ctx, client, session, trace)
|
|
}
|
|
if len(models) > 0 {
|
|
effectiveSettings := codexAppServerEffectiveSettings(models, session, nil)
|
|
session.Settings = &effectiveSettings
|
|
}
|
|
planModeMask, defaultModeMask := a.fetchCollaborationModeMasks(ctx, client, session, trace)
|
|
|
|
threadParams := appServerThreadStartParams(session, a.sessionCWD(session))
|
|
trace.Log("thread.start.params", codexAppServerTraceThreadStartParams(session, threadParams, false))
|
|
a.observeStartupResourcesAsync(session, client, trace)
|
|
callbackSession := session
|
|
threadResult, err := trace.TypedCall(codexAppServerThreadStartTimeout, appServerMethodThreadStart, func() (json.RawMessage, error) {
|
|
return client.ThreadStart(ctx, codexAppServerThreadStartTimeout, threadParams,
|
|
func(ctx context.Context, message acpMessage) error {
|
|
trace.LogMessage(message.Method, len(message.ID) > 0, len(message.Params))
|
|
_, err := a.handleAppServerMessage(ctx, client, callbackSession, "", message, nil, nil, nil)
|
|
return err
|
|
})
|
|
})
|
|
if err != nil {
|
|
var callErr *acpCallError
|
|
if errors.As(err, &callErr) && callErr.AuthRequired() {
|
|
a.storeSession(session.AgentSessionID, &codexAppServerSession{
|
|
serverInfo: serverInfo,
|
|
account: account,
|
|
authState: "auth_required",
|
|
authMessage: a.config.authRequiredMessage,
|
|
acpLiveState: newACPLiveState(),
|
|
pendingRequests: make(map[string]*pendingInteractiveRequest),
|
|
})
|
|
keepSession = true
|
|
return []activityshared.Event{newSessionActivityEvent(session, EventSessionStarted, SessionStatusReady, map[string]any{
|
|
"adapter": a.commandString(),
|
|
"command": a.commandString(),
|
|
"agent": serverInfo,
|
|
"permissionModeId": session.PermissionModeID,
|
|
"authState": "auth_required",
|
|
"authMessage": a.config.authRequiredMessage,
|
|
})}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
threadID, err := appServerThreadID(threadResult)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
session.ProviderSessionID = threadID
|
|
trace.Log("thread.id.resolved", map[string]any{
|
|
"thread_id": threadID,
|
|
})
|
|
slog.Info("agent session app-server thread started",
|
|
"event", "agent_session.app_server.thread_start.succeeded",
|
|
"provider", a.config.provider,
|
|
"room_id", session.RoomID,
|
|
"agent_session_id", session.AgentSessionID,
|
|
"provider_session_id", threadID,
|
|
)
|
|
|
|
liveState := newACPLiveState()
|
|
liveState.currentMode = codexACPEffectiveModeID(session)
|
|
liveState.availableCommands = codexAppServerCommands()
|
|
liveState.commandsKnown = true
|
|
applyACPConfigOptionDescriptors(&liveState, codexAppServerConfigOptionDescriptors(models, session, threadResult))
|
|
|
|
started = true
|
|
keepSession = true
|
|
a.storeSession(session.AgentSessionID, &codexAppServerSession{
|
|
client: client,
|
|
threadID: threadID,
|
|
runtimeSession: session,
|
|
serverInfo: serverInfo,
|
|
account: account,
|
|
models: cloneCodexAppServerModels(models),
|
|
startupModelsReady: len(models) > 0,
|
|
startupRateLimitsReady: false,
|
|
planModeMask: planModeMask,
|
|
defaultModeMask: defaultModeMask,
|
|
defaultModel: codexAppServerSessionDefaultModel(session, models),
|
|
authState: "authenticated",
|
|
acpLiveState: liveState,
|
|
pendingRequests: make(map[string]*pendingInteractiveRequest),
|
|
})
|
|
a.refreshStartupMetadataAsync(session, threadResult, len(models) == 0, a.config.rateLimits, trace)
|
|
a.emitCommandSnapshot(AgentSessionCommandSnapshot{
|
|
AgentSessionID: strings.TrimSpace(session.AgentSessionID),
|
|
Commands: codexAppServerCommands(),
|
|
})
|
|
return []activityshared.Event{newSessionActivityEvent(session, EventSessionStarted, SessionStatusReady, map[string]any{
|
|
"adapter": a.commandString(),
|
|
"command": a.commandString(),
|
|
"agent": serverInfo,
|
|
"permissionModeId": session.PermissionModeID,
|
|
})}, nil
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) Resume(ctx context.Context, session Session) (err error) {
|
|
if strings.TrimSpace(session.ProviderSessionID) == "" {
|
|
return missingProviderSessionResumeError(session)
|
|
}
|
|
unlockLifecycle := a.lockSessionLifecycle(session.AgentSessionID)
|
|
defer unlockLifecycle()
|
|
if err := a.admitCodexReplacementLocked(session.AgentSessionID); err != nil {
|
|
return err
|
|
}
|
|
// Resume may run over a session that still holds a live client. Unlike
|
|
// Start, the old client is kept alive until the replacement has resumed
|
|
// successfully (storeSession closes it on replace): if the new spawn or
|
|
// thread/resume fails, the previous session must remain usable.
|
|
trace := newCodexAppServerStartupTrace(session, a.startupSpanObserver, a.startupObserver)
|
|
defer func() {
|
|
trace.Finish(err)
|
|
}()
|
|
trace.Log("resume.begin", map[string]any{
|
|
"thread_id": strings.TrimSpace(session.ProviderSessionID),
|
|
})
|
|
extraSkillRoots, err := tuttiAgentExtraSkillRoots(a.config.skillRootsStrategy, session.Env)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
stableSystemSkillsRoot, err := tuttiAgentStableSystemSkillsRoot(a.config.skillRootsStrategy, session.Env)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
client, initializeResult, attachedCheckpoint, err := a.startClient(ctx, session, trace, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
started := false
|
|
keepSession := false
|
|
previousSession := a.getSession(session.AgentSessionID)
|
|
startedSession := &codexAppServerSession{
|
|
client: client,
|
|
pendingRequests: make(map[string]*pendingInteractiveRequest),
|
|
}
|
|
defer func() {
|
|
if !started {
|
|
a.closeOrRetainCodexSession(session.AgentSessionID, startedSession)
|
|
}
|
|
if !keepSession {
|
|
if previousSession != nil {
|
|
a.storeSession(session.AgentSessionID, previousSession)
|
|
} else {
|
|
a.removeSession(session.AgentSessionID)
|
|
}
|
|
}
|
|
}()
|
|
if attachedCheckpoint {
|
|
planModeMask, defaultModeMask, defaultModel, checkpointFound :=
|
|
codexAppServerProtocolCheckpointFromRuntimeContext(session.RuntimeContext)
|
|
if !checkpointFound {
|
|
return errors.New(
|
|
"attached live Codex replay is missing provider resume checkpoint; record a new cassette",
|
|
)
|
|
}
|
|
liveState := newACPLiveState()
|
|
liveState.currentMode = codexACPEffectiveModeID(session)
|
|
liveState.availableCommands = codexAppServerCommands()
|
|
liveState.commandsKnown = true
|
|
applyACPConfigOptionDescriptors(
|
|
&liveState,
|
|
codexAppServerConfigOptionDescriptors(nil, session, nil),
|
|
)
|
|
started = true
|
|
keepSession = true
|
|
a.storeSession(session.AgentSessionID, &codexAppServerSession{
|
|
client: client,
|
|
threadID: strings.TrimSpace(session.ProviderSessionID),
|
|
resumeRuntimeContext: clonePayload(session.RuntimeContext),
|
|
planModeMask: planModeMask,
|
|
defaultModeMask: defaultModeMask,
|
|
defaultModel: firstNonEmpty(
|
|
strings.TrimSpace(session.SettingsValue().Model),
|
|
defaultModel,
|
|
),
|
|
authState: "authenticated",
|
|
acpLiveState: liveState,
|
|
pendingRequests: make(map[string]*pendingInteractiveRequest),
|
|
})
|
|
a.emitCommandSnapshot(AgentSessionCommandSnapshot{
|
|
AgentSessionID: strings.TrimSpace(session.AgentSessionID),
|
|
Commands: codexAppServerCommands(),
|
|
})
|
|
return nil
|
|
}
|
|
serverInfo := a.appServerInfo(initializeResult)
|
|
if err := a.stabilizeSystemSkillPaths(session, stableSystemSkillsRoot, trace); err != nil {
|
|
return err
|
|
}
|
|
if err := a.configureExtraSkillRoots(ctx, client, session, extraSkillRoots, trace); err != nil {
|
|
return err
|
|
}
|
|
|
|
account, authRequired := a.fetchAccount(ctx, client, session, trace)
|
|
if authRequired {
|
|
a.storeSession(session.AgentSessionID, &codexAppServerSession{
|
|
threadID: session.ProviderSessionID,
|
|
serverInfo: serverInfo,
|
|
account: account,
|
|
authState: "auth_required",
|
|
authMessage: a.config.authRequiredMessage,
|
|
acpLiveState: newACPLiveState(),
|
|
pendingRequests: make(map[string]*pendingInteractiveRequest),
|
|
})
|
|
keepSession = true
|
|
return nil
|
|
}
|
|
models := []map[string]any(nil)
|
|
if codexAppServerNeedsSynchronousModels(session) {
|
|
models = a.fetchModels(ctx, client, session, trace)
|
|
}
|
|
if len(models) > 0 && strings.TrimSpace(session.SettingsValue().ReasoningEffort) != "" {
|
|
hasExplicitModel := strings.TrimSpace(session.SettingsValue().Model) != ""
|
|
effectiveSettings := codexAppServerEffectiveSettings(models, session, nil)
|
|
// The catalog default is needed to validate an effort-only persisted
|
|
// setting, but it must not become a thread/resume model override. The
|
|
// existing thread remains authoritative until the resume result reports
|
|
// its actual model.
|
|
if !hasExplicitModel {
|
|
effectiveSettings.Model = ""
|
|
}
|
|
session.Settings = &effectiveSettings
|
|
}
|
|
planModeMask, defaultModeMask := a.fetchCollaborationModeMasks(ctx, client, session, trace)
|
|
|
|
params := appServerThreadStartParams(session, a.sessionCWD(session))
|
|
params["threadId"] = strings.TrimSpace(session.ProviderSessionID)
|
|
trace.Log("thread.start.params", codexAppServerTraceThreadStartParams(session, params, true))
|
|
a.observeStartupResourcesAsync(session, client, trace)
|
|
// codex replays thread/tokenUsage/updated during thread/resume so the GUI
|
|
// can show context fill before a new turn runs. The resumed session is not
|
|
// stored yet, so applyTokenUsage cannot reach it; capture the replayed
|
|
// usage here and fold it into the live state below.
|
|
var replayedUsage acpUsageState
|
|
replayedUsageKnown := false
|
|
callbackSession := session
|
|
threadResult, err := trace.TypedCall(acpStartCallTimeout, appServerMethodThreadResume, func() (json.RawMessage, error) {
|
|
return client.ThreadResume(ctx, acpStartCallTimeout, params,
|
|
func(ctx context.Context, message acpMessage) error {
|
|
trace.LogMessage(message.Method, len(message.ID) > 0, len(message.Params))
|
|
if message.Method == appServerNotifyTokenUsage && len(message.Params) > 0 {
|
|
tokenParams := map[string]any{}
|
|
if json.Unmarshal(message.Params, &tokenParams) == nil {
|
|
if usage, ok := appServerTokenUsageState(tokenParams); ok {
|
|
replayedUsage = usage
|
|
replayedUsageKnown = true
|
|
}
|
|
}
|
|
}
|
|
_, err := a.handleAppServerMessage(ctx, client, callbackSession, "", message, nil, nil, nil)
|
|
return err
|
|
})
|
|
})
|
|
if err != nil {
|
|
return classifyACPResumeError(session, appServerMethodThreadResume, err)
|
|
}
|
|
if len(models) > 0 {
|
|
effectiveSettings := codexAppServerEffectiveSettings(models, session, threadResult)
|
|
session.Settings = &effectiveSettings
|
|
}
|
|
liveState := newACPLiveState()
|
|
liveState.currentMode = codexACPEffectiveModeID(session)
|
|
liveState.availableCommands = codexAppServerCommands()
|
|
liveState.commandsKnown = true
|
|
applyACPConfigOptionDescriptors(&liveState, codexAppServerConfigOptionDescriptors(models, session, threadResult))
|
|
if replayedUsageKnown {
|
|
liveState.usage = mergeACPUsageState(liveState.usage, replayedUsage)
|
|
}
|
|
|
|
started = true
|
|
keepSession = true
|
|
a.storeSession(session.AgentSessionID, &codexAppServerSession{
|
|
client: client,
|
|
threadID: strings.TrimSpace(session.ProviderSessionID),
|
|
runtimeSession: session,
|
|
serverInfo: serverInfo,
|
|
account: account,
|
|
models: cloneCodexAppServerModels(models),
|
|
startupModelsReady: len(models) > 0,
|
|
startupRateLimitsReady: false,
|
|
planModeMask: planModeMask,
|
|
defaultModeMask: defaultModeMask,
|
|
defaultModel: codexAppServerSessionDefaultModel(session, models),
|
|
authState: "authenticated",
|
|
acpLiveState: liveState,
|
|
pendingRequests: make(map[string]*pendingInteractiveRequest),
|
|
})
|
|
a.refreshStartupMetadataAsync(session, threadResult, len(models) == 0, a.config.rateLimits, trace)
|
|
// Mirror Start: push the command snapshot so a resumed session advertises
|
|
// review/compact/undo to the GUI (otherwise the slash palette and the
|
|
// review picker only work on freshly created sessions).
|
|
a.emitCommandSnapshot(AgentSessionCommandSnapshot{
|
|
AgentSessionID: strings.TrimSpace(session.AgentSessionID),
|
|
Commands: codexAppServerCommands(),
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (*CodexAppServerAdapter) CanResume(session Session) bool {
|
|
return strings.TrimSpace(session.ProviderSessionID) != ""
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) HasLiveSession(session Session) bool {
|
|
a.mu.Lock()
|
|
appSession := a.sessions[strings.TrimSpace(session.AgentSessionID)]
|
|
if appSession == nil || appSession.client == nil || appSession.releasing || appSession.releaseFailed {
|
|
a.mu.Unlock()
|
|
return false
|
|
}
|
|
client := appSession.client
|
|
a.mu.Unlock()
|
|
select {
|
|
case <-client.Done():
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) Close(ctx context.Context, session Session) error {
|
|
if a == nil {
|
|
return nil
|
|
}
|
|
agentSessionID := strings.TrimSpace(session.AgentSessionID)
|
|
unlockLifecycle := a.lockSessionLifecycle(agentSessionID)
|
|
defer unlockLifecycle()
|
|
a.rejectPendingRequests(agentSessionID, errPermissionRequestCanceled)
|
|
if appSession := a.getSession(agentSessionID); appSession != nil &&
|
|
appSession.client != nil &&
|
|
strings.TrimSpace(appSession.threadID) != "" {
|
|
a.mu.Lock()
|
|
shared := a.clientReferencedLocked(appSession.client, agentSessionID)
|
|
a.mu.Unlock()
|
|
if session.IsSideConversation() || shared {
|
|
if err := appSession.client.ThreadUnsubscribeNoHandler(
|
|
ctx,
|
|
acpStartCallTimeout,
|
|
appSession.threadID,
|
|
); err != nil {
|
|
select {
|
|
case <-appSession.client.Done():
|
|
// The shared process is already gone; local removal is the
|
|
// only cleanup left and Side will be expired.
|
|
default:
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return a.closeLiveSession(agentSessionID)
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) QuiesceForClose(
|
|
ctx context.Context,
|
|
session Session,
|
|
) error {
|
|
if a == nil {
|
|
return nil
|
|
}
|
|
appTurn := a.sessionActiveTurn(session.AgentSessionID)
|
|
if appTurn == nil &&
|
|
a.sessionActiveTurnID(session.AgentSessionID) == "" {
|
|
return nil
|
|
}
|
|
appSession := a.getSession(session.AgentSessionID)
|
|
_, err := a.Cancel(ctx, session, "session closed")
|
|
if errors.Is(err, ErrSessionDisconnected) {
|
|
return nil
|
|
}
|
|
if err != nil && !errors.Is(err, ErrSessionNoActiveTurn) {
|
|
return err
|
|
}
|
|
if appTurn == nil {
|
|
return nil
|
|
}
|
|
select {
|
|
case <-appTurn.terminated:
|
|
return nil
|
|
default:
|
|
}
|
|
|
|
// Cancel queues an interrupt when turn/start has been sent but has not
|
|
// returned the provider Turn id yet. Close must not detach the session while
|
|
// that queued interrupt still depends on the session registry. Wait for the
|
|
// normal binding/interrupt path, then tear down the shared transport if the
|
|
// provider never supplies an interruptible identity within the ordinary
|
|
// cancellation grace window.
|
|
grace := a.cancelGraceWindow
|
|
if grace <= 0 {
|
|
grace = defaultCodexAppServerCancelGraceWindow
|
|
}
|
|
timer := time.NewTimer(grace)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-appTurn.terminated:
|
|
return nil
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-timer.C:
|
|
}
|
|
a.markTurnForceCanceled(appTurn)
|
|
slog.Warn(
|
|
"agent session app-server force-closing turn with unresolved provider identity",
|
|
"event", "agent_session.app_server.close.pending_turn_start_forced",
|
|
"agent_session_id", session.AgentSessionID,
|
|
"provider_session_id", session.ProviderSessionID,
|
|
"turn_id", appTurn.turnID,
|
|
"grace_ms", grace.Milliseconds(),
|
|
)
|
|
if appSession != nil && appSession.client != nil {
|
|
_ = appSession.client.Close()
|
|
}
|
|
select {
|
|
case <-appTurn.terminated:
|
|
return nil
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) ReleaseLiveSession(_ context.Context, session Session) error {
|
|
if a == nil {
|
|
return nil
|
|
}
|
|
agentSessionID := strings.TrimSpace(session.AgentSessionID)
|
|
unlockLifecycle := a.lockSessionLifecycle(agentSessionID)
|
|
defer unlockLifecycle()
|
|
if a.hasLiveSessionWork(agentSessionID) {
|
|
return ErrLiveSessionBusy
|
|
}
|
|
return a.closeLiveSession(agentSessionID)
|
|
}
|
|
|
|
// DisconnectLiveSession resolves pending interactions and drops only the
|
|
// app-server transport. The Codex thread remains resumable; no provider
|
|
// thread/session deletion request is sent.
|
|
func (a *CodexAppServerAdapter) DisconnectLiveSession(_ context.Context, session Session) error {
|
|
if a == nil {
|
|
return nil
|
|
}
|
|
agentSessionID := strings.TrimSpace(session.AgentSessionID)
|
|
unlockLifecycle := a.lockSessionLifecycle(agentSessionID)
|
|
defer unlockLifecycle()
|
|
a.rejectPendingRequests(agentSessionID, ErrSessionDisconnected)
|
|
return a.closeLiveSession(agentSessionID)
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) closeLiveSession(agentSessionID string) error {
|
|
a.mu.Lock()
|
|
appSession := a.sessions[agentSessionID]
|
|
shared := appSession != nil &&
|
|
a.clientReferencedLocked(appSession.client, agentSessionID)
|
|
if shared {
|
|
delete(a.sessions, agentSessionID)
|
|
a.mu.Unlock()
|
|
return nil
|
|
}
|
|
if appSession != nil && appSession.client != nil {
|
|
appSession.releasing = true
|
|
appSession.client.SetMessageHandler(nil)
|
|
}
|
|
a.mu.Unlock()
|
|
if appSession != nil && appSession.client != nil {
|
|
if err := appSession.client.Close(); err != nil {
|
|
if codexSessionAlreadyGone(err) {
|
|
a.mu.Lock()
|
|
if a.sessions[agentSessionID] == appSession {
|
|
delete(a.sessions, agentSessionID)
|
|
}
|
|
a.mu.Unlock()
|
|
return nil
|
|
}
|
|
a.mu.Lock()
|
|
if a.sessions[agentSessionID] == appSession {
|
|
appSession.releasing = false
|
|
appSession.releaseFailed = true
|
|
}
|
|
a.mu.Unlock()
|
|
return err
|
|
}
|
|
a.mu.Lock()
|
|
if a.sessions[agentSessionID] == appSession {
|
|
delete(a.sessions, agentSessionID)
|
|
}
|
|
a.mu.Unlock()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) startInitializedClient(
|
|
ctx context.Context,
|
|
session Session,
|
|
trace *codexAppServerStartupTrace,
|
|
) (*codexAppServerClient, json.RawMessage, error) {
|
|
client, initializeResult, _, err := a.startClient(ctx, session, trace, false)
|
|
return client, initializeResult, err
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) startClient(
|
|
ctx context.Context,
|
|
session Session,
|
|
trace *codexAppServerStartupTrace,
|
|
allowAttachedCheckpoint bool,
|
|
) (*codexAppServerClient, json.RawMessage, bool, error) {
|
|
spec, cleanup, err := a.prepareInitializedClientLaunch(ctx, session)
|
|
if err != nil {
|
|
trace.Log("process.prepare.failed", map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
return nil, nil, false, err
|
|
}
|
|
return a.startClientPrepared(
|
|
ctx,
|
|
session,
|
|
trace,
|
|
spec,
|
|
cleanup,
|
|
allowAttachedCheckpoint,
|
|
)
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) prepareInitializedClientLaunch(
|
|
ctx context.Context,
|
|
session Session,
|
|
) (ProcessSpec, func(context.Context), error) {
|
|
if a == nil || a.transport == nil {
|
|
return ProcessSpec{}, nil, errors.New(
|
|
"app-server process transport is unavailable",
|
|
)
|
|
}
|
|
command := append([]string(nil), a.config.command...)
|
|
spawnEnv := append(codexACPEnv(session, a.host), session.Env...)
|
|
if a.commandResolver != nil {
|
|
resolved, err := a.commandResolver(ctx, a.config.provider)
|
|
if err != nil {
|
|
return ProcessSpec{}, nil, err
|
|
}
|
|
if len(resolved.Command) > 0 {
|
|
command = append([]string(nil), resolved.Command...)
|
|
}
|
|
spawnEnv = append(spawnEnv, resolved.Env...)
|
|
}
|
|
spec, cleanup, err := prepareProviderLaunch(ctx, a.preparer, session, ProcessSpec{
|
|
Provider: a.config.provider,
|
|
AgentSessionID: session.AgentSessionID,
|
|
RootAgentSessionID: session.RootAgentSessionID,
|
|
RoomID: session.RoomID,
|
|
CWD: a.sessionCWD(session),
|
|
Command: command,
|
|
Env: spawnEnv,
|
|
})
|
|
if err != nil {
|
|
return ProcessSpec{}, nil, err
|
|
}
|
|
if a.config.skillRootsStrategy == providerregistry.AppServerSkillRootsStrategyTuttiStable {
|
|
spec.Env = withoutEnvironmentKey(spec.Env, tuttiAgentExtraSkillRootsEnv)
|
|
spec.Env = withoutEnvironmentKey(spec.Env, tuttiAgentStableSystemSkillsEnv)
|
|
}
|
|
spec.Env = withCodexAppServerLogging(spec.Env)
|
|
return spec, cleanup, nil
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) startClientPrepared(
|
|
ctx context.Context,
|
|
session Session,
|
|
trace *codexAppServerStartupTrace,
|
|
spec ProcessSpec,
|
|
cleanup func(context.Context),
|
|
allowAttachedCheckpoint bool,
|
|
) (*codexAppServerClient, json.RawMessage, bool, error) {
|
|
trace.Log("process.start.begin", map[string]any{
|
|
"command": strings.Join(spec.Command, " "),
|
|
"cwd": spec.CWD,
|
|
})
|
|
processStartedAt := time.Now()
|
|
conn, err := a.transport.Start(ctx, spec)
|
|
if err != nil {
|
|
cleanupPreparedLaunch(cleanup)
|
|
trace.Log("process.start.failed", map[string]any{
|
|
"duration_ms": time.Since(processStartedAt).Milliseconds(),
|
|
"error": err.Error(),
|
|
})
|
|
return nil, nil, false, err
|
|
}
|
|
conn = wrapProviderLaunchCleanup(conn, cleanup)
|
|
trace.Log("process.start.succeeded", map[string]any{
|
|
"duration_ms": time.Since(processStartedAt).Milliseconds(),
|
|
})
|
|
client := newCodexAppServerClient(conn)
|
|
client.SetStderrSink(trace.LogStderr)
|
|
// The session-level handler receives every message that arrives outside
|
|
// an in-flight RPC. Because turn/start responds immediately while the
|
|
// turn keeps streaming, this is the main delivery path for turn output:
|
|
// resolve the active turn context so notifications keep producing
|
|
// activity events after the RPC has returned. Child sessions may outlive
|
|
// that root turn, so their events retain a session-level fallback below.
|
|
client.SetMessageHandler(func(ctx context.Context, message acpMessage) error {
|
|
endInputUnit := a.inputUnits.begin(ctx, session.AgentSessionID)
|
|
defer endInputUnit()
|
|
turnSession := session
|
|
if appSession := a.getSession(session.AgentSessionID); appSession != nil {
|
|
turnSession.ProviderSessionID = firstNonEmpty(appSession.threadID, turnSession.ProviderSessionID)
|
|
}
|
|
turnID := ""
|
|
var normalizer *acpTurnNormalizer
|
|
var turnEmit func([]activityshared.Event)
|
|
var turnEmitCommands CommandSnapshotSink
|
|
if activeTurn := a.sessionActiveTurn(session.AgentSessionID); activeTurn != nil {
|
|
turnSession = activeTurn.session
|
|
turnID = activeTurn.turnID
|
|
normalizer = activeTurn.normalizer
|
|
turnEmit = activeTurn.emit
|
|
turnEmitCommands = activeTurn.emitCommands
|
|
}
|
|
events, err := a.handleAppServerMessage(ctx, client, turnSession, turnID, message, normalizer, turnEmit, turnEmitCommands)
|
|
// Stamp the reduction while the decoder-owned provider input unit is
|
|
// still in scope. Most turn output is stamped again by the active-turn
|
|
// emitter, but lifecycle reductions such as turn/started can take a
|
|
// different emission path. The tracker is idempotent, so the emitter
|
|
// can retain its existing boundary without duplicating indexes.
|
|
events = a.inputUnits.stamp(session.AgentSessionID, events)
|
|
// A child may outlive its root Turn. Only child events owned by the
|
|
// currently active canonical root Turn may enter that Turn's emitter;
|
|
// otherwise a newer Turn's acceptance buffer or close fence can drop them.
|
|
turnEvents, detachedChildEvents := appServerEventsForActiveRootTurn(
|
|
session.AgentSessionID,
|
|
turnID,
|
|
events,
|
|
)
|
|
if turnEmit != nil {
|
|
turnEmit(turnEvents)
|
|
}
|
|
if len(detachedChildEvents) > 0 {
|
|
a.emitSessionEvents(
|
|
session.AgentSessionID,
|
|
a.stampTurnLifecycleSnapshots(session.AgentSessionID, detachedChildEvents),
|
|
)
|
|
}
|
|
return err
|
|
})
|
|
started := false
|
|
defer func() {
|
|
if !started {
|
|
a.closeOrRetainCodexSession(session.AgentSessionID, &codexAppServerSession{
|
|
client: client,
|
|
pendingRequests: make(map[string]*pendingInteractiveRequest),
|
|
})
|
|
}
|
|
}()
|
|
captureOrigin := processCassetteCaptureOrigin(conn)
|
|
if captureOrigin == ProcessCassetteCaptureOriginAttachedLiveConnection {
|
|
if !allowAttachedCheckpoint {
|
|
return nil, nil, false, errors.New(
|
|
"attached live provider checkpoint cannot start a new Codex session",
|
|
)
|
|
}
|
|
started = true
|
|
return client, nil, true, nil
|
|
}
|
|
|
|
initializeResult, err := trace.TypedCall(acpStartCallTimeout, appServerMethodInitialize, func() (json.RawMessage, error) {
|
|
return client.Initialize(ctx, acpStartCallTimeout, map[string]any{
|
|
"clientInfo": a.clientInfoParams(spec.Env),
|
|
"capabilities": map[string]any{
|
|
"experimentalApi": true,
|
|
},
|
|
}, func(ctx context.Context, message acpMessage) error {
|
|
trace.LogMessage(message.Method, len(message.ID) > 0, len(message.Params))
|
|
_, err := a.handleAppServerMessage(ctx, client, session, "", message, nil, nil, nil)
|
|
return err
|
|
})
|
|
})
|
|
if err != nil {
|
|
slog.Warn("agent session app-server initialize failed",
|
|
"event", "agent_session.app_server.initialize.failed",
|
|
"room_id", session.RoomID,
|
|
"agent_session_id", session.AgentSessionID,
|
|
"error", err.Error(),
|
|
)
|
|
return nil, nil, false, err
|
|
}
|
|
trace.Log("initialized.notify.begin", nil)
|
|
notifyStartedAt := time.Now()
|
|
if err := client.Initialized(ctx); err != nil {
|
|
trace.Log("initialized.notify.failed", map[string]any{
|
|
"duration_ms": time.Since(notifyStartedAt).Milliseconds(),
|
|
"error": err.Error(),
|
|
})
|
|
return nil, nil, false, err
|
|
}
|
|
trace.Log("initialized.notify.succeeded", map[string]any{
|
|
"duration_ms": time.Since(notifyStartedAt).Milliseconds(),
|
|
})
|
|
started = true
|
|
return client, initializeResult, false, nil
|
|
}
|
|
|
|
func appServerEventsForActiveRootTurn(
|
|
rootAgentSessionID string,
|
|
activeRootTurnID string,
|
|
events []activityshared.Event,
|
|
) ([]activityshared.Event, []activityshared.Event) {
|
|
rootAgentSessionID = strings.TrimSpace(rootAgentSessionID)
|
|
activeRootTurnID = strings.TrimSpace(activeRootTurnID)
|
|
turnEvents := make([]activityshared.Event, 0, len(events))
|
|
detachedChildEvents := make([]activityshared.Event, 0, len(events))
|
|
for _, event := range events {
|
|
eventAgentSessionID := strings.TrimSpace(event.AgentSessionID)
|
|
if eventAgentSessionID != "" && eventAgentSessionID != rootAgentSessionID {
|
|
if activeRootTurnID == "" || strings.TrimSpace(event.RootTurnID) != activeRootTurnID {
|
|
detachedChildEvents = append(detachedChildEvents, event)
|
|
continue
|
|
}
|
|
}
|
|
if activeRootTurnID != "" {
|
|
turnEvents = append(turnEvents, event)
|
|
}
|
|
}
|
|
return turnEvents, detachedChildEvents
|
|
}
|