fix(config): route the last direct config writers through the advisory lock - #8095
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Right shape for a lost-update bug — per-site conversion under the existing primitive, failure semantics preserved, with a ratchet so the gap can't regrow. Suggestions
[DESIGN-REVIEWED] 0d30e91 |
5bfb9de to
96cc332
Compare
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All claims verified: First-Principles-Verdict: CONCERNS The ratchet's vacuity self-test exercises a hand-copied matcher, not the real one — detector drift passes its own "not vacuous" check. What this change shipsIntent: FIX — make the last eleven direct config.json writers take the advisory sidecar lock so concurrent writes stop silently losing each other.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 0d30e91 |
96cc332 to
963ec35
Compare
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
963ec35 to
192ccc9
Compare
Legitimate, and a regression this PR introduced rather than a pre-existing one. Before the conversion these two steps reached their section through Both sites now abort. A genuinely absent section is still created, so the ordinary path is unchanged. One thing the finding did not name, and it matters: fixing only the callback would have shipped a dead guard. Red-before proven: with |
Legitimate, and now fixed by deleting the import rather than annotating it, which is strictly better than what was asked. First pass ( The pre-existing lazy |
Legitimate, and squarely my error: the commit message claimed Now passes |
192ccc9 to
677f5d1
Compare
Taken as proposed. I checked one thing before accepting, because it would have been a reason to decline: Equivalence verified rather than assumed. Gates re-run after the change: |
…y lock
update_config_locked holds an advisory lock on a <path>.lock sidecar for its
whole read-modify-write, and its own docstring calls it "the required path for
new config.json mutations". A set of writers that pre-date it still called
write_config_atomically directly and relied on the in-process asyncio
_get_config_lock() instead. That lock is a LoopBoundLock: it serializes callers
on the same event loop in the same process and nothing else -- not a holder of
the sidecar, not a worker thread, not another process. So a locked
read-modify-write and one of these writers could interleave, and whichever
renamed second published a document that never saw the other's change. The loss
was silent and the lost data was user configuration.
Converts every remaining direct write_config_atomically(config_path()) caller,
keeping each site's existing failure semantics. All eleven were fail-closed
already (each bails rather than resetting on an unreadable config), so every one
takes the default on_corrupt="fail" and no site gains a reset path:
dashboard/handlers/agents.py
_commit_agent_config_locked -- already inside the shielded offload, so a
synchronous update_config_locked; ConfigReadError still escapes as the
unit's first step, keeping the handler's 500 exact.
api_default_agent -- was a loop-side read+write under the asyncio lock, which
cannot cover a writer in another process. Routed through
chat_utils.run_config_write, the one async entry point that holds BOTH
locks: it takes the loop-side lock, dispatches the synchronous
read-modify-write to a worker thread so an unbounded advisory-flock wait
never stalls the gateway, and shields that worker in a drain loop so the
lock cannot be released with a write still in flight. Composing those three
inline would have been a third hand-built copy of an existing helper.
dashboard/handlers/security.py
_mutate_agent_config -- the overlay-owned check moves inside the mutate
callback so it stays in the same hold as the write. ConfigReadError is
translated to this module's ConfigCorruptError, so callers keep answering
a coded 409.
apps/manager.py
_drop_trust_grant, _restore_trust_grant -- the CLI runs both in its own
process, which is exactly the writer an asyncio lock cannot reach. Both
re-derive their decision from the locked read: the revoke's no-grant fast
path is preserved (mutate returns None, no write) so a concurrent revoke is
a no-op rather than a redundant rewrite, and the restore appends to
apps_trusted only when the locked document does not already hold the name,
so a dashboard re-grant landing before the acquire cannot be duplicated
into the persisted consent list. That guarded shape already existed for
apps_trusted_local; the base list was the outlier.
cli_setup.py (whatsapp, slash command, sandbox consent, timezone,
dashboard URL) and cli_chat.py (_ensure_default_agent_in_config)
The wizard has the widest read-to-write window in the tree: it reads to
compute a prompt default, blocks on the operator, then writes. The pre-prompt
read stays -- it decides whether the step runs and produces the existing
messages -- but it is no longer the read the write is derived from. Section
shape guards are re-checked inside the lock. The sandbox consent step's
audit-then-write ordering is unchanged: the SEL event stays ahead of the
acquire.
A mutate callback ABORTS on a non-dict section and creates one only when it is
genuinely absent. Replacing it would destroy an operator value the step does not
own while reporting success -- the same silent-config-loss shape this change
exists to remove, so a locking fix must not introduce it. The slash-command
step's PRE-LOCK read is guarded on the same rule, because that read runs first:
`.get("slack", {}).get(...)` raised AttributeError on a scalar and took the
wizard down with a traceback, which also made the write-path guard unreachable.
It now refuses the step the way the whatsapp and sandbox steps already do.
agents.py:750 is deliberately NOT converted: it writes the kiro-cli agent spec,
not config.json. stamp_meta=False at every converted site, because none of these
writers stamped meta before and this change is about the lock, not the document
shape.
Three entries on the issue's list were false: apps/manager.py's
config_local_path() use is a read-only precondition check, and handlers/
telemetry.py never writes config at all (git log -S finds no such write in its
history).
Also corrects the docstring paragraph that named the legacy writers. It is
replaced rather than deleted, because a SECOND family still bypasses the lock and
the ratchet below does not reach it -- for two DIFFERENT reasons. Writers that
reach config_path() through kiro_crew.agent._atomic_json_write (messaging.py's
per-channel savers, core.py's STT PUT, mcp.py's gateway-enable) make no
write_config_atomically call at all, so the matcher never sees them. Writers
going through KiroCrewConfig.save() (updates.py's log-level PUT, core.py's theme
PUT, several agents.py CRUD endpoints) DO call it directly, but from inside
loader.py, which the ratchet exempts. The opening claim is scoped to match:
"every DIRECT write_config_atomically(config_path()) caller outside this module"
is the exact set the ratchet checks, and is deliberately not the same as "every
writer that reaches config.json". Deleting the paragraph outright would have made
the docstring wrong in the other direction. Converting that family is follow-up
work.
Tests
- test/test_config_writers_advisory_lock.py, new. Drives a converted writer
against a locked writer in the interleave that used to lose data and asserts
BOTH changes survive, plus a canary key neither writer owns so a whole-document
clobber is named. Red-before proven by restoring the pre-conversion shape at
each site: the apps-manager revoke (2 tests red), the slash-command step (red),
the timezone step (red), the foreign-section clobber (2 tests red) and the
missing stamp_meta=False (red). The two wizard interleave cases need no threads
-- the competing locked write is driven from inside the prompt, i.e. strictly
after the step's read and strictly before its write, which is the worst case
with no timing to be flaky about. Further tests pin that fail-closed survived
the conversion, that a foreign section is refused in BOTH directions (absent is
still created), and that the default-agent seed stamps no meta and skips the
write entirely when agents already exist.
- TestEveryConfigWriterIsLocked in test/test_config_rmw_preserves_settings.py,
the ratchet the issue asks for. Walks the AST per function, tracking names
bound to config_path()/config_local_path(), so it sees a write through a local
variable and not just an inline call. config/loader.py is exempt (it holds the
primitive and KiroCrewConfig.save). Carries a self-test asserting the matcher
actually flags both spellings and does NOT flag a caller-supplied path, so a
broken matcher cannot pass as an empty offender list.
- Four existing tests took their seam on the writer this change replaces and move
to update_config_locked. Every pinned property is unchanged; two are
strengthened -- test_default_agent_write_holds_the_config_lock now also asserts
the <config>.lock sidecar was taken, which is the guarantee the asyncio lock it
already checked cannot give.
Closes #8032
677f5d1 to
0d30e91
Compare
Legitimate, and an inconsistency inside this PR's own change rather than an edge case. The pre-lock It matters because Now appends only when the locked document does not already hold the name, mirroring the Red-before proven: |
Legitimate, and the second instance of the same imprecision — worth saying plainly rather than patching a third time. The previous round corrected the paragraph that lists the still-unlocked family, but left the opening sentence overclaiming, so the docstring contradicted itself two paragraphs apart: Narrowed exactly as asked, and the scope is now stated once and used consistently:
Plus an explicit note that "direct caller outside this module" is the precise set the ratchet checks and is deliberately not the same as "every writer that reaches No code change; documentation only, so no test accompanies it. |
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
Problem / Motivation
update_config_lockedtakes an advisory lock on a<path>.locksidecar for its whole read-modify-write, and its own docstring calls it "the required path for newconfig.jsonmutations". Eleven writers that pre-date it still calledwrite_config_atomicallydirectly and relied on the in-process asyncio_get_config_lock()instead.That lock is a
LoopBoundLock. It serializes callers on the same event loop in the same process and nothing else — not a holder of the sidecar (all ~69update_config_lockedcall sites), not a worker thread, not another process. So a locked read-modify-write and one of these writers could interleave, and whichever renamed second published a document that never saw the other's change.Concretely:
kirocrew config set model opusand akirocrew setuptimezone step running at the same time, or the dashboard settings PATCH and an app uninstall's trust withdrawal. One of the two changes just is not there afterwards. Nothing errors, nothing logs, and the endpoint reports success.Why it matters
Two things are true and neither is visible from a single call site.
Every current
update_config_lockedcaller believes it has mutual exclusion. Against these eleven it did not — a partially adopted lock reads as safety without providing it, and the data it fails to protect is the user's whole configuration file, including inline channel credentials.And
_get_config_lock()could not be adopted from the other side to close the gap: it is an asyncio lock, so a synchronous writer, a worker-thread writer, or the CLI in a separate process cannot acquire it at all. Any fix that bridges the two primitives ends up inventing a lock-ordering discipline for one call site. Conversion is the only shape that scales.What changed (motivation → approach → change)
Every remaining direct
write_config_atomically(config_path())caller now routes throughupdate_config_locked, keeping each site's existing failure semantics.All eleven were already fail-closed — each one bails rather than resetting when the config is unreadable — so every conversion takes the default
on_corrupt="fail"and no site gains a reset path.stamp_meta=Falseat every converted site, because none of these writers stampedmetabefore and this change is about the lock, not the document shape.agents.py_commit_agent_config_lockedupdate_config_locked;ConfigReadErrorstill escapes as the unit's first step, so the handler's 500 stays exactagents.pyapi_default_agent_offload_config_write— the advisory acquire can WAIT, and an unbounded flock wait on the loop would stall the gatewaysecurity.py_mutate_agent_config_get_config_lock+run_in_executorConfigReadErroris translated to this module'sConfigCorruptErrorso callers keep answering a coded 409apps/manager.py_drop_trust_grant,_restore_trust_grantupdate_config_locked. The no-grant fast path is preserved (mutate returnsNone, no write) and re-derived under the lock, so a concurrent revoke is a no-op rather than a redundant rewritecli_setup.py×5 (whatsapp, slash command, sandbox consent, timezone, dashboard URL) andcli_chat.py_ensure_default_agent_in_configThe wizard has the widest read-to-write window in the tree: it reads to compute a prompt default, blocks on the operator, then writes. The pre-prompt read stays — it decides whether the step runs and it produces the existing operator-facing messages — but it is no longer the read the write is derived from. Section shape guards are re-checked inside the lock. The sandbox-consent step's audit-then-write ordering is unchanged: the SEL event stays ahead of the acquire, so a failure between the two still leaves a record without a grant and never a grant without a record.
A mutate callback aborts on a foreign section; it never replaces one
The first revision of this PR got this wrong, and the GPT review lane caught it. A callback that assigns a fresh
{}over a non-dict section destroys an operator value the step does not own and reports success — the same silent-config-loss shape this PR exists to remove, reintroduced by the fix for it. Both the slash-command and dashboard-URL sites now abort; a section that is genuinely absent is still created, which is the ordinary path.Fixing only the callback would have shipped a dead guard.
_setup_slash_command's pre-lock read doescfg.get("slack", {}).get("command", ...), which raisedAttributeErroron a scalar section and took the whole wizard down with a traceback before the write path was ever reached. So the read is guarded on the same rule, and that pre-existing crash becomes the clean refusal the whatsapp and sandbox steps already give.agents.py:750is deliberately not converted: it writes the kiro-cli agent spec, notconfig.json.Three entries on the issue's list were false, and were verified against
origin/mainrather than taken on trust.apps/manager.py'sconfig_local_path()use is a read-only precondition check.handlers/telemetry.pynever writes config at all —git log -Sfinds no such write anywhere in its history.The docstring is corrected, not deleted
The issue asks for the "legacy writers" paragraph to be removed. It is replaced instead, because deleting it would make the docstring wrong in the other direction: a second family of writers still bypasses this lock, and the ratchet below does not reach it — for two different reasons that are worth stating precisely, since the paragraph exists to mark exactly that boundary.
config_path()throughkiro_crew.agent._atomic_json_write(messaging.py's per-channel savers,core.py's STT PUT,mcp.py's gateway-enable) make nowrite_config_atomicallycall at all, so the matcher never sees them.KiroCrewConfig.save()(updates.py's log-level PUT,core.py's theme PUT, severalagents.pyCRUD endpoints) do callwrite_config_atomicallydirectly — but from insideloader.py, which the ratchet exempts, so the write is invisible at every caller.That is ~16 further sites and is why this PR stops here: each needs its own failure-semantics judgement, and folding them in would triple the review surface. The docstring now names them so the next reader does not read the ratchet's green as covering them.
Tests
test/test_config_writers_advisory_lock.py(new). Drives a converted writer against a locked writer in the exact interleave that used to lose data, and asserts both changes survive — plus a canary key neither writer owns, so a whole-document clobber names what it destroyed. "Holds a lock" is not observable; "did not lose the other writer's setting" is.Red-before proven by restoring the pre-conversion shape at each site:
apps/manager.pytrust revokecli_setup.pyslash-command stepcli_setup.pytimezone stepcli_chat.pymissingstamp_meta=FalseThe two interleave cases on the wizard need no threads: the competing locked write is driven from inside the prompt, i.e. strictly after the step's read and strictly before its write. That is the worst case of the window with no timing to be flaky about. The apps-manager case uses the repo's established
threading.Eventpair with a bounded_TIMEOUT, so a regression fails on an assertion instead of hanging the suite. Further tests pin that fail-closed survived the conversion (an unreadable config is refused, never replaced), that a foreign section is refused in both directions (absent is still created), and that the default-agent seed stamps nometaand skips the write entirely when agents already exist.TestEveryConfigWriterIsLockedintest/test_config_rmw_preserves_settings.py— the ratchet the issue asks for, so the list cannot regrow. Walks the AST per function, tracking names bound toconfig_path()/config_local_path(), so it catches a write through a local variable (path = config_path()…write_config_atomically(path, data)) and not only an inline call. Per-function scoping matters both ways: file-wide bindings would report false positives, and would also let a genuine offender hide behind an unrelated function's rebinding of the same name.config/loader.pyis exempt — it holds the primitive andKiroCrewConfig.save.It carries a self-test: a scan asserting an empty offender list is indistinguishable from a scan whose matcher is broken, so the detector is exercised on both spellings a regression would take, and on a caller-supplied path (the
agents.py:750shape) which must not be flagged.Four existing tests took their seam on the writer this change replaces and move to
update_config_locked. Every pinned property is unchanged; two are strengthened —test_default_agent_write_holds_the_config_locknow also asserts the<config>.locksidecar was taken, which is precisely the guarantee the asyncio lock it already checked cannot give.Manual verification
N/A — the defect is a lost update between two writers, which is only observable deterministically under an injected interleave. The new tests are that interleave; a manual reproduction would be a race and would prove less.
Screenshots / video
Why no screenshot: backend and test files only — the diff touches no frontend path and renders no pixel.
Related Issues
Surfaced by #7793 / #7937.
Pattern harvest
Rule candidate:
lintPattern: a shared locking primitive adopted by most but not all writers of a resource — the guarantee is only as strong as the participating set, and a partially adopted lock reads as safety at every call site while providing none. Shipped as
TestEveryConfigWriterIsLocked, which is the generalizable half: the defect is not "these eleven sites forgot a lock" but "nothing made forgetting it visible". A second, narrower pattern showed up during review and is worth naming: converting a bare read-modify-write into amutatecallback silently changes what happens to a value the callback does not own, so an in-placesetdefaultthat used to crash or refuse can become an overwrite that reports success. The residual is named honestly in the primitive's docstring — the same class still exists for writers reaching the file through_atomic_json_write/KiroCrewConfig.save, and an aliased import ofwrite_config_atomicallywould also evade the AST matcher.Checklist
update_config_locked's docstringCloses #8032