{"record":{"id":"15e7a627317d1004","repo":"siyuan-note/siyuan","slug":"agent-session-has-an-uncommitted-turn","errorCode":null,"errorMessage":"agent session has an uncommitted turn","messagePattern":"agent session has an uncommitted turn","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"kernel/agent/runtime.go","lineNumber":272,"sourceCode":"\t\treturn fmt.Errorf(\"invalid session id\")\n\t}\n\tif turn.TurnID == \"\" || turn.State != \"running\" {\n\t\treturn fmt.Errorf(\"invalid agent runtime turn\")\n\t}\n\tlock := sessionLock(sessionID)\n\tlock.Lock()\n\tdefer lock.Unlock()\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\tcommitted, err := isTurnCommittedLocked(sessionID, runtime.ActiveTurn.TurnID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif !committed {\n\t\t\treturn fmt.Errorf(\"agent session has an uncommitted turn\")\n\t\t}\n\t\truntime.ActiveTurn = nil\n\t}\n\tdata, err := os.ReadFile(filepath.Join(sessionsDir(), sessionID, \"session.json\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar session map[string]any\n\tif err := gulu.JSON.UnmarshalJSON(data, &session); err != nil {\n\t\treturn err\n\t}\n\tif turn.BaseRevision >= 0 && numberToInt64(session[\"revision\"]) != turn.BaseRevision {\n\t\treturn ErrSessionConflict\n\t}\n\tif findRuntimeUserAnchor(session, turn.UserEntryID) < 0 {\n\t\treturn fmt.Errorf(\"agent runtime user entry not found\")\n\t}\n\truntime.ActiveTurn = turn","sourceCodeStart":254,"sourceCodeEnd":290,"githubUrl":"https://github.com/siyuan-note/siyuan/blob/8641553a1f07374001902d3ce773285db1292b2d/kernel/agent/runtime.go#L254-L290","documentation":"beginRuntimeTurn refuses to install a new ActiveTurn when the session already has a different ActiveTurn that has not been committed, raising 'agent session has an uncommitted turn'. The runtime serializes turns per session: only one uncommitted turn may be in flight. Once the existing turn is committed, it is cleared and the new turn proceeds.","triggerScenarios":"Calling beginRuntimeTurn while a previous turn is still active and isTurnCommittedLocked returns false for it (same sessionID, different TurnID than the incoming turn).","commonSituations":"Concurrent or overlapping agent requests on the same session; a previous turn crashed without commit/rollback; a client retried before the first request finished.","solutions":["Commit or roll back the existing ActiveTurn (via the session save/commit path) before starting a new turn","Check the session's runtime state before beginning a turn and reject/queue if an uncommitted turn exists","Serialize turn start per session in the caller (e.g. per-session queue or mutex)"],"exampleFix":"// before\nbeginRuntimeTurn(sid, turnB) // fails: turnA uncommitted\n// after\nif err := commitRuntimeTurn(sid, turnA); err != nil { return err }\nbeginRuntimeTurn(sid, turnB)","handlingStrategy":"try-catch","validationCode":"if runtime.ActiveTurn != nil && runtime.ActiveTurn.TurnID != newTurn.TurnID { ensureCommitted(sessionID, runtime.ActiveTurn.TurnID) }","typeGuard":null,"tryCatchPattern":"if err := beginRuntimeTurn(sid, turn); err != nil && strings.Contains(err.Error(), \"uncommitted turn\") { commitOrRollbackActiveTurn(sid); return beginRuntimeTurn(sid, turn) }","preventionTips":["Complete or roll back each turn before starting the next","Queue turn starts per session","Handle crash recovery to clear stale ActiveTurns"],"tags":["go","agent-runtime","concurrency","state-machine"],"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-14T05:17:10.506Z"}