Concurrency, commits and git synchronization¶
Writer boundary¶
Each mounted KB has one in-process mutex. Write tools acquire it for the filesystem change and the associated git operation, so one server process is the sole writer of that working copy.
Do not mount the same local checkout into multiple writer processes. Separate clones can synchronize through the same remote, but high-contention multi-writer operation is not a supported scaling model; partition KBs across instances instead.
concept_batch (D125) runs its whole multi-concept transaction under this
same single per-KB lock — it does not acquire a second one. Every operation
is validated and its content materialized in memory before the first byte is
written; if a write, the summary log.md entry, or the caller's index-sync
step then fails partway, every file the call already wrote (including any
implicit expanded-index stub a new directory triggered) and log.md are
restored to their exact pre-call bytes and mode before the error is
returned. Unlike concept_move — which documents that a rewrite_links
failure leaves the already-applied moves in place — concept_batch never
leaves a partially-applied batch on disk or in the search indexes: callers
observe either the complete batch or the exact pre-call KB state. It does
not open a nested git commit; a rolled-back call also leaves no commit,
since gitWrap only commits after the handler returns success.
The advisory KB lock¶
Inside one server process, git operations are serialised by a per-KB mutex. That mutex is blind to
any other process, and cartographer import is exactly that: a separate process writing into the
same directory the sync loop manages. The two interleaving corrupted the git index — 617 staged
deletions and 224 untracked entries for the same paths, with HEAD and the working tree verified
byte-identical — so since D155 both take an advisory lock file, .cartographer.lock in the KB
root (dot-prefixed, so every walker already skips it, and excluded via .git/info/exclude so it can
never dirty the working tree).
Advisory, not mandatory, on purpose:
- a lock whose recorded pid no longer exists is reclaimed with a note on stderr — a crash must not block a KB forever;
- the server waits for a bounded window and then proceeds under its mutex alone, reporting the condition: a lock held by something else must not stop the server from writing;
importfails fast, naming the holder and how to stop it. An import is an operator action at a keyboard, and a silent ten-minute block is worse than an error.--dry-runwrites nothing and never takes the lock.
An in-progress rebase makes sync refuse. An aborted pull --rebase --autostash left a
.git/rebase-merge containing only an autostash, and from then on every MCP write failed with git's
own "there is already a rebase-merge directory" — a message that names no way out — until the
directory was removed by hand. SyncIn/SyncOut now detect the state first and return an error that
names the remedy: rebase --continue/--abort for a real rebase, or the stash list inspection
followed by removing the directory for an orphan autostash. Detection plus instruction, never
automatic deletion: the directory may hold a real operator rebase, or an autostash holding the only
copy of uncommitted work, and deleting another writer's state is the class of action that caused the
incident in the first place.
The advisory client lock¶
The client side has its own advisory lock, for the same reason and with a different scope:
.cartographer-client.lock in the client's target directory, taken by every path that
read-modify-writes the lockfile or .cartographer.yaml (sync, disconnect,
doctor --repair-hashes, the TUI's sync actions). The concurrent writers are separate
cartographer sync processes — the session-start bootstrap hook starts one per agent session —
and before D172 the loser of that race silently dropped another provider's lockfile entry. It is an
flock, released on every exit path, with a bounded 30s wait that fails naming the file rather than
proceeding. Details and the surrounding order of operations: sync.md §The client lock.
Git profiles¶
git.profile: local is the default and the historical behavior. When
git.auto_commit is enabled, successful write tools are wrapped by gitWrap and
produce one commit per logical operation, on the branch currently checked out in
the KB clone. Multi-file operations are committed together, individual file
replacement is atomic, and Cartographer creates no working branch, opens no pull
request and never merges into main: repository review policy belongs to the
remote hosting workflow.
Each commit subject is <tool_name>: <resource>, so the history of a KB is
readable as an audit trail. The resource is built from the arguments that
identify what the write touched: path for the artifact tools, concept_id/path
for the asset tools, and otherwise the first of id, name, source_id,
contradiction_id present in the call. A tool invoked with none of them commits
as the bare tool name.
git.profile: server (D117) is an opt-in GitHub review boundary. It requires a
base branch, a dedicated working branch, GitHub owner/repository/API URL and the
name of an environment variable holding a token. On mount Cartographer fetches
origin, checks out or creates the dedicated branch from the remote base, and
refuses dirty, detached, ambiguous, mismatched or shared-working-branch checkouts.
The origin path must exactly match the configured github_owner/github_repository
for both HTTPS and SSH remotes (credentials are never logged). The base branch is
never a push target.
Each successful server-profile push creates or reuses exactly one open PR from the
working branch to the base. pr_status and sync_status expose the non-secret PR
identity and forge degradation. pr_finalize(head_sha) is an advanced operator
action: it requires the caller's current PR head, satisfied reviews and checks, a
fresh rebase and validation, then uses force-with-lease only on the working branch
before requesting a GitHub squash merge. After the merge the clean working branch is
reset to the fetched base for the next PR cycle.
If a forge timeout happens after the merge request, the profile enters
merge_uncertain: new writes are refused until startup or pr_status verifies the
merged PR's resulting commit on the base and safely resets the working branch. It
never opens a replacement PR during that recovery.
Remote synchronization¶
When git.sync is enabled and origin exists:
SyncInfetches and runs pull/rebase/autostash before a local-profile write; server profile rebases the dedicated working branch ontoorigin/<base>. A freshness window can skip repeated fetches within the configured interval.- The write and local commit run under the same KB lock.
SyncOutqueues a debounced background push. Sync-sensitive operations and graceful HTTP shutdown flush pending work.- A rejected push retries through fetch + pull/rebase + push, with bounded backoff. Server profile incorporates a concurrent working-branch update and replays it on the base; automatic force is never used outside finalization.
sync_status is the authoritative read-only view of replication: it reports
whether sync is disabled, no remote exists, a push is pending, or the latest
commit/push failed, together with the best available ahead count. A failure is
cleared only after a real push succeeds. Offline writes remain local-successful;
in debounced mode their response reports pending because the later result is
not yet knowable.
Without a remote, synchronization is a no-op and local commits still work.
Conflict registry¶
A rebase conflict is aborted immediately. For conflicting concept files the
server records local/remote SHAs in
.cartographer/conflicts.json and marks the working copy's concept
status: degraded.
The KB remains available for unrelated concepts. conflicts_list exposes the
registry and sync_check reports open_conflicts.
git_conflict_resolve(concept_id, strategy, body?) records one of:
ours— retain the local version;theirs— retain the remote version;edit— use the supplied complete reconciled file.
After every registered conflict has a resolution, Local Core performs one merge transaction, materializes the selected contents, commits and attempts the push. On failure it aborts the merge and keeps the registry so resolution can be retried. Server profile instead commits the selected content on its working branch, rebases that branch onto the current base and updates the PR; it never merges or pushes the base.
Optimistic content concurrency¶
concept_read returns normalized content hashes. In server profile they reflect
the working branch after its latest base rebase, not a frozen PR-open snapshot.
Write tools that accept if_match reject an update with stale_write when the
stored content no longer matches.
There are no advisory per-concept leases or session locks. Concurrency safety comes from the KB mutex, content hashes and git conflict handling.
Search index concurrency¶
The derived indexes are read and written by concurrent requests — under HTTP a
search and a concept_write land on different goroutines — and they sit
outside the writer boundary above: the KB mutex serialises the filesystem and
git work, not the in-memory index.
search.Index is a pair of plain maps with no synchronization of its own, so
the liveIndex wrapper in internal/mcpserver owns that discipline. Its read
lock is held for the whole query, not just long enough to load the index
pointer: ranking walks the same maps an incremental add/remove mutates, so
a search that ranks after releasing the lock races every concurrent write, and
the usual symptom is a concurrent map read and map write panic rather than a
stale result. The allow predicate a caller passes is invoked while that lock
is held and must not call back into the same liveIndex.
Operator recovery¶
Cartographer does not promise automatic repair of arbitrary interrupted git state. Before mounting a KB, the checkout should have no active merge/rebase and no unexplained working-tree changes. Back up the repository and follow the deployment recovery procedure for storage or remote failures.