Skip to content

Commit d1d8bbf

Browse files
authored
Merge pull request #1833 from LeoLan-Digital/fix/permanent-daemon-watch
fix: persist daemon watches across client disconnects
2 parents 70f6de8 + 2609e56 commit d1d8bbf

4 files changed

Lines changed: 455 additions & 30 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ codebase-memory-mcp config set auto_index true
255255

256256
`auto_index` is off by default. When enabled, new projects are indexed automatically on first connection. The configurable file limit is `config set auto_index_limit 50000`. Previously indexed projects can be watched through `auto_watch` (on by default), subject to its global setting and session ownership.
257257

258-
Each initialized, long-lived MCP session can register its root project when it connects and any target from a successful `index_repository` call, including custom project names and roots in another worktree. While `auto_watch` is enabled, these watches belong to the registering sessions and remain while at least one owning session is connected; closing the last owner removes the watch. An async index registers its target only after success and while its requesting session remains connected. CLI indexing and failed MCP indexes do not register watches. Global `auto_watch` controls registration and removes all watches on the next refresh or registration request when set to `false`. `index_status` reports this in its `watch` object: `watched`, and either the poll cadence and last scan time or the `reason` it is not watched.
258+
Each initialized, long-lived MCP session can register its root project when it connects and any target from a successful `index_repository` call, including custom project names and roots in another worktree. While `auto_watch` is enabled, these watches belong to the registering sessions and remain while at least one owning session is connected; closing the last owner removes the watch. Under a daemon started with `daemon start` a watch outlives its last owning session: the project stays watched and is re-indexed in the background when it changes, until that daemon stops, the project is deleted, its directory disappears, or `auto_watch` is set to `false`. An async index registers its target only after success and while its requesting session remains connected. CLI indexing and failed MCP indexes do not register watches. Global `auto_watch` controls registration and removes all watches on the next refresh or registration request when set to `false`. `index_status` reports this in its `watch` object: `watched`, and either the poll cadence and last scan time or the `reason` it is not watched.
259259

260260
Watcher registration is controlled globally by `auto_watch` (default `true`). On the next refresh or registration request, `true` allows MCP sessions to register their root on connect and their successful `index_repository` targets; `false` removes all watches. CLI indexing and failed MCP indexes do not add watches.
261261

@@ -904,7 +904,7 @@ Tools requiring arguments accept JSON piped on stdin. Tools with only optional a
904904

905905
| Tool | Description |
906906
|------|-------------|
907-
| `index_repository` | Index a repository into the graph. A successful call from an active MCP session registers the target for auto-sync while that session remains connected, subject to the global `auto_watch` setting; CLI calls do not register watches. Waits for the whole index by default; pass `async: true` to start it in the daemon and return at once, then poll with `status: true` (see below). |
907+
| `index_repository` | Index a repository into the graph. A successful call from an active MCP session registers the target for auto-sync while that session remains connected (under `daemon start`, also after it disconnects), subject to the global `auto_watch` setting; CLI calls do not register watches. Waits for the whole index by default; pass `async: true` to start it in the daemon and return at once, then poll with `status: true` (see below). |
908908
| `list_projects` | List indexed projects with paging; use `detail: "stats"` for node/edge counts and database sizes. |
909909
| `delete_project` | Remove a project and all its graph data. |
910910
| `index_status` | Check indexing status of a project. |

