fix(update): add --if-version CAS guard, close the lost-update gap on both stores - #65
Merged
Merged
Conversation
… both stores `knowledge update --content` was an unconditional whole-content overwrite on the local JSON store: LocalItemStore.update ignored expectedVersion entirely (it did not even accept the parameter) and local items carried no version field at all, so a write computed from a stale read succeeded at rc=0 and silently destroyed an intervening write from a second agent. The cloud/postgres store already enforced a server-side version check, but the CLI always re-read the item's version immediately before writing, which only guards the instant inside one command invocation — it cannot catch a decision an agent made from an earlier, separate `get`, because by write time the freshly re-read version already reflects any intervening edit. Fixes both gaps: - LocalItemStore now tracks a lock-protected version counter (distinct from version HISTORY, which it still does not keep; supportsVersions stays false) and rejects a write via KnowledgeVersionConflictError when expectedVersion is supplied and stale. - `knowledge update` gains an optional `--if-version <n>` flag: pass the version a prior `get` returned, and the write is rejected unless the store is still at exactly that version. Omit it and behaviour is unchanged. - A version conflict is now a distinct exit code (2) from the CLI's generic error exit (1), with `code`/`expected`/`current` in --json output, so a caller can detect the guard firing without parsing prose. Regression test: tests/knowledge-update-cas.test.ts reproduces the exact lost update (two stale reads at the same version, two writes, second destroys first) at both the ItemStore layer and the CLI layer, confirmed failing against the unfixed store before this fix and passing after. Agent: agent-chief-planning
CI (#65, run 30784802813) caught this correctly: `bun run verify:generated` rebuilds through the package's own `build` script and diffs the result against the committed bin/dist, and the prior commit here edited src/store.ts, src/item-store.ts and src/cli.ts without regenerating them. Regenerated with `bun run build` (bun 1.3.14, matching the version pinned in .github/workflows/ci.yml) via `bun run verify:generated`. Affects exactly the files touched by the CAS fix's source changes: - dist/store.d.ts, dist/item-store.d.ts — the version/CAS doc comments and the new KnowledgeVersionConflictError re-export - dist/index.js, bin/knowledge.js, bin/knowledge-mcp.js — the bundles that embed item-store.ts/cli.ts `bun run verify:generated` now exits 0 against this commit. Agent: agent-chief-planning
…nus's suite Peer review (silvanus/agent-chief-harness) on PR #65 found one vacuous assertion in tests/knowledge-update-cas.test.ts: the non-integer-flag test asserted `stderr.toContain('--if-version')`, which also passes on a build where --if-version does not exist at all, because argument parsing's "Unknown flag: --if-version. Run 'knowledge --help' for valid options." echoes the flag name back. Added `expect(result.stderr).not.toContain('Unknown flag')`, the same discriminator silvanus used in their own suite. Proved it discriminates: reverted src/cli.ts, src/item-store.ts, src/store.ts to the pre-fix parent (eed4035) in a scratch worktree (never on this branch), kept the test file, and re-ran. - before the assertion fix: 2 pass / 7 fail against pre-fix code - after the assertion fix: 1 pass / 8 fail against pre-fix code (this test now correctly joins the failing set) - the fixed file still passes 9/9 against this branch's actual (post-fix) implementation Audited the rest of the file for the same shape (stderr matched against the flag's own name, or text a generic unknown-flag/usage error could also produce): none found. The other stderr assertions check for 'version_conflict', 'version 1', and 'now at version 2', none of which a generic "Unknown flag" error can produce. Grafted tests/entry-versioning-client.test.ts from origin/fix/5d45a037-if-version (silvanus is closing PR #66 in this PR's favour). Their suite's design refused --if-version on the local JSON store; this branch's fix implements the guard for real on local (todos 97d26f1b was exactly the local-store gap), so their refusal test contradicted this branch's contract and was rewritten — not deleted — to assert it: a stale --if-version is refused (exit 2, both versions named) and a matching one writes and bumps the counter. Every other test in their file passed against this implementation unchanged. Full grafted file against this branch: 20 pass / 0 fail / 85 expect() calls. The new --if-version describe block alone: 5 pass / 0 fail / 29 expect() calls (their own report against their implementation: 5 pass / 0 fail / 26 expect() calls — same five tests, one rewritten). Full suite: 407 pass / 2 skip / 11 fail, the same pre-existing/environmental failure set established before this change (cli.test.ts subprocess timeouts, buildServer/MCP registration, project-panel — none touching item-store.ts, cli.ts's update path, or either changed test file). No src/ changes in this commit; `bun run verify:generated` still exits 0. Agent: agent-chief-planning
andrei-hasna
marked this pull request as ready for review
August 3, 2026 05:37
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes a measured P1 data-loss defect (todos
97d26f1b):knowledge update --contentwas an unconditional whole-content overwrite, and a write computed from a stale read could silently destroy an intervening write from another agent.LocalItemStore.updateignoredexpectedVersion(didn't even accept the parameter), and local items carried noversionfield, so any concurrency guard was a no-op on the on-box JSON store.updatealready re-reads the item and sendsexpectedVersion: current.version, but that re-read happens fresh at write time, so it can never catch a decision an agent made from an earlier, separateget— by write time the freshly re-read version already reflects any intervening edit, so the guard trivially "passes" against itself.Fix
LocalItemStorenow tracks a lock-protected version counter (bumped on every successful write, check-and-write inside one file-lock acquisition). This is distinct from version history —supportsVersionsstaysfalse, no prior bodies are retained; that's still Postgres-only.knowledge updategains an optional--if-version <n>flag. Pass the version a priorgetreturned; the write is rejected (nothing written) if the store has moved to a different version since. Omitting it preserves the exact previous behaviour on both backends.KnowledgeVersionConflictError, already used by the cloud/Postgres path) is now surfaced as a distinct exit code (2), separate from the CLI's generic error exit (1), withcode/expected/currentfields in--jsonoutput.Regression test
tests/knowledge-update-cas.test.tsreproduces the exact repro from the bug report (two reads at the same version, two writes, second clobbers first) at both theItemStorelayer (fast, in-process) and the CLI layer (real subprocess, real local JSON file), plus:versionfield yet) is read as version 1, not silently unguardable--if-versionstill works)--if-versionrejected before touching the store)--storeplus a fully-selected postgres mode pointed at a guard-refused non-loopback host still resolves local, and the default store location is never createdConfirmed failing (7/9) against the unfixed store before the fix, passing (9/9) after.
Test plan
created.versionundefined, noKnowledgeVersionConflictErrorthrown, clobber not prevented)bun test) with ambientHASNA_KNOWLEDGE_*vars unset: 400 pass / 2 skip / 13 fail — all 13 failures reproduce on a pristine pre-fix baseline run under the same conditions (11 baseline fails, same test names; the 2 extra arecli.test.ts/smoke-script timeouts under full-suite CPU contention, confirmed passing when run in isolation)tsc --noEmitcleanentry-versioning*.test.ts,cloud-store.test.ts,knowledge-mode.test.ts,serve.test.ts) all green (99/99) run in isolationItemStoredirectly withstorePathOverridden: true, or spawns the CLI with a fresh tempHOME+ explicit--store, stripping ambient*KNOWLEDGE*envNotes for reviewers
--if-versionis added toupdateonly, per the task.archive/restore/untag/upsertkeep their existing auto-derivedexpectedVersion(unchanged behaviour, still real protection for the single-invocation race).Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.