You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
I searched existing issues and this isn't a duplicate
I'm on the latest version (or I've noted my version below)
What happened?
Two callers writing to the same document via POST /api/agent-write-md with position: "replace", overlapping in time and neither having read the other's change first, both get back 200 with the identical minimal success shape (no warning/warnings field,
no divergence indicator, no conflict status) — and one of those two responses is wrong. One
caller's entire document body is silently discarded: not merged, not interleaved, not recoverable
from either response, and nothing in the response or the server log tells the losing caller its
write didn't happen. That's the primary thing we're reporting: a caller is told its write
succeeded when it was entirely thrown away, with no error, no conflict status, and no divergence
warning. The data loss is the symptom; the dishonest 200 is the defect, and it's the part we'd
ask you to fix regardless of which underlying mechanism turns out to be responsible (see below).
For contrast: the same overlap shape with position: "append" merges correctly — both callers'
inserted text is present, byte-exact, no corruption at the merge boundary. So whatever holds for
additive, non-overlapping edits works here; it's specifically the whole-document replace shape
that has this property.
Expected behavior: either the concurrent replaces merge in some defined way, or the losing
write is surfaced as a conflict/error the caller can act on — the same kind of signal the
codebase already has for other write paths (an agent-write-content-divergence warning exists;
a respondDocInConflict 409 exists for agent-patch's offset-based stale-target detection).
Silently discarding a caller's write with a 200 success response is the part that seems worth
fixing regardless of which specific remedy is chosen.
Actual behavior: one caller's entire document body is deleted by the other caller's
concurrent replace, with no error, no conflict status, and no divergence warning in either
response.
What we think is happening — and the question for you
We don't think this is a CRDT bug, and we'd rather say that plainly than let the report imply
otherwise. We ran a standalone demonstration against yjs@13.6.32 directly — not against OK's
server, so this is evidence about Yjs's semantics, not about your implementation — with two
replicas of one Y.Doc, each doing a whole-document replace (delete-all + insert-all, the shape
PR #5 describes replace as using), then merged both ways. They converged, and both bodies
were present, concatenated. Yjs's CRDT does not silently drop a concurrent write under genuine
concurrency — the opposite of what our repro shows.
What does reproduce our exact observation, in that same standalone test, is serialized
transacts against a single shared Y.Doc: whichever commits last wins, the other body is absent
entirely, not merged and not duplicated.
So whatever is discarding one write here, it isn't the CRDT merge — which is why we're not
reporting this as a CRDT defect. From outside, we can see (at least) three shapes that would all
look identical in our repro and differ only in what fix they need:
the two requests were serialized against one document — defensible design; the only defect is
the 200.
each request round-tripped its own document snapshot before persisting — a genuine lost update
at the persistence layer.
replace is modelled as a whole-value set with last-writer-wins.
We genuinely don't know which, and can't tell from outside — but you can, in minutes, from the
source. Which of these is it? The right fix differs by shape (a signal vs. serialization vs. a
data-model change), so knowing that matters more to us than any specific fix we could suggest
below, and we'd rather ask than guess.
Have you tried the latest release? (anticipating the first question a maintainer will ask)
Yes — executed against both the current latest stable release and the current latest published
prerelease:
Executed (installed, ran, hit with real concurrent HTTP requests): v0.46.2 (2026-08-04),
v0.48.4 (2026-08-05), v0.48.10 (2026-08-06, current latest stable), and v0.49.0-beta.24 (2026-08-06, current latest published prerelease). All four reproduce
identically. v0.48.10 is one commit past v0.48.9, and that commit
(3840a6ee, an ACP follow-file phantom-tab fix) touches only packages/app/src/components/acp/follow-file.ts and its test — the packages/server diff
between v0.48.9 and v0.48.10 is empty.
Code-compared (SHA-256 over agent-sessions.ts and conflict-errors.ts, plus an
anchor-extracted diff of the handleAgentWriteMd handler body, 324 lines): byte-identical at
every tag checked — v0.48.4, v0.48.9, v0.48.10, v0.49.0-beta.18, and v0.49.0-beta.24. None of
the commits between v0.48.4 and beta.24 targets concurrency, CRDT merge, or replace behaviour
(keyword-swept: concurren|conflict|crdt|yjs|replace|merge|divergen|race|lost|overwrit, 10/26
hits on beta.18's commit range, all unrelated — desktop spawn errors, Finder integration,
sidebar collapse, a Slidev plugin, ACP reliability). This time the code prediction and the
executed run agree at the same build: we ran v0.49.0-beta.24 itself, not just diffed it.
One adjacent, unrelated change worth flagging proactively: a v0.49 beta route-migration
(api-pipeline.ts on Hono) tightens the CSRF Origin gate on /api/agent-write-md — non-loopback
origins now 403, and no-Origin callers (curl, CLI, any non-browser agent) still pass through by
design. Confirmed from the actual v0.49.0-beta.24 run: both concurrent curl calls (no Origin
header) returned 200, not 403. It doesn't touch this bug at all, noted only so it isn't
mistaken for a fix if it comes up.
OBSERVED vs INFERRED
Observed, via the HTTP API against local v0.46.2 and v0.48.4 instances (both executed,
identical result each time):
Both position: "replace" calls returned 200 with an identical minimal response shape.
Only one caller's content is present on disk afterward; the other's is not present anywhere in
the file (checked byte-for-byte, not just via a text diff tool).
The caller whose HTTP response arrived earlier is not the one whose content survived — the
content that survived came from the caller whose request/response window closed later.
A server-log grep for divergence/reconcile/conflict/error signals across the write window
returned zero matches for this scenario.
Inferred (not instrumented from a running build during this trial):
We ruled out one candidate mechanism, not by inference but by running it: a standalone
demonstration against yjs@13.6.32 (two replicas of one Y.Doc, each doing the
delete-all+insert-all shape PR Agent-write content-divergence warnings now carry the converged… #5 describes replace as using) converged with both writes
present under genuine CRDT concurrency. That result rules the CRDT merge itself out as the
mechanism here — it does not produce silent total loss of one write, it duplicates.
What does reproduce our exact observation, in that same standalone test, is serialized
transacts against a single shared Y.Doc: whichever commits last deletes everything present at
that moment, and the loser's content isn't merged, duplicated, or recoverable — it's simply
gone. That's consistent with PR Agent-write content-divergence warnings now carry the converged… #5's own description of replace as "delete the prior bytes
wholesale before inserting the new payload." But PR Agent-write content-divergence warnings now carry the converged… #5 describes the write primitive, not
whether OK serializes concurrent calls against one document, round-trips separate document
snapshots per request, or does something else entirely — so from outside we can't tell which of
those shapes is actually happening on the server. See "What we think is happening — and the
question for you," above, for the three candidates and the question we're asking.
This is offered as a demonstrated result about Yjs's semantics plus an open question about OK's
server behaviour, not a code-level diagnosis of this specific path — we did not read or
instrument OK's source for this trial.
What this report did NOT establish (so it doesn't imply broader coverage than it has): only
the HTTP API (agent-write-md) was exercised — the Electron/WYSIWYG save path and the MCP write_document tool were not tested, though they may share the underlying CRDT primitive per
PR #5. No version between the four executed tags (v0.46.2, v0.48.4, v0.48.10,
v0.49.0-beta.24) was independently executed — those gaps rest on the code comparison
described above, not a fresh run. And, per the question above: which of the three candidate
shapes (serialized transacts against one document, per-request snapshot round-trip, or
whole-value last-writer-wins) actually produces this on OK's server is something we could not
determine from outside — our Yjs demonstration rules out the CRDT merge as the mechanism, but it
doesn't tell us which server-layer shape replaces it.
Suggested direction (entirely yours to take or leave)
Ranked by how confident we are that it's worth doing, not by how much code it is — options, not a
demand; the maintainers know this codebase and may see a better one:
Return a conflict instead of 200 when a stale/concurrent replace is detected. Detection
and honest reporting, without requiring the API to actually solve the merge. This is correct
under all three candidate shapes above — whichever one it turns out to be, telling the losing
caller it succeeded is wrong — and it's the smallest thing to accept. Prior art: RFC 9110
§13.1.1's 412 Precondition Failed; the GitHub Contents API's 409 on a stale blob sha.
This was our fallback suggestion in an earlier pass at this report; on reflection it's the
headline, not the fallback.
Optimistic concurrency: a version witness on read, a precondition on write. We'd frame this
as a server-issued version token rather than a content hash — hashing serialized content
produces false conflicts on whitespace/line-ending/serialization drift that isn't a real
conflict. Prior art: RFC 9110 ETag/If-Match → 412; the GitHub Contents API requires the
blob sha on update and returns 409 "<path> does not match <sha>"; Google Docs' writeControl.requiredRevisionId. The companion requirement is that the read path has to
return the witness in the first place — we did not verify whether OK's read path currently
does, and that's the main reason we're not calling this "the lighter lift" the way we might
have assumed.
A targeted-edit primitive, so whole-document replace becomes the rare case. We hadn't
considered this until researching for this report, but it's the direction other comparable
agent-facing products already converge on: Notion's markdown API documents update_content
(old_str/new_str) as "the recommended approach for making precise edits without rewriting
the full page", ahead of replace_content; Yjs's own idiom is diff-then-applyDelta, not
delete-all+insert-all; Liveblocks' REST write takes a Yjs update, not a document string. This
removes the problem rather than reporting it — an agent doing read-modify-write against a
whole document is the pattern worth designing away, not just handling more gracefully.
Merge-against-latest as an opt-in second mode. Google Docs' writeControl.targetRevisionId
takes this approach — applying the write as a delta against current state instead of rejecting
it, on the premise that "the API client can be thought of as another collaborator."
One option we considered and are not suggesting: an explicit checkout/lock on a document for
the duration of a replace. Noting it (and why we dropped it) rather than silently leaving it out —
an agent's read-modify-write can hold a lock for minutes of inference, not milliseconds, and a
recent paper on multi-agent concurrency control (CoAgent, arXiv:2606.15376) measured two-phase
locking deadlocking 0.81 times per trial under agent-style workloads. On a single-user
local-first machine there's also no operator around to clear a lock stranded by a crashed
session.
We ran the Yjs demonstration above as a standalone test against yjs@13.6.32 directly — not
against your server — so it's evidence about Yjs's semantics only, not about your implementation.
Happy to share the script (a few dozen lines) alongside the reproduction script offered below, if
it's useful for checking our reasoning.
Thank you for Open Knowledge — this is being reported because we'd like to keep building on it,
not as a drive-by. Happy to provide more detail, the reproduction script, the Yjs demonstration
script, or re-run against a specific build if useful.
Steps to reproduce
Install @inkeep/open-knowledge and start it: npm install @inkeep/open-knowledge@<version>
(non-global install works fine), then ok start (or ok init first if the project isn't
initialized yet). We used --host 127.0.0.1 --port <port>.
Create a document, e.g. concurrency-test-replace.md, with any starting content.
Fire two POST /api/agent-write-md requests at that document with position: "replace" and
distinct sentinel strings, launched in the same shell tick as backgrounded subshells so
neither reads the other's write first and the requests genuinely overlap (see the snippet
below — no sleep, no ordering between them):
(
curl -s -X POST http://127.0.0.1:<port>/api/agent-write-md \
-H 'Content-Type: application/json' \
-d '{ "docName": "concurrency-test-replace", "agentId": "agent-a", "position": "replace", "markdown": "# Replace Conflict Test\n\nAGENT-A-FULL-REPLACE-BODY\nAgent A wrote this entire document without reading Agent B'\''s change.\n" }'
) &
PID_A=$!
(
curl -s -X POST http://127.0.0.1:<port>/api/agent-write-md \
-H 'Content-Type: application/json' \
-d '{ "docName": "concurrency-test-replace", "agentId": "agent-b", "position": "replace", "markdown": "# Replace Conflict Test\n\nAGENT-B-FULL-REPLACE-BODY\nAgent B wrote this entire document without reading Agent A'\''s change.\n" }'
) &
PID_B=$!wait$PID_A$PID_B
Read the document back (GET /api/agent-read-md, or the file on disk).
Expected: both markers present in some resolvable form, or an error/conflict on one call. Observed: only one of AGENT-A-FULL-REPLACE-BODY / AGENT-B-FULL-REPLACE-BODY is
present; the other is not in the file anywhere, and neither HTTP response indicated anything
was at risk.
We have this as a standalone, runnable script (repro-concurrent-replace.sh) that does the
above end-to-end — its own sandbox setup, install, server start, both experiments, assertion,
self-teardown — and ran it (not just the manual steps above) against v0.46.2, v0.48.4, v0.48.10,
and v0.49.0-beta.24, confirmed to reproduce every time, including runs against completely
independent throwaway sandboxes to rule out setup contamination. Happy to attach or paste it if
useful; keeping it out of the issue body itself since it's a few hundred lines.
Platform
macOS (Apple Silicon)
How did you install OpenKnowledge?
CLI (npm / npx)
Version
Executed and reproduced at all four: v0.46.2 (2026-08-04), v0.48.4 (2026-08-05), v0.48.10
(2026-08-06, current latest stable release), and v0.49.0-beta.24 (2026-08-06, current
latest published prerelease), each via ok --version against a real install. v0.48.10 is
one commit past v0.48.9 — 3840a6ee fix(open-knowledge): stop ACP follow-file opening blank phantom tabs (#3280) — and that commit touches only
packages/app/src/components/acp/follow-file.ts and its test; the packages/server diff
between v0.48.9 and v0.48.10 is empty.
We separately checked (code comparison) whether the write path itself changed anywhere in
this range: agent-sessions.ts, conflict-errors.ts, and the handleAgentWriteMd handler body
are byte-identical (SHA-256) at v0.48.4, v0.48.9, v0.48.10, v0.49.0-beta.18, and
v0.49.0-beta.24. None of the commits in this range touches concurrency/CRDT/replace
behaviour. Unlike our first pass at this on 2026-08-05 (where the beta was diffed but not
run), this time we also executed v0.49.0-beta.24 directly — the code prediction and the
executed run now agree at the same build, on both the stable and beta lines.
Prerelease-vs-default note, stated for transparency: v0.49.0-beta.24 is a published
prerelease, not what npm install @inkeep/open-knowledge resolves to by default (that's
still v0.48.10 as of this report — npm's latest dist-tag). We tested it explicitly by
version pin because it is the newest published build, not because it's what a typical
user runs.
---
### Logs, errors, or screenshots
```shell
# Both API responses for the position:"replace" experiment (v0.48.4 run, 2026-08-05) — 200 OK,
# identical minimal shape, no warning/conflict/divergence field on either side:
// Agent A response
{"timestamp":"2026-08-05T07:24:19.673Z","subscriberCount":0,"systemSubscriberCount":0,"hints":[{"type":"orphan","parentCandidates":["README"],"message":"This doc has no backlinks yet. To make it discoverable, consider linking from a parent hub doc (index/overview files in the folder tree): [[README]]."}],"brokenLinks":[]}
// Agent B response
{"timestamp":"2026-08-05T07:24:19.668Z","subscriberCount":0,"systemSubscriberCount":0,"hints":[{"type":"orphan","parentCandidates":["README"],"message":"This doc has no backlinks yet. To make it discoverable, consider linking from a parent hub doc (index/overview files in the folder tree): [[README]]."}],"brokenLinks":[]}
# File on disk after both writes — only Agent A's body survived, Agent B's is gone entirely:
$ grep -c AGENT-A-FULL-REPLACE-BODY concurrency-test-replace.md
1
$ grep -c AGENT-B-FULL-REPLACE-BODY concurrency-test-replace.md
0
# Server log grep across the write window — no divergence/conflict/error signal fired:
$ grep -iE "divergence|reconcile|conflict|error" server.log
(0 matches, exit 1)
# Timing evidence that the two requests genuinely overlapped (nanosecond timestamps, independently
# captured by each caller):
B start=1785914659.651711000 end=1785914659.682926000
A start=1785914659.651471000 end=1785914659.683895000
# Started 240 microseconds apart; A's window fully contains B's. B's request/response window
# closed earlier, yet A's content is what survived — consistent with "whichever server-side
# transact commits last deletes everything present at that moment," not simple
# last-response-wins or request-arrival-order.
Preflight
What happened?
Two callers writing to the same document via
POST /api/agent-write-mdwithposition: "replace", overlapping in time and neither having read the other's change first,both get back
200with the identical minimal success shape (nowarning/warningsfield,no divergence indicator, no conflict status) — and one of those two responses is wrong. One
caller's entire document body is silently discarded: not merged, not interleaved, not recoverable
from either response, and nothing in the response or the server log tells the losing caller its
write didn't happen. That's the primary thing we're reporting: a caller is told its write
succeeded when it was entirely thrown away, with no error, no conflict status, and no divergence
warning. The data loss is the symptom; the dishonest
200is the defect, and it's the part we'dask you to fix regardless of which underlying mechanism turns out to be responsible (see below).
For contrast: the same overlap shape with
position: "append"merges correctly — both callers'inserted text is present, byte-exact, no corruption at the merge boundary. So whatever holds for
additive, non-overlapping edits works here; it's specifically the whole-document
replaceshapethat has this property.
Expected behavior: either the concurrent replaces merge in some defined way, or the losing
write is surfaced as a conflict/error the caller can act on — the same kind of signal the
codebase already has for other write paths (an
agent-write-content-divergencewarning exists;a
respondDocInConflict409 exists foragent-patch's offset-based stale-target detection).Silently discarding a caller's write with a
200success response is the part that seems worthfixing regardless of which specific remedy is chosen.
Actual behavior: one caller's entire document body is deleted by the other caller's
concurrent replace, with no error, no conflict status, and no divergence warning in either
response.
What we think is happening — and the question for you
We don't think this is a CRDT bug, and we'd rather say that plainly than let the report imply
otherwise. We ran a standalone demonstration against
yjs@13.6.32directly — not against OK'sserver, so this is evidence about Yjs's semantics, not about your implementation — with two
replicas of one
Y.Doc, each doing a whole-document replace (delete-all + insert-all, the shapePR #5 describes
replaceas using), then merged both ways. They converged, and both bodieswere present, concatenated. Yjs's CRDT does not silently drop a concurrent write under genuine
concurrency — the opposite of what our repro shows.
What does reproduce our exact observation, in that same standalone test, is serialized
transacts against a single shared
Y.Doc: whichever commits last wins, the other body is absententirely, not merged and not duplicated.
So whatever is discarding one write here, it isn't the CRDT merge — which is why we're not
reporting this as a CRDT defect. From outside, we can see (at least) three shapes that would all
look identical in our repro and differ only in what fix they need:
the
200.at the persistence layer.
replaceis modelled as a whole-value set with last-writer-wins.We genuinely don't know which, and can't tell from outside — but you can, in minutes, from the
source. Which of these is it? The right fix differs by shape (a signal vs. serialization vs. a
data-model change), so knowing that matters more to us than any specific fix we could suggest
below, and we'd rather ask than guess.
Have you tried the latest release? (anticipating the first question a maintainer will ask)
Yes — executed against both the current latest stable release and the current latest published
prerelease:
v0.48.4 (2026-08-05), v0.48.10 (2026-08-06, current latest stable), and
v0.49.0-beta.24 (2026-08-06, current latest published prerelease). All four reproduce
identically. v0.48.10 is one commit past v0.48.9, and that commit
(
3840a6ee, an ACP follow-file phantom-tab fix) touches onlypackages/app/src/components/acp/follow-file.tsand its test — thepackages/serverdiffbetween v0.48.9 and v0.48.10 is empty.
agent-sessions.tsandconflict-errors.ts, plus ananchor-extracted diff of the
handleAgentWriteMdhandler body, 324 lines): byte-identical atevery tag checked — v0.48.4, v0.48.9, v0.48.10, v0.49.0-beta.18, and v0.49.0-beta.24. None of
the commits between v0.48.4 and beta.24 targets concurrency, CRDT merge, or
replacebehaviour(keyword-swept:
concurren|conflict|crdt|yjs|replace|merge|divergen|race|lost|overwrit, 10/26hits on beta.18's commit range, all unrelated — desktop spawn errors, Finder integration,
sidebar collapse, a Slidev plugin, ACP reliability). This time the code prediction and the
executed run agree at the same build: we ran v0.49.0-beta.24 itself, not just diffed it.
v0.49beta route-migration(
api-pipeline.tson Hono) tightens the CSRF Origin gate on/api/agent-write-md— non-loopbackorigins now 403, and no-Origin callers (curl, CLI, any non-browser agent) still pass through by
design. Confirmed from the actual v0.49.0-beta.24 run: both concurrent
curlcalls (noOriginheader) returned
200, not403. It doesn't touch this bug at all, noted only so it isn'tmistaken for a fix if it comes up.
OBSERVED vs INFERRED
Observed, via the HTTP API against local v0.46.2 and v0.48.4 instances (both executed,
identical result each time):
position: "replace"calls returned200with an identical minimal response shape.the file (checked byte-for-byte, not just via a text diff tool).
content that survived came from the caller whose request/response window closed later.
returned zero matches for this scenario.
Inferred (not instrumented from a running build during this trial):
demonstration against
yjs@13.6.32(two replicas of oneY.Doc, each doing thedelete-all+insert-all shape PR Agent-write content-divergence warnings now carry the converged… #5 describes
replaceas using) converged with both writespresent under genuine CRDT concurrency. That result rules the CRDT merge itself out as the
mechanism here — it does not produce silent total loss of one write, it duplicates.
transacts against a single shared
Y.Doc: whichever commits last deletes everything present atthat moment, and the loser's content isn't merged, duplicated, or recoverable — it's simply
gone. That's consistent with PR Agent-write content-divergence warnings now carry the converged… #5's own description of
replaceas "delete the prior byteswholesale before inserting the new payload." But PR Agent-write content-divergence warnings now carry the converged… #5 describes the write primitive, not
whether OK serializes concurrent calls against one document, round-trips separate document
snapshots per request, or does something else entirely — so from outside we can't tell which of
those shapes is actually happening on the server. See "What we think is happening — and the
question for you," above, for the three candidates and the question we're asking.
server behaviour, not a code-level diagnosis of this specific path — we did not read or
instrument OK's source for this trial.
What this report did NOT establish (so it doesn't imply broader coverage than it has): only
the HTTP API (
agent-write-md) was exercised — the Electron/WYSIWYG save path and the MCPwrite_documenttool were not tested, though they may share the underlying CRDT primitive perPR #5. No version between the four executed tags (v0.46.2, v0.48.4, v0.48.10,
v0.49.0-beta.24) was independently executed — those gaps rest on the code comparison
described above, not a fresh run. And, per the question above: which of the three candidate
shapes (serialized transacts against one document, per-request snapshot round-trip, or
whole-value last-writer-wins) actually produces this on OK's server is something we could not
determine from outside — our Yjs demonstration rules out the CRDT merge as the mechanism, but it
doesn't tell us which server-layer shape replaces it.
Suggested direction (entirely yours to take or leave)
Ranked by how confident we are that it's worth doing, not by how much code it is — options, not a
demand; the maintainers know this codebase and may see a better one:
200when a stale/concurrent replace is detected. Detectionand honest reporting, without requiring the API to actually solve the merge. This is correct
under all three candidate shapes above — whichever one it turns out to be, telling the losing
caller it succeeded is wrong — and it's the smallest thing to accept. Prior art: RFC 9110
§13.1.1's
412 Precondition Failed; the GitHub Contents API's409on a stale blobsha.This was our fallback suggestion in an earlier pass at this report; on reflection it's the
headline, not the fallback.
as a server-issued version token rather than a content hash — hashing serialized content
produces false conflicts on whitespace/line-ending/serialization drift that isn't a real
conflict. Prior art: RFC 9110
ETag/If-Match→412; the GitHub Contents API requires theblob
shaon update and returns409 "<path> does not match <sha>"; Google Docs'writeControl.requiredRevisionId. The companion requirement is that the read path has toreturn the witness in the first place — we did not verify whether OK's read path currently
does, and that's the main reason we're not calling this "the lighter lift" the way we might
have assumed.
considered this until researching for this report, but it's the direction other comparable
agent-facing products already converge on: Notion's markdown API documents
update_content(
old_str/new_str) as "the recommended approach for making precise edits without rewritingthe full page", ahead of
replace_content; Yjs's own idiom is diff-then-applyDelta, notdelete-all+insert-all; Liveblocks' REST write takes a Yjs update, not a document string. This
removes the problem rather than reporting it — an agent doing read-modify-write against a
whole document is the pattern worth designing away, not just handling more gracefully.
writeControl.targetRevisionIdtakes this approach — applying the write as a delta against current state instead of rejecting
it, on the premise that "the API client can be thought of as another collaborator."
One option we considered and are not suggesting: an explicit checkout/lock on a document for
the duration of a replace. Noting it (and why we dropped it) rather than silently leaving it out —
an agent's read-modify-write can hold a lock for minutes of inference, not milliseconds, and a
recent paper on multi-agent concurrency control (CoAgent, arXiv:2606.15376) measured two-phase
locking deadlocking 0.81 times per trial under agent-style workloads. On a single-user
local-first machine there's also no operator around to clear a lock stranded by a crashed
session.
We ran the Yjs demonstration above as a standalone test against
yjs@13.6.32directly — notagainst your server — so it's evidence about Yjs's semantics only, not about your implementation.
Happy to share the script (a few dozen lines) alongside the reproduction script offered below, if
it's useful for checking our reasoning.
Thank you for Open Knowledge — this is being reported because we'd like to keep building on it,
not as a drive-by. Happy to provide more detail, the reproduction script, the Yjs demonstration
script, or re-run against a specific build if useful.
Steps to reproduce
Install
@inkeep/open-knowledgeand start it:npm install @inkeep/open-knowledge@<version>(non-global install works fine), then
ok start(orok initfirst if the project isn'tinitialized yet). We used
--host 127.0.0.1 --port <port>.Create a document, e.g.
concurrency-test-replace.md, with any starting content.Fire two
POST /api/agent-write-mdrequests at that document withposition: "replace"anddistinct sentinel strings, launched in the same shell tick as backgrounded subshells so
neither reads the other's write first and the requests genuinely overlap (see the snippet
below — no
sleep, no ordering between them):Read the document back (
GET /api/agent-read-md, or the file on disk).Expected: both markers present in some resolvable form, or an error/conflict on one call.
Observed: only one of
AGENT-A-FULL-REPLACE-BODY/AGENT-B-FULL-REPLACE-BODYispresent; the other is not in the file anywhere, and neither HTTP response indicated anything
was at risk.
We have this as a standalone, runnable script (
repro-concurrent-replace.sh) that does theabove end-to-end — its own sandbox setup, install, server start, both experiments, assertion,
self-teardown — and ran it (not just the manual steps above) against v0.46.2, v0.48.4, v0.48.10,
and v0.49.0-beta.24, confirmed to reproduce every time, including runs against completely
independent throwaway sandboxes to rule out setup contamination. Happy to attach or paste it if
useful; keeping it out of the issue body itself since it's a few hundred lines.
Platform
macOS (Apple Silicon)
How did you install OpenKnowledge?
CLI (npm / npx)
Version
Executed and reproduced at all four: v0.46.2 (2026-08-04), v0.48.4 (2026-08-05), v0.48.10
(2026-08-06, current latest stable release), and v0.49.0-beta.24 (2026-08-06, current
latest published prerelease), each via
ok --versionagainst a real install. v0.48.10 isone commit past v0.48.9 —
3840a6ee fix(open-knowledge): stop ACP follow-file opening blank phantom tabs (#3280)— and that commit touches onlypackages/app/src/components/acp/follow-file.ts and its test; the packages/server diff
between v0.48.9 and v0.48.10 is empty.
We separately checked (code comparison) whether the write path itself changed anywhere in
this range: agent-sessions.ts, conflict-errors.ts, and the handleAgentWriteMd handler body
are byte-identical (SHA-256) at v0.48.4, v0.48.9, v0.48.10, v0.49.0-beta.18, and
v0.49.0-beta.24. None of the commits in this range touches concurrency/CRDT/replace
behaviour. Unlike our first pass at this on 2026-08-05 (where the beta was diffed but not
run), this time we also executed v0.49.0-beta.24 directly — the code prediction and the
executed run now agree at the same build, on both the stable and beta lines.
Prerelease-vs-default note, stated for transparency: v0.49.0-beta.24 is a published
prerelease, not what
npm install @inkeep/open-knowledgeresolves to by default (that'sstill v0.48.10 as of this report — npm's
latestdist-tag). We tested it explicitly byversion pin because it is the newest published build, not because it's what a typical
user runs.