Skip to content

Drift and conflicts

Two people edit the same page. Contexo handles that in two places, and it helps to keep them apart:

  • Drift is a read-time warning. Your local copy is behind the server; nothing has failed yet.
  • A conflict is a write-time rejection. You pushed with a stale parent_sha and the server refused that file with a 409.

Drift detection exists to make conflicts rare. When it works, the agent learns the page moved before it edits, and pulls first.

Every push sends a parent_sha per file, read from .contexo/.sync/state.json:

{
"last_pull_sha": "a4b2c1d3…",
"page_shas": { "wiki/concepts/stripe-subscription.md": "a4b2c1d3…" }
}

That map is written by pushes and pulls only. Editing a page locally does not touch it — which is exactly the point: it records the version you started from, so the server can tell whether you edited the current version or an old one.

A page with no entry in page_shas (never pushed, never pulled) is pushed with an empty parent and can’t conflict, and can’t drift either — there is no baseline to compare against.

When an agent reads ctx://wiki/<slug> or ctx://raw/<session-id>, the MCP server compares your recorded sha for that page against the server’s current one. If they differ, it fetches the diff and prepends this block to the page bytes:

<DRIFT_NOTICE>
This page changed on the server since your last pull.
your version: a4b2c1d
server now: b7e9f20
What changed:
~ frontmatter changed
~ ## Concepts
+ ## Webhooks
- ## Open Questions
Consider `ctx pull` before editing this page. If you push without
pulling, the server will 409 unless your local parent_sha matches.
Call ctx_diff(slug=...) for the full per-section diff.
</DRIFT_NOTICE>

One line per changed section — + added, - removed, ~ modified, ~> renamed — so the notice stays smaller than the page it is attached to. When a page changed only outside its ## sections you get (changes outside structured sections) instead of a section list.

Four properties of the check that decide whether you’ll ever see it:

  • It runs on page reads only. ctx://index, ctx://tags and ctx://search are served straight from disk with no drift check.
  • It needs a baseline. Pages that have never been pulled are skipped, including pages you authored locally and haven’t pushed.
  • The result is cached for 60 seconds per page. An agent re-reading the same page in a loop triggers one round-trip, not fifty. A teammate’s push shows up on the next read after that window.
  • It is best-effort. No credentials, no network, a 5xx from the server, an unparseable diff — all degrade to “no notice”. Drift detection never breaks a page read, and a failure is not cached, so the next read retries.

Set CONTEXO_DRIFT_DISABLE=1 in the agent’s environment to turn the check off entirely — useful when you’re offline and don’t want the latency of a doomed HTTP call on every read.

ctx status runs the same comparison across every locally tracked page:

ctx status
Initialized: yes
Server: https://contexo.example.com
Repo: acme-api
Authenticated: yes (legacy API key)
User: dev <dev@example.com>
Local pages: 1
Last pull: (never)
Pages never pushed: 0
Pages drifted on server: 0

When pages have moved, that last line becomes Pages drifted on server (run `ctx pull` to refresh): 2, followed by one indented row per page showing the path and (local <sha> → server <sha>). It is one request per tracked page, so on a big knowledge base ctx status --no-drift is the fast, offline-friendly version.

The conflict loop, when the agent is driving

Section titled “The conflict loop, when the agent is driving”

If the agent pushes anyway — because drift was disabled, or the teammate pushed in the last minute, or the page was edited from a different machine — the server accepts the files whose parents match and rejects the rest with a 409. ctx_push turns that rejection into a directive rather than an error:

<MERGE_REQUIRED>
Push rejected: 1 file(s) have moved on the server since your edit.
Reconcile each file below by writing a merged version that
incorporates BOTH your changes AND the server's changes, then
re-invoke ctx_push. The state has been updated so the re-push
uses the server's current sha as the new parent.
─── FILE 1/1: wiki/concepts/stripe-subscription.md ───
ancestor (both diverged from): a4b2c1d
server now: b7e9f20
YOUR changes vs ancestor:
~ frontmatter changed
~ ## Concepts
SERVER changes vs ancestor:
~ frontmatter changed
~ ## Concepts
+ ## Webhooks
CONFLICTING regions (both sides changed):
## Concepts — synthesize both edits into one coherent version
--- ANCESTOR VERSION ---
<full text of the version you both started from>
--- YOUR VERSION (the one you tried to push) ---
<full text of what you tried to push>
--- SERVER VERSION ---
<full text of what is on the server now>
STEPS for the agent:
1. For each file above: write a merged version via
ctx_write_page(slug=..., type=..., body=<merged content>)
2. Re-invoke ctx_push with the SAME filters. …
</MERGE_REQUIRED>

It is a three-way merge presented as text: the common ancestor, your version, the server’s version, plus both one-sided section diffs so the model can see who changed what. Sections modified on both sides are called out as conflicting regions with an explicit instruction to synthesise rather than pick a winner. When no section overlaps, the block says so — (No section was modified by both sides — auto-mergeable in principle.) — and the merge is mechanical.

The whole loop is: push → <MERGE_REQUIRED>ctx_write_page with merged content → push again. Only the rejected files wait for that second push: in a mixed batch the files whose parents matched were committed by the first one, and the directive lists just the ones that weren’t.

ctx push has no merge machinery. On a 409 it prints N conflict(s): to stderr with a current=… expected_parent=… line per file, tells you to Resolve by running 'ctx pull', merging the conflicting pages, then 'ctx push' again, and exits non-zero with push: N conflict(s) remain. Files whose parents did match are already committed.

Follow that advice literally and you will lose your edit, because ctx pull overwrites — it does not merge. Any page that changed on the server is rewritten with the server’s bytes, and your local version is gone. So do this instead:

Terminal window
# 1. See exactly what your local copy would change, while you still have it.
ctx diff stripe-subscription --local
# 2. Keep a copy of your version.
cp .contexo/wiki/concepts/stripe-subscription.md /tmp/mine.md
# 3. Take the server's version.
ctx pull
# 4. Merge /tmp/mine.md into the pulled file by hand (or with your editor's merge tool).
# 5. Preview, then push.
ctx push --show-diff

Step 1 is the one people skip. ctx diff --local is the only view of your divergence that exists before the pull destroys it, and it is section-aware, so it tells you which headings you actually touched.

Step 5 shows the [EDIT] preview and asks for confirmation before altering an existing page. Pass --yes only when you’re scripting; without a TTY the push refuses to alter existing pages rather than proceeding unattended.

  • Pull at the start of a session. The ctx_pull tool description tells agents to do exactly this.
  • Leave drift detection on. The notice is cheap and it is the only warning that arrives before the edit.
  • Push soon after you write. The window between writing and pushing is the window in which someone else’s push turns yours into a merge.
  • ctx status when you come back to a project after a few days.
Variable Effect
CONTEXO_DRIFT_DISABLE=1 No <DRIFT_NOTICE> on MCP page reads. ctx status --no-drift is the CLI equivalent.
CONTEXO_DISTILL_DISABLE=1 ctx_push never pauses for the source-page handshake.
CONTEXO_CAPTURE_DISABLE=1 ctx capture turn no-ops, so no buffer is written for the session.
  • ctx_push — the tool that produces <MERGE_REQUIRED>
  • ctx status — the drift report, and --no-drift
  • ctx diff--local before you pull, sha-to-sha after
  • The daily flow — the push path that leads here