siyuan-note/siyuan · error
agent session revision conflict
Error message
agent session revision conflict
What it means
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.
Source
Thrown at kernel/agent/session.go:76
Sessions []*SessionIndexItem `json:"sessions"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
type sessionMeta struct {
ID string `json:"id"`
Title string `json:"title"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
Revision int64 `json:"revision"`
ExpectedRevision *int64 `json:"expectedRevision,omitempty"`
CommitTurnID string `json:"commitTurnID,omitempty"`
RecoveryTurnID string `json:"recoveryTurnID,omitempty"`
LastCommittedTurnID string `json:"lastCommittedTurnID,omitempty"`
}
var ErrSessionConflict = errors.New("agent session revision conflict")
var ErrRuntimeNotFinalized = errors.New("agent runtime turn is not finalized")
var sessionLocks sync.Map
func sessionLock(id string) *sync.Mutex {
lock, _ := sessionLocks.LoadOrStore(id, &sync.Mutex{})
return lock.(*sync.Mutex)
}
func loadSessionIndex() map[string]*SessionIndexItem {
data, err := os.ReadFile(sessionsIndexPath())
if err != nil {
return nil
}
var index map[string]*SessionIndexItem
if gulu.JSON.UnmarshalJSON(data, &index) != nil {
return nil
}View on GitHub (pinned to afa823b6b4)
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
Example fix
// before: fire-and-forget save, loses races
await fetchPost('/api/ai/agent/saveSession', sessionPayload);
// after: CAS loop on revision
let resp = await fetchPost('/api/ai/agent/saveSession', {...payload, expectedRevision: payload.revision});
if (resp.code === -1 && resp.httpStatus === 409) {
const fresh = await fetchPost('/api/ai/agent/getSession', {id: payload.id});
const merged = {...fresh.data, ...localEdits, revision: fresh.data.revision};
resp = await fetchPost('/api/ai/agent/saveSession', {...merged, expectedRevision: fresh.data.revision});
} Defensive patterns
Strategy: retry
Validate before calling
// Before saving, load the authoritative state and use its revision as the CAS token
const fresh = await fetchPost('/api/ai/agent/getSession', {id: session.id});
if (fresh.data.revision !== session.revision) {
session = fresh.data; // rebase local edits onto this before saving
}
payload.expectedRevision = session.revision; Type guard
const isConflict = (code: number, msg: string) =>
code === -1 && msg.includes('agent session revision conflict'); Try / catch
try {
await fetchPost('/api/ai/agent/saveSession', payload);
} catch (e: any) {
if (e?.status === 409) {
const currentRevision = e.data?.data?.revision ?? null;
// re-fetch, merge, retry ONCE with expectedRevision = currentRevision; never loop blindly
}
throw e;
} Prevention
- 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
When it happens
Trigger: 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.
Common situations: 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).
Related errors
- agent runtime turn is not finalized
- invalid session id
- invalid session data
- decode session data failed: %w
- decode existing session data failed: %w
AI-assisted analysis of siyuan-note/siyuan@afa823b6b4 (2026-08-18).
Data as JSON: /api/errors/96d72df40bab2856.
Report an issue: GitHub.