@@ -1532,6 +1532,7 @@ struct cbm_mcp_server {
15321532 cbm_proc_log_cb index_log_callback ;
15331533 void * index_log_context ;
15341534 cbm_mcp_project_mutation_begin_fn mutation_begin ;
1535+ cbm_mcp_project_mutation_try_begin_fn mutation_try_begin ;
15351536 cbm_mcp_project_mutation_end_fn mutation_end ;
15361537 void * mutation_context ;
15371538 cbm_mcp_quarantine_test_hook_fn quarantine_test_hook ;
@@ -1682,14 +1683,27 @@ void cbm_mcp_server_set_project_mutation_guard(cbm_mcp_server_t *srv,
16821683 return ;
16831684 }
16841685 srv -> mutation_begin = begin ;
1686+ srv -> mutation_try_begin = NULL ;
16851687 srv -> mutation_end = end ;
16861688 srv -> mutation_context = begin ? context : NULL ;
16871689}
16881690
1691+ void cbm_mcp_server_set_project_mutation_try_guard (
1692+ cbm_mcp_server_t * srv , cbm_mcp_project_mutation_try_begin_fn try_begin ) {
1693+ if (srv && srv -> mutation_begin ) {
1694+ srv -> mutation_try_begin = try_begin ;
1695+ }
1696+ }
1697+
16891698static bool mcp_project_mutation_begin (cbm_mcp_server_t * srv , const char * project ) {
16901699 return !srv -> mutation_begin || srv -> mutation_begin (srv -> mutation_context , project );
16911700}
16921701
1702+ static bool mcp_project_mutation_try_begin (cbm_mcp_server_t * srv , const char * project ) {
1703+ return !srv -> mutation_begin ||
1704+ (srv -> mutation_try_begin && srv -> mutation_try_begin (srv -> mutation_context , project ));
1705+ }
1706+
16931707static void mcp_project_mutation_end (cbm_mcp_server_t * srv , const char * project ) {
16941708 if (srv -> mutation_end ) {
16951709 srv -> mutation_end (srv -> mutation_context , project );
@@ -2045,8 +2059,18 @@ static bool quarantine_corrupt_store(cbm_mcp_server_t *srv, const char *project,
20452059/* Open the right project's .db file for query tools.
20462060 * Caches the connection — reopens only when project changes.
20472061 * Tracks last-access time so the event loop can evict idle stores. */
2062+ typedef enum {
2063+ STORE_RECOVERY_NONE ,
2064+ STORE_RECOVERY_BUSY ,
2065+ STORE_RECOVERY_TRY_GUARD_UNAVAILABLE ,
2066+ } store_recovery_status_t ;
2067+
20482068static cbm_store_t * resolve_store_internal (cbm_mcp_server_t * srv , const char * project ,
2049- bool mutation_already_held ) {
2069+ bool mutation_already_held , bool nonblocking_recovery ,
2070+ store_recovery_status_t * recovery_status ) {
2071+ if (recovery_status ) {
2072+ * recovery_status = STORE_RECOVERY_NONE ;
2073+ }
20502074 if (!project ) {
20512075 return NULL ; /* project is required — no implicit fallback */
20522076 }
@@ -2076,9 +2100,16 @@ static cbm_store_t *resolve_store_internal(cbm_mcp_server_t *srv, const char *pr
20762100 srv -> store = NULL ;
20772101 bool mutation_acquired = mutation_already_held ;
20782102 if (!mutation_acquired ) {
2079- mutation_acquired = mcp_project_mutation_begin (srv , project );
2103+ mutation_acquired = nonblocking_recovery
2104+ ? mcp_project_mutation_try_begin (srv , project )
2105+ : mcp_project_mutation_begin (srv , project );
20802106 }
20812107 if (!mutation_acquired ) {
2108+ if (nonblocking_recovery && recovery_status ) {
2109+ * recovery_status = srv -> mutation_try_begin
2110+ ? STORE_RECOVERY_BUSY
2111+ : STORE_RECOVERY_TRY_GUARD_UNAVAILABLE ;
2112+ }
20822113 return NULL ;
20832114 }
20842115
@@ -2142,7 +2173,7 @@ static cbm_store_t *resolve_store_internal(cbm_mcp_server_t *srv, const char *pr
21422173}
21432174
21442175static cbm_store_t * resolve_store (cbm_mcp_server_t * srv , const char * project ) {
2145- return resolve_store_internal (srv , project , false);
2176+ return resolve_store_internal (srv , project , false, false, NULL );
21462177}
21472178
21482179/* Forward decl — definition lives below alongside list_projects. */
@@ -10486,6 +10517,28 @@ static char *adr_read_legacy_file(const char *root_path) {
1048610517 "then draft and store. Sections: PURPOSE, STACK, ARCHITECTURE, " \
1048710518 "PATTERNS, TRADEOFFS, PHILOSOPHY."
1048810519
10520+ /* resolve_store opens file-backed projects query-only. A mutation must release
10521+ * that reader before opening a dedicated writer because atomic publication uses
10522+ * a self-contained DELETE-mode database that switches back to WAL on write. */
10523+ static cbm_store_t * open_adr_store_for_write (cbm_mcp_server_t * srv , cbm_store_t * resolved ,
10524+ cbm_store_t * * owned_rw ) {
10525+ if (!srv || !resolved || !owned_rw ) {
10526+ return NULL ;
10527+ }
10528+ const char * resolved_db_path = cbm_store_db_path (resolved );
10529+ if (!resolved_db_path ) {
10530+ return resolved ;
10531+ }
10532+ char * rw_path = heap_strdup (resolved_db_path );
10533+ if (!rw_path ) {
10534+ return NULL ;
10535+ }
10536+ invalidate_cached_store (srv );
10537+ * owned_rw = cbm_store_open_path (rw_path );
10538+ free (rw_path );
10539+ return * owned_rw ;
10540+ }
10541+
1048910542static char * handle_manage_adr (cbm_mcp_server_t * srv , const char * args ) {
1049010543 char * project = get_project_arg (args );
1049110544 char * mode_str = cbm_mcp_get_string_arg (args , "mode" );
@@ -10517,8 +10570,10 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
1051710570 true);
1051810571 }
1051910572
10573+ bool write_request =
10574+ content && (strcmp (mode_str , "update" ) == 0 || strcmp (mode_str , "store" ) == 0 );
1052010575 bool mutation_held = false;
10521- if (project ) {
10576+ if (write_request && project ) {
1052210577 mutation_held = mcp_project_mutation_begin (srv , project );
1052310578 if (!mutation_held ) {
1052410579 free (project );
@@ -10539,11 +10594,21 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
1053910594 /* ADRs are stored in the SQLite store (project_summaries), the SAME
1054010595 * backend the UI /api/adr endpoints use — so writes via the MCP tool and
1054110596 * the UI are visible to each other (#256). */
10542- cbm_store_t * resolved = resolve_store_internal (srv , project , mutation_held );
10597+ store_recovery_status_t recovery_status = STORE_RECOVERY_NONE ;
10598+ cbm_store_t * resolved =
10599+ resolve_store_internal (srv , project , mutation_held , !write_request , & recovery_status );
1054310600 if (!resolved ) {
10544- char * err = build_no_store_error (project );
10545- char * res = cbm_mcp_text_result (err , true);
10546- free (err );
10601+ char * res = NULL ;
10602+ if (recovery_status == STORE_RECOVERY_BUSY ) {
10603+ res = cbm_mcp_text_result ("project is busy; retry after indexing" , true);
10604+ } else if (recovery_status == STORE_RECOVERY_TRY_GUARD_UNAVAILABLE ) {
10605+ res =
10606+ cbm_mcp_text_result ("project recovery requires a nonblocking mutation guard" , true);
10607+ } else {
10608+ char * err = build_no_store_error (project );
10609+ res = cbm_mcp_text_result (err , true);
10610+ free (err );
10611+ }
1054710612 if (mutation_held ) {
1054810613 mcp_project_mutation_end (srv , project );
1054910614 }
@@ -10553,66 +10618,57 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
1055310618 return res ;
1055410619 }
1055510620
10556- /* resolve_store opens file-backed projects READ-ONLY (query stores must
10557- * not mutate the DB). manage_adr is the only resolve_store caller that
10558- * WRITES, so it needs a writable handle. For a file-backed project open a
10559- * dedicated read-write handle to the same DB file (the project is verified
10560- * to exist via resolve_store, so cbm_store_open_path won't create a ghost
10561- * DB). For an in-memory / embedded store (db_path == NULL) the resolved
10562- * store is already writable — use it directly. */
1056310621 cbm_store_t * store = resolved ;
1056410622 cbm_store_t * owned_rw = NULL ;
10565- const char * resolved_db_path = cbm_store_db_path (resolved );
10566- if (resolved_db_path ) {
10567- /* Atomic publication leaves a self-contained DELETE-mode database.
10568- * Opening the writer switches it back to WAL, which requires an
10569- * exclusive lock and therefore fails while resolve_store's read-only
10570- * handle is still open. Copy the ACTUAL resolved path first (it may be
10571- * a drifted-filename fallback), then release that cached reader before
10572- * opening the writer. Request-scoped queries reopen it when needed. */
10573- char * rw_path = heap_strdup (resolved_db_path );
10574- if (!rw_path ) {
10575- if (mutation_held ) {
10576- mcp_project_mutation_end (srv , project );
10577- }
10578- free (project );
10579- free (mode_str );
10580- free (content );
10581- return cbm_mcp_text_result ("failed to allocate ADR store path" , true);
10582- }
10583- invalidate_cached_store (srv );
10584- owned_rw = cbm_store_open_path (rw_path );
10585- free (rw_path );
10586- if (!owned_rw ) {
10587- char * err = build_no_store_error (project );
10588- char * res = cbm_mcp_text_result (err , true);
10589- free (err );
10623+ if (write_request ) {
10624+ store = open_adr_store_for_write (srv , resolved , & owned_rw );
10625+ if (!store ) {
1059010626 if (mutation_held ) {
1059110627 mcp_project_mutation_end (srv , project );
1059210628 }
1059310629 free (project );
1059410630 free (mode_str );
1059510631 free (content );
10596- return res ;
10632+ return cbm_mcp_text_result ( "failed to open writable ADR store" , true) ;
1059710633 }
10598- store = owned_rw ;
1059910634 }
1060010635
1060110636 /* One-time migration: older versions wrote ADRs to a file at
10602- * <root>/.codebase-memory/adr.md. If the store has no ADR yet but that
10603- * legacy file exists, import it so nothing is lost on upgrade. */
10637+ * <root>/.codebase-memory/adr.md. A read never waits for the project lease:
10638+ * it returns the legacy content immediately and attempts migration only if
10639+ * a nonblocking acquire succeeds. */
1060410640 cbm_adr_t adr ;
1060510641 memset (& adr , 0 , sizeof (adr ));
1060610642 bool have_adr = (cbm_store_adr_get (store , project , & adr ) == CBM_STORE_OK );
10607- if (!have_adr ) {
10643+ char * legacy = NULL ;
10644+ if (!have_adr && !write_request ) {
1060810645 char * root_path = project_root_from_store (store , project );
10609- char * legacy = adr_read_legacy_file (root_path );
10646+ legacy = adr_read_legacy_file (root_path );
1061010647 free (root_path );
10611- if (legacy ) {
10612- if (cbm_store_adr_store (store , project , legacy ) == CBM_STORE_OK ) {
10613- have_adr = (cbm_store_adr_get (store , project , & adr ) == CBM_STORE_OK );
10648+ if (legacy && mcp_project_mutation_try_begin (srv , project )) {
10649+ if (!mcp_request_cancelled (srv )) {
10650+ /* A publisher may have completed before the lease was granted.
10651+ * File-backed stores must reopen after acquisition and trust
10652+ * only that generation. Embedded stores have no publication
10653+ * boundary, so retain their live handle. */
10654+ if (cbm_store_db_path (resolved )) {
10655+ invalidate_cached_store (srv );
10656+ resolved = NULL ;
10657+ store = NULL ;
10658+ resolved = resolve_store_internal (srv , project , true, false, NULL );
10659+ }
10660+ if (resolved ) {
10661+ store = open_adr_store_for_write (srv , resolved , & owned_rw );
10662+ if (store ) {
10663+ have_adr = (cbm_store_adr_get (store , project , & adr ) == CBM_STORE_OK );
10664+ if (!have_adr &&
10665+ cbm_store_adr_store (store , project , legacy ) == CBM_STORE_OK ) {
10666+ have_adr = (cbm_store_adr_get (store , project , & adr ) == CBM_STORE_OK );
10667+ }
10668+ }
10669+ }
1061410670 }
10615- free ( legacy );
10671+ mcp_project_mutation_end ( srv , project );
1061610672 }
1061710673 }
1061810674
@@ -10621,7 +10677,8 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
1062110677 yyjson_mut_doc_set_root (doc , root_obj );
1062210678
1062310679 bool is_error = false;
10624- if ((strcmp (mode_str , "update" ) == 0 || strcmp (mode_str , "store" ) == 0 ) && content ) {
10680+ const char * adr_content = have_adr ? adr .content : legacy ;
10681+ if (write_request ) {
1062510682 if (cbm_store_adr_store (store , project , content ) == CBM_STORE_OK ) {
1062610683 yyjson_mut_obj_add_str (doc , root_obj , "status" , "updated" );
1062710684 yyjson_mut_obj_add_str (doc , root_obj , "semantics" , "whole_document_replaced" );
@@ -10630,10 +10687,10 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
1063010687 is_error = true;
1063110688 }
1063210689 } else if (strcmp (mode_str , "sections" ) == 0 ) {
10633- adr_list_sections_from_content (doc , root_obj , have_adr ? adr . content : NULL );
10690+ adr_list_sections_from_content (doc , root_obj , adr_content );
1063410691 } else { /* get */
10635- if (have_adr && adr . content ) {
10636- yyjson_mut_obj_add_strcpy (doc , root_obj , "content" , adr . content );
10692+ if (adr_content ) {
10693+ yyjson_mut_obj_add_strcpy (doc , root_obj , "content" , adr_content );
1063710694 } else {
1063810695 yyjson_mut_obj_add_str (doc , root_obj , "content" , "" );
1063910696 yyjson_mut_obj_add_str (doc , root_obj , "status" , "no_adr" );
@@ -10652,6 +10709,7 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
1065210709 if (mutation_held ) {
1065310710 mcp_project_mutation_end (srv , project );
1065410711 }
10712+ free (legacy );
1065510713 free (project );
1065610714 free (mode_str );
1065710715 free (content );
0 commit comments