{"record":{"id":"96d72df40bab2856","repo":"siyuan-note/siyuan","slug":"agent-session-revision-conflict","errorCode":null,"errorMessage":"agent session revision conflict","messagePattern":"agent session revision conflict","errorType":"http","errorClass":null,"httpStatus":409,"severity":"error","filePath":"kernel/agent/session.go","lineNumber":76,"sourceCode":"\tSessions []*SessionIndexItem `json:\"sessions\"`\n\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}","sourceCodeStart":58,"sourceCodeEnd":94,"githubUrl":"https://github.com/siyuan-note/siyuan/blob/afa823b6b4e4f183511e0bc0a3be93caa94c7c97/kernel/agent/session.go#L58-L94","documentation":"Optimistic-concurrency guard for the AI Agent session store. SaveSessionState (kernel/agent/session.go) treats session.json's revision field as a compare-and-swap token; when a save request carries expectedRevision or commitTurnID that does not match what is on disk / in the runtime, it returns ErrSessionConflict instead of writing. The saveSession handler (kernel/api/agent.go:641) maps it to HTTP 409 with Data {\"revision\": <currentRevision>} so the client can rebase and retry.","triggerScenarios":"POST /api/ai/agent/saveSession in any of four cases: (1) expectedRevision != the revision inside data/storage/ai/agent/sessions/<id>/session.json (line 365); (2) session.json does not exist but expectedRevision is non-zero (line 381); (3) the runtime's ActiveTurn.TurnID differs from the submitted commitTurnID (line 390); (4) no active runtime turn and lastCommittedTurnID != commitTurnID (line 399). Typical producer: two windows/instances saving the same session, a save racing the runtime's own commit, or retrying with a stale revision after a kernel restart.","commonSituations":"Multi-window or multi-device use of the same agent session; an older frontend holding a pre-restart snapshot; a client retry that lost the 409 race; a session that was concurrently deleted and recreated (its revision restarted at 0).","solutions":["On 409, read Data.revision from the response, then GET /api/ai/agent/getSession for the authoritative state","Merge/replay your local edits (title, settings) onto that fresh state and resend with expectedRevision set to the returned revision","If committing a finished turn, resend the exact same commitTurnID — a retry of an already-committed turn is detected and returns the committed state idempotently (line 357)","If the session was deleted meanwhile, treat the conflict as 'session gone' and create a new session instead of forcing the save"],"exampleFix":"// before: fire-and-forget save, loses races\nawait fetchPost('/api/ai/agent/saveSession', sessionPayload);\n\n// after: CAS loop on revision\nlet resp = await fetchPost('/api/ai/agent/saveSession', {...payload, expectedRevision: payload.revision});\nif (resp.code === -1 && resp.httpStatus === 409) {\n  const fresh = await fetchPost('/api/ai/agent/getSession', {id: payload.id});\n  const merged = {...fresh.data, ...localEdits, revision: fresh.data.revision};\n  resp = await fetchPost('/api/ai/agent/saveSession', {...merged, expectedRevision: fresh.data.revision});\n}","handlingStrategy":"retry","validationCode":"// Before saving, load the authoritative state and use its revision as the CAS token\nconst fresh = await fetchPost('/api/ai/agent/getSession', {id: session.id});\nif (fresh.data.revision !== session.revision) {\n  session = fresh.data; // rebase local edits onto this before saving\n}\npayload.expectedRevision = session.revision;","typeGuard":"const isConflict = (code: number, msg: string) =>\n  code === -1 && msg.includes('agent session revision conflict');","tryCatchPattern":"try {\n  await fetchPost('/api/ai/agent/saveSession', payload);\n} catch (e: any) {\n  if (e?.status === 409) {\n    const currentRevision = e.data?.data?.revision ?? null;\n    // re-fetch, merge, retry ONCE with expectedRevision = currentRevision; never loop blindly\n  }\n  throw e;\n}","preventionTips":["Always send expectedRevision taken from the last successful getSession/saveSession response","Keep exactly one writer per session (one window/instance) to avoid save races","Treat 409 + revision in Data as a rebalance signal, not an error to show the user","Idempotent commit retries are safe only with the identical commitTurnID"],"tags":["agent","session","optimistic-locking","conflict","http-409"],"backgroundTag":"optimistic-lock-conflict","analyzedSha":"afa823b6b4e4f183511e0bc0a3be93caa94c7c97","analyzedAt":"2026-08-18T17:04:10.865Z","schemaVersion":2},"datasetVersion":"2026-08-31T14:17:45.589Z"}