{"record":{"id":"fe12ffc68a10e958","repo":"siyuan-note/siyuan","slug":"agent-runtime-turn-changed","errorCode":null,"errorMessage":"agent runtime turn changed","messagePattern":"agent runtime turn changed","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"kernel/agent/runtime.go","lineNumber":328,"sourceCode":"\tif !isValidSessionID(sessionID) {\n\t\treturn fmt.Errorf(\"invalid session id\")\n\t}\n\tlock := sessionLock(sessionID)\n\tlock.Lock()\n\tdefer lock.Unlock()\n\tcommitted, err := isTurnCommittedLocked(sessionID, turn.TurnID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif committed {\n\t\treturn nil\n\t}\n\truntime, err := loadRuntimeLocked(sessionID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif runtime.ActiveTurn != nil && runtime.ActiveTurn.TurnID != turn.TurnID {\n\t\treturn fmt.Errorf(\"agent runtime turn changed\")\n\t}\n\tturn.UpdatedAt = time.Now().UnixMilli()\n\truntime.ActiveTurn = turn\n\treturn writeRuntimeLocked(sessionID, runtime)\n}\n\nfunc saveRuntimeCompaction(sessionID string, compaction *runtimeCompaction) error {\n\tif sessionID == \"\" || compaction == nil {\n\t\treturn errContextCannotBeCompacted\n\t}\n\tif !isValidSessionID(sessionID) {\n\t\treturn fmt.Errorf(\"invalid session id\")\n\t}\n\tlock := sessionLock(sessionID)\n\tlock.Lock()\n\tdefer lock.Unlock()\n\truntime, err := loadRuntimeLocked(sessionID)\n\tif err != nil {","sourceCodeStart":310,"sourceCodeEnd":346,"githubUrl":"https://github.com/siyuan-note/siyuan/blob/8641553a1f07374001902d3ce773285db1292b2d/kernel/agent/runtime.go#L310-L346","documentation":"saveRuntimeTurn detects that the session's ActiveTurn has a different TurnID than the turn being saved and returns 'agent runtime turn changed'. This optimistic-concurrency check prevents overwriting a newer turn's state with stale writes.","triggerScenarios":"Saving turn A after the session's ActiveTurn was replaced by turn B (beginRuntimeTurn with a new turn, recovery, or regenerate occurred in between).","commonSituations":"Long-running request finishing its save after the user regenerated/recovered; retry logic replaying an old save against a session whose turn already advanced; concurrent writers to the same session.","solutions":["Re-fetch the current ActiveTurn and abort or restart the turn if its TurnID changed","Check turn currency immediately before save and reload the runtime on conflict","Serialize turn lifecycle per session so a save cannot race a turn replacement"],"exampleFix":"// before\nsaveRuntimeTurn(sid, oldTurn) // ActiveTurn is now newTurn\n// after\nruntime, _ := loadRuntime(sid)\nif runtime.ActiveTurn != nil && runtime.ActiveTurn.TurnID == oldTurn.TurnID {\n    saveRuntimeTurn(sid, oldTurn)\n}","handlingStrategy":"retry","validationCode":"if runtime.ActiveTurn == nil || runtime.ActiveTurn.TurnID != turn.TurnID { reloadOrRestartTurn() }","typeGuard":null,"tryCatchPattern":"if err := saveRuntimeTurn(sid, turn); err != nil && strings.Contains(err.Error(), \"turn changed\") { turn = reloadTurn(sid, turn.TurnID); if turn == nil { return errTurnSuperseded } }","preventionTips":["Save promptly after each turn step to shrink the conflict window","Treat superseded turns as aborted rather than forcing a save","Serialize per-session turn work in one worker"],"tags":["go","agent-runtime","concurrency","optimistic-locking"],"backgroundTag":"invalid-state-transition","analyzedSha":"8641553a1f07374001902d3ce773285db1292b2d","analyzedAt":"2026-09-11T16:08:28.414Z","contentChangedAt":"2026-09-11T16:08:28.414Z","schemaVersion":2},"datasetVersion":"2026-09-14T00:17:10.932Z"}