‎docs/CONFIGURATION.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ Current keys:
8989
| `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. |
9090
| `auto_watch` | `true` | Global switch for MCP session watch registration. On the next refresh or registration request, `true` allows session roots and successful `index_repository` targets to be watched; `false` removes all watches. The background watcher can still run with no registered projects. |
9191
| `doc_candidates` | `auto` | Score documentation sections against code to store doc↔code candidates (the possible targets of a section, and the sections that may describe a function or file, each with a judged probability). Unset (`auto`), the index mode decides: on for `full` indexes (the default mode), off for `moderate` and `fast`. Set `true` or `false` to choose for every mode; `config reset doc_candidates` returns to `auto`. The scoring grows with sections × functions: under a second up to ~100k functions, ~15 s at ~400k, ~3 minutes on the Linux kernel. Off stores no candidates and leaves the rest of the graph unchanged; on in `fast` mode stores candidates without adding `fast`'s skipped `SIMILAR_TO` / `SEMANTICALLY_RELATED` edges. `index_repository` also takes `doc_candidates` for a single index, which wins over this setting. Applies from the next index. |
92-
| `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. Active MCP sessions can register their root project and successful explicit index targets; `index_status` reports the current state in its `watch` object. |
92+
| `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. Active MCP sessions can register their root project and successful explicit index targets; under `daemon start` those watches outlive the session until the daemon stops, the project is deleted, its directory disappears, or `auto_watch` is set to `false`. `index_status` reports the current state in its `watch` object. |
9393
| `watch_non_git` | `false` | Also poll project roots that are **not git repositories**. By default the watcher only follows git projects, so a project indexed from a plain directory is never refreshed after its first index — reindex it manually with `index_repository`. Set `true` to poll such roots on the same adaptive cadence with a file-tree scan: the indexer's own discovery walk (same skip lists, `.gitignore` and `.cbmignore` rules) hashed over each file's path, size and mtime. Any change reindexes once; paths the indexer skips (including cbm's own `.codebase-memory/` output) never trigger. The first poll after the daemon starts reindexes each such project once, since nothing records which tree state the index holds. The scan walks the whole tree every poll, so it costs more than git polling on very large trees. Read once when the daemon starts, like `watcher_enabled`. |
9494
| `index_max_files` | `off` | Optional maximum number of accepted source files in one discovery run. |
9595
| `index_max_source_mb` | `off` | Optional maximum accepted source size in MiB in one discovery run. |

‎src/daemon/application.c‎

Lines changed: 86 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -196,11 +196,11 @@ struct cbm_daemon_application_index_record {
196196
cbm_daemon_application_index_record_t *next;
197197
};
198198

199-
/* A watcher-triggered physical job is owned by the exact live sessions that
200-
* currently subscribe to its project/root watch. The callback waiting for the
201-
* job is only a storage waiter; it is deliberately not an ownership
202-
* subscription, so the worker is cancelled when the last matching session
203-
* disconnects even while unrelated daemon sessions remain alive. */
199+
/* A watcher-triggered physical job is normally owned by the exact live
200+
* sessions subscribed to its project/root watch. In permanent daemon mode the
201+
* watcher itself additionally owns the physical job so background indexing can
202+
* continue while no client session is connected. The callback remains a
203+
* storage waiter in both modes and releases daemon ownership on completion. */
204204
struct cbm_daemon_application_watch_job_subscription {
205205
cbm_daemon_application_session_t *session;
206206
cbm_daemon_application_job_t *job;
@@ -564,16 +564,34 @@ static bool application_watches_disabled_locked(cbm_daemon_application_t *applic
564564
return true;
565565
}
566566

567-
static void application_release_session_watch_locked(cbm_daemon_application_session_t *session) {
567+
static void application_release_session_watch_locked(cbm_daemon_application_session_t *session,
568+
bool session_ending) {
568569
application_watch_job_unsubscribe_session_locked(session, NULL);
570+
571+
/*
572+
* A permanent daemon owns the physical project watch independently of
573+
* individual client sessions. Session disconnect releases only that
574+
* session's logical subscription; the watcher remains available for
575+
* background change detection while the daemon generation is alive.
576+
*
577+
* Non-permanent applications retain the historical session-scoped
578+
* lifecycle and remove the physical watch after the final subscriber.
579+
* A release decided by workspace policy (session_ending false: the
580+
* session's root is no longer allowed) is not a disconnect and removes
581+
* the watch in both modes. Daemon shutdown and auto_watch=false remove
582+
* persistent watches through their own paths; a deleted project's watch
583+
* ends at its next watcher callback
584+
* (application_permanent_watch_orphaned_locked).
585+
*/
586+
bool daemon_keeps_watch = session_ending && session->application->permanent;
569587
while (session->watches) {
570588
application_watch_membership_t *membership = session->watches;
571589
cbm_daemon_application_watch_t *watch = membership->watch;
572590
session->watches = membership->next;
573591
cbm_free(CBM_MEM_CLASS_OTHER, membership);
574592
if (watch->subscribers > 0) {
575593
watch->subscribers--;
576-
if (watch->subscribers == 0) {
594+
if (!daemon_keeps_watch && watch->subscribers == 0) {
577595
application_remove_watch_locked(session->application, watch);
578596
}
579597
}
@@ -721,7 +739,7 @@ static void application_refresh_watch_locked(cbm_daemon_application_session_t *s
721739
return;
722740
}
723741
if (!application_session_workspace_allowed(session, "watch")) {
724-
application_release_session_watch_locked(session);
742+
application_release_session_watch_locked(session, false);
725743
return;
726744
}
727745
application_register_watch_locked(session, cbm_mcp_server_session_project(session->mcp),
@@ -806,7 +824,9 @@ static const char *application_watch_strategy_name(cbm_watcher_strategy_t strate
806824

807825
/* index_status watch visibility (#2167). The physical watcher is the truth for
808826
* "watched"; session roots and successful explicit targets stay watched while
809-
* at least one owning MCP session remains open. Takes the
827+
* at least one owning MCP session remains open (under `daemon start`, also
828+
* after the last one closes, until the daemon stops, the project is deleted,
829+
* its root disappears, or auto_watch is switched off). Takes the
810830
* application mutex and the watcher lock one after the other, never nested. */
811831
static void application_watch_status(void *context, const char *project,
812832
cbm_mcp_watch_status_t *out) {
@@ -3649,7 +3669,7 @@ static void application_session_cancel(void *context,
36493669
reap_update || (session->background_eligible && application->update_thread_started &&
36503670
application->update_owners == 0);
36513671
application_index_watch_unsubscribe_locked(session, NULL);
3652-
application_release_session_watch_locked(session);
3672+
application_release_session_watch_locked(session, true);
36533673
bool final_live_session = newly_cancelled;
36543674
for (cbm_daemon_application_session_t *other = application->sessions;
36553675
final_live_session && other; other = other->next) {
@@ -3709,7 +3729,7 @@ static void application_session_close(void *context,
37093729
reap_update || (session->background_eligible && application->update_thread_started &&
37103730
application->update_owners == 0);
37113731
application_index_watch_unsubscribe_locked(session, NULL);
3712-
application_release_session_watch_locked(session);
3732+
application_release_session_watch_locked(session, true);
37133733
cbm_mutex_unlock(&application->mutex);
37143734
application_auto_index_cancel_join(application, join_auto_index);
37153735
application_jobs_reap_completed(application);
@@ -4256,21 +4276,43 @@ static void application_watch_encode_index_args_locked(const cbm_daemon_applicat
42564276
*args = encoded ? yyjson_mut_write(document, 0, NULL) : NULL;
42574277
}
42584278

4279+
/* Under `daemon start` a watch outlives its last session, so delete_project
4280+
* from another session or the CLI can remove the project's DB while nothing
4281+
* owns the watch any more: daemon MCP sessions carry no watcher pointer and
4282+
* never unwatch it. Re-indexing such an orphan would resurrect the deleted
4283+
* project. A full reindex also unlinks the DB while it rebuilds it, so a
4284+
* missing DB means "deleted" only while no job for the project is running.
4285+
* Caller holds application->mutex. */
4286+
static bool application_permanent_watch_orphaned_locked(cbm_daemon_application_t *application,
4287+
const cbm_daemon_application_watch_t *watch,
4288+
const char *project_key) {
4289+
return application->permanent && watch->subscribers == 0 &&
4290+
!application_find_active_job_locked(application, project_key) &&
4291+
!application_regular_db_exists(watch->project);
4292+
}
4293+
42594294
static cbm_daemon_application_job_t *application_background_index_admit(
42604295
cbm_daemon_application_t *application, const char *project_name, const char *canonical_root,
42614296
const char *project_key, yyjson_mut_doc *document, yyjson_mut_val *root, char **args,
4262-
bool require_live_watch, int *no_job_result) {
4297+
bool require_live_watch, bool *watcher_owns_job, int *no_job_result) {
42634298
application_job_subscribe_status_t subscribe_status = APPLICATION_JOB_SUBSCRIBE_UNAVAILABLE;
4299+
*watcher_owns_job = false;
42644300
application_jobs_reap_completed(application);
42654301
cbm_mutex_lock(&application->mutex);
42664302
if (require_live_watch) {
42674303
(void)application_watches_disabled_locked(application);
42684304
}
42694305
cbm_daemon_application_watch_t *watch =
42704306
require_live_watch ? application_find_watch_locked(application, project_name) : NULL;
4271-
bool watch_live =
4272-
!require_live_watch || (watch && watch->subscribers > 0 &&
4273-
application_watch_root_matches(watch->root, canonical_root));
4307+
if (watch && application_permanent_watch_orphaned_locked(application, watch, project_key)) {
4308+
/* End the orphan the way a session refresh ends a deleted project's
4309+
* watch; the callback below becomes a harmless skip. */
4310+
application_remove_watch_locked(application, watch);
4311+
watch = NULL;
4312+
}
4313+
bool watch_live = !require_live_watch ||
4314+
(watch && application_watch_root_matches(watch->root, canonical_root) &&
4315+
(watch->subscribers > 0 || application->permanent));
42744316
if (require_live_watch && watch_live) {
42754317
application_watch_encode_index_args_locked(watch, document, root, args);
42764318
}
@@ -4284,16 +4326,28 @@ static cbm_daemon_application_job_t *application_background_index_admit(
42844326
if (job && require_live_watch) {
42854327
watch_subscriptions_ok = application_watch_job_subscribe_sessions_locked(
42864328
application, watch, job, &watch_owner_count);
4287-
if (watch_subscriptions_ok && watch_owner_count > 0) {
4288-
job->watcher_waiters++;
4289-
} else if (!watch_subscriptions_ok) {
4329+
4330+
if (!watch_subscriptions_ok) {
42904331
subscribe_status = APPLICATION_JOB_SUBSCRIBE_ALLOCATION_FAILED;
4291-
}
4292-
/* application_job_subscribe_locked() lends the caller one ordinary
4293-
* subscriber. A watcher callback is only a storage waiter: exact live
4294-
* session subscriptions above own the physical work. */
4295-
application_job_unsubscribe_locked(job);
4296-
if (!watch_subscriptions_ok || watch_owner_count == 0) {
4332+
application_job_unsubscribe_locked(job);
4333+
job = NULL;
4334+
} else if (application->permanent) {
4335+
/*
4336+
* The ordinary subscription acquired above is retained as daemon
4337+
* ownership. This allows the physical watcher to run indexing even
4338+
* while no client session currently owns the project watch.
4339+
*/
4340+
*watcher_owns_job = true;
4341+
job->watcher_waiters++;
4342+
} else if (watch_owner_count > 0) {
4343+
/*
4344+
* Historical non-permanent behavior: exact live sessions own the
4345+
* physical work; the watcher callback itself is only a waiter.
4346+
*/
4347+
job->watcher_waiters++;
4348+
application_job_unsubscribe_locked(job);
4349+
} else {
4350+
application_job_unsubscribe_locked(job);
42974351
job = NULL;
42984352
}
42994353
}
@@ -4318,7 +4372,7 @@ static cbm_daemon_application_job_t *application_background_index_admit(
43184372

43194373
static int application_background_index_wait(cbm_daemon_application_t *application,
43204374
cbm_daemon_application_job_t *job,
4321-
bool require_live_watch) {
4375+
bool require_live_watch, bool watcher_owns_job) {
43224376
bool successful = false;
43234377
bool cancelled = false;
43244378
for (;;) {
@@ -4331,6 +4385,9 @@ static int application_background_index_wait(cbm_daemon_application_t *applicati
43314385
if (job->watcher_waiters > 0) {
43324386
job->watcher_waiters--;
43334387
}
4388+
if (watcher_owns_job) {
4389+
application_job_unsubscribe_locked(job);
4390+
}
43344391
} else {
43354392
application_job_unsubscribe_locked(job);
43364393
}
@@ -4387,13 +4444,15 @@ static int application_background_index(cbm_daemon_application_t *application,
43874444
}
43884445

43894446
int no_job_result = APPLICATION_BACKGROUND_INDEX_FAILED;
4447+
bool watcher_owns_job = false;
43904448
cbm_daemon_application_job_t *job = application_background_index_admit(
43914449
application, project_name, canonical_root, project_key, document, root, &args,
4392-
require_live_watch, &no_job_result);
4450+
require_live_watch, &watcher_owns_job, &no_job_result);
43934451
free(project_key);
43944452
free(args);
43954453
yyjson_mut_doc_free(document);
4396-
return job ? application_background_index_wait(application, job, require_live_watch)
4454+
return job ? application_background_index_wait(application, job, require_live_watch,
4455+
watcher_owns_job)
43974456
: no_job_result;
43984457
}
43994458

0 commit comments

Comments
 (0)