Files
tutti/packages/agent/daemon/runtime/controller_session_commit.go
T
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

130 lines
5.3 KiB
Go

package agentruntime
import "strings"
// Session commit boundary
// -----------------------
//
// A turn-execution goroutine captures a Session snapshot when its turn begins
// and folds provider events onto that local value while the provider runs.
// Meanwhile the controller's live session may be mutated concurrently by other
// lifecycle commands (SetTitle, SetVisible, UpdateSettings), so the exec-path
// snapshot is never the accepted state. commitTurnExecSessionLocked is the
// single boundary through which those goroutines write back, and it merges only
// the fields turn execution owns. Callers must publish and report using the
// returned accepted session, never the exec-path snapshot.
// mergeTurnExecSession produces the accepted session for a turn-execution
// commit. `current` is the controller's current session (the only accepted
// state); `update` is the turn goroutine's local snapshot. Only
// turn-execution-owned fields are taken from `update`; user-mutated fields
// (Title, Visible, Settings, PermissionModeID) and identity fields are always
// preserved from `current`, so a concurrent SetTitle/SetVisible/UpdateSettings
// can never be lost to a stale exec copy.
func mergeTurnExecSession(current, update Session) (Session, bool) {
// A stale adapter lifecycle snapshot (already superseded by a newer one
// applied through the session-event sink) must never regress the accepted
// session. This lost-update guard previously lived only in storeTurnSession;
// finishTurn now applies the same rule so settlement cannot resurrect an
// older lifecycle.
if current.LifecycleAuthority && current.LifecycleSeq > update.LifecycleSeq {
return Session{}, false
}
accepted := current
// Turn-execution-owned fields: lifecycle, status, availability, and the
// event-derived facts only turn execution observes.
accepted.Status = update.Status
accepted.TurnLifecycle = cloneRuntimeTurnLifecycle(update.TurnLifecycle)
accepted.SubmitAvailability = cloneRuntimeSubmitAvailability(update.SubmitAvailability)
accepted.LastError = update.LastError
accepted.Resumable = current.Resumable || update.Resumable
if providerSessionID := strings.TrimSpace(update.ProviderSessionID); providerSessionID != "" {
accepted.ProviderSessionID = providerSessionID
}
if update.UpdatedAtUnixMS > current.UpdatedAtUnixMS {
accepted.UpdatedAtUnixMS = update.UpdatedAtUnixMS
}
if update.LifecycleAuthority {
accepted.LifecycleAuthority = true
if update.LifecycleSeq > accepted.LifecycleSeq {
accepted.LifecycleSeq = update.LifecycleSeq
}
}
// Title ownership: a title the user set explicitly is immutable from the
// exec path's perspective. A provider/event title is only a candidate while
// no user title exists; once accepted it is carried into the accepted
// session without changing the established-title state.
if current.UserTitleSet {
accepted.Title = current.Title
} else if strings.TrimSpace(update.Title) != "" {
accepted.Title = strings.TrimSpace(update.Title)
}
accepted.UserTitleSet = current.UserTitleSet
accepted.RuntimeContext = mergeTurnExecRuntimeContext(
current.RuntimeContext,
update.RuntimeContext,
accepted.SettingsValue(),
accepted.InitialTitleEstablished,
)
return accepted, true
}
// mergeTurnExecRuntimeContext preserves the accepted runtime context while
// merging event-derived keys from the exec snapshot, then re-asserts the
// settings-derived keys and the initial-title marker from the accepted session
// so a stale exec copy can never clobber concurrent settings or title state.
func mergeTurnExecRuntimeContext(
current, update map[string]any,
settings SessionSettings,
established bool,
) map[string]any {
merged := clonePayload(current)
if merged == nil {
merged = map[string]any{}
}
if len(update) > 0 {
merged = mergeRuntimeContextPatch(merged, update)
}
merged = runtimeContextWithSessionSettings(merged, settings)
return runtimeContextWithInitialTitleEstablished(merged, established)
}
// commitTurnExecSessionLocked validates the turn fence, applies the merge onto
// the controller's current session, optionally settles the turn, and persists
// the accepted session. When settle is true the matched turn fence is always
// removed — even if the merge rejects the write (different live turn, newer
// adapter lifecycle, closed session) — because a stale exec snapshot must not
// keep the fence open.
func (c *Controller) commitTurnExecSessionLocked(key, turnID string, update Session, settle bool) (Session, bool) {
active, ok := c.turns[key]
if !ok || strings.TrimSpace(active.turnID) != strings.TrimSpace(turnID) {
return Session{}, false
}
if settle {
delete(c.turns, key)
}
current, ok := c.sessions[key]
if !ok {
return Session{}, false
}
if settle && sessionHasDifferentLiveTurn(current, turnID) {
return Session{}, false
}
accepted, ok := mergeTurnExecSession(current, update)
if !ok {
return Session{}, false
}
if settle && !accepted.LifecycleAuthority {
// ADR 0008: snapshot-authority sessions already settle through their own
// copied lifecycle; legacy sessions get the controller-origin settle and
// a pure status reconcile.
accepted = settleFinishedTurnLifecycle(accepted, turnID)
accepted = c.reconcileSessionStatusLocked(key, accepted)
}
c.sessions[key] = accepted
return accepted, true
}