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
130 lines
5.3 KiB
Go
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
|
|
}
|