{"record":{"id":"7ee5a3b87456c72b","repo":"siyuan-note/siyuan","slug":"agent-runtime-turn-is-not-finalized","errorCode":null,"errorMessage":"agent runtime turn is not finalized","messagePattern":"agent runtime turn is not finalized","errorType":"http","errorClass":null,"httpStatus":409,"severity":"error","filePath":"kernel/agent/session.go","lineNumber":77,"sourceCode":"\tTotal    int                 `json:\"total\"`\n\tPage     int                 `json:\"page\"`\n\tPageSize int                 `json:\"pageSize\"`\n}\n\ntype sessionMeta struct {\n\tID                  string `json:\"id\"`\n\tTitle               string `json:\"title\"`\n\tCreatedAt           int64  `json:\"createdAt\"`\n\tUpdatedAt           int64  `json:\"updatedAt\"`\n\tRevision            int64  `json:\"revision\"`\n\tExpectedRevision    *int64 `json:\"expectedRevision,omitempty\"`\n\tCommitTurnID        string `json:\"commitTurnID,omitempty\"`\n\tRecoveryTurnID      string `json:\"recoveryTurnID,omitempty\"`\n\tLastCommittedTurnID string `json:\"lastCommittedTurnID,omitempty\"`\n}\n\nvar ErrSessionConflict = errors.New(\"agent session revision conflict\")\nvar ErrRuntimeNotFinalized = errors.New(\"agent runtime turn is not finalized\")\n\nvar sessionLocks sync.Map\n\nfunc sessionLock(id string) *sync.Mutex {\n\tlock, _ := sessionLocks.LoadOrStore(id, &sync.Mutex{})\n\treturn lock.(*sync.Mutex)\n}\n\nfunc loadSessionIndex() map[string]*SessionIndexItem {\n\tdata, err := os.ReadFile(sessionsIndexPath())\n\tif err != nil {\n\t\treturn nil\n\t}\n\tvar index map[string]*SessionIndexItem\n\tif gulu.JSON.UnmarshalJSON(data, &index) != nil {\n\t\treturn nil\n\t}\n\treturn index","sourceCodeStart":59,"sourceCodeEnd":95,"githubUrl":"https://github.com/siyuan-note/siyuan/blob/afa823b6b4e4f183511e0bc0a3be93caa94c7c97/kernel/agent/session.go#L59-L95","documentation":"The agent runtime tracks each model turn in a per-session runtime state; a turn must reach a terminal state before its results can be committed into session.json. SaveSessionState returns ErrRuntimeNotFinalized (kernel/agent/session.go:393) when the submitted commitTurnID matches the active turn but isRuntimeTurnTerminal is still false — i.e. streaming, tool execution, or confirmation for that turn is still in flight. The API surfaces it as HTTP 409 with the current revision.","triggerScenarios":"POST /api/ai/agent/saveSession with commitTurnID (or recoveryTurnID) for a turn whose ActiveTurn is not terminal: committing while the SSE stream is still emitting, while a tool call awaits user confirmation (/api/ai/agent/confirm), or while a browser capability result (/api/ai/agent/browserCapabilityResult) has not arrived yet.","commonSituations":"Frontend commits as soon as the stream visually completes but before the turn-end event; a turn paused on approval; timing races between stream close and runtime checkpoint persistence; custom clients that commit immediately after their own timeout instead of the turn event.","solutions":["Wait for the terminal turn event (SSE turn end / agentRunning=false) before committing","Poll GET /api/ai/agent/getSession until the runtime no longer reports an active non-terminal turn, then resend the same commitTurnID","If the turn is stuck on approval, resolve /api/ai/agent/confirm first","If the kernel restarted mid-turn, GET /api/ai/agent/getSession first — it runs FinalizeOrphanedTurn so the orphaned turn becomes committable"],"exampleFix":"// before: commit on stream end guess\nstream.on('close', () => save(payload));\n\n// after: commit only once the turn is terminal\nstream.on('turn_end', async () => {\n  let s = await fetchPost('/api/ai/agent/getSession', {id});\n  while (s.data.agentRunning) { s = await fetchPost('/api/ai/agent/getSession', {id}); }\n  await fetchPost('/api/ai/agent/saveSession', {...payload, commitTurnID: turnID});\n});","handlingStrategy":"retry","validationCode":"// Commit only after the runtime reports the turn finished\nconst s = await fetchPost('/api/ai/agent/getSession', {id});\nconst terminal = !s.data.agentRunning; // runtime merged, no active turn\nif (terminal) { /* safe to save with commitTurnID */ }","typeGuard":"null","tryCatchPattern":"catch (e) {\n  if (e?.status === 409 && /not finalized/.test(e.data?.msg ?? '')) {\n    // schedule a re-check after the next turn event; do not force-save over the runtime\n  }\n}","preventionTips":["Drive commits from the turn-end SSE event, never from a client-side timer or stream-close guess","Resolve pending confirmations (/api/ai/agent/confirm) before committing the turn","After a kernel restart, GET the session first so FinalizeOrphanedTurn can finish the orphaned turn","Never strip commitTurnID to bypass the check — the runtime content would be lost"],"tags":["agent","runtime","turn","state-machine","http-409"],"backgroundTag":"async-operation-still-running","analyzedSha":"afa823b6b4e4f183511e0bc0a3be93caa94c7c97","analyzedAt":"2026-08-18T17:04:10.865Z","schemaVersion":2},"datasetVersion":"2026-08-31T19:17:28.585Z"}