With #2134, an unnamed index_repository of a root that another indexed
project already owns adopts that owner's name, and the daemon keys the
job by it (application_index_project_key reads the forwarded "name").
The unnamed status poll that the #2144 async reply asks for ("the same
repo_path and status: true") still derived its key from the path alone
in index_status_project_key. For an owned root it never found the job
it had just started and answered "no index job or index is known for
project '<path-derived>'"; an ambiguous root got that same misleading
answer instead of the loud error the start returns.
index_status_project_key now runs the start path's
index_root_owner_resolve on the same canonical repo_path: an explicit
name still wins, a single other owner is the key, several owners fail
with the same error naming them, otherwise the path-derived key stays.
The status error buffer grows to hold that owner list.
The daemon's other readers of the key (job dedupe in
application_job_subscribe_locked, the synchronous cut-short marker set
and taken around application_index_run_sync) derive it from the
forwarded args, which already carry the owner name, so they agree with
the start; status was the only reader that diverged.
Refs #2134, #2144
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
tool_index_repository_reuses_existing_project_for_root_issue2134 counts
how often the canonical repo root occurs in list_projects. On Windows,
cbm_canonical_path returns "C:\Users\...", while the stored root_path is
"C:/Users/...", so the count was 0 and the test failed on the CLANG64
leg (test_mcp.c:8448: owners == 0, expected 1).
Normalize the expected root with cbm_normalize_path_sep, a no-op on
POSIX, before counting.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Linux, MSan, TSan and diag CI failed in mcp_mutation_guard:
tool_cross_repo_missing_inputs_fail_without_creating_ghost_databases
ASSERT(source_failed). handle_index_repository resolved the root owner
before dispatching cross-repo-intelligence mode. That test registers its
named target with the source's own root, so the unindexed source adopted
existing-cross-target as its project (log: index.root_owner_reused
project=existing-cross-target), and the missing source was no longer
reported. Cross-repo mode reads an existing source index and never writes
one, so the #2134 reuse now runs only on the re-index path, after that
dispatch. macOS CI stayed green because cbm_tmpdir() is /tmp, a symlink
to /private/tmp there, so the canonical repo_path never equalled the
fixture's stored root; with the fixture path canonicalized, the failure
reproduced 3/3 on macOS and passes 3/3 with this change.
The #2134 test itself called realpath() into a 1 KiB buffer. glibc's
FORTIFY check aborts on that ("buffer overflow detected", a SIGABRT of the
mcp suite on the ubuntu 2/3 shards), and MinGW has no realpath at all
(Windows compile error). It now uses cbm_canonical_path with a 4 KiB
buffer, the same canonicalizer production uses.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The memory-core ratchet flagged src/mcp/mcp.c growing by 10 raw allocator
sites (784 -> 794) from index_root_owner_resolve and its caller. The owner
list buffer, which this code allocates and frees itself, now uses
cbm_realloc/cbm_free (class other). Strings handed over by other APIs
(cbm_project_name_from_path, heap_strdup, the index argument strings) are
released through safe_free, as elsewhere in the file, so no memory the core
never counted reaches cbm_free. Behaviour is unchanged; mcp.c is back at its
baseline of 784.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
index_repository without `name` derived the project key from the path
alone. When the root had first been indexed under an explicit name, a
later unnamed re-index forked a second index (same root_path,
path-derived name) and left the original untouched, so the stale index
kept being served while the call reported success.
Resolve the key from the indexes on disk before any work starts:
- the path-derived project exists -> keep it (unchanged behavior);
- exactly one other project owns the canonical root -> adopt its name
and update it in place, so existing indexes are never orphaned;
- several projects own the root -> fail loudly, name them and ask for
`name` instead of guessing or forking again;
- none -> first index under the derived name, as before.
The resolved name is written into the args handed to the daemon job
coordinator and the supervised worker, so the project lease key and the
index actually written stay the same project.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Why: the activation rendezvous is per OS account, so an install or update
run under another HOME / CBM_CACHE_DIR (a sandbox, a second profile) meets
the account's live daemon at the same endpoint. The daemon's
activation-shutdown handler verifies only the requester's build, never its
cache, so the CLI's scope decision is the only thing that tells "my daemon"
from "someone else's". That decision drained the cohort unless the active
cache was positively DIFFERENT: a busy session group (another activation or
a session start held it, so the active identity was never read) and an
unreadable active cache both fell through to "ours", printed "Stopping
active CBM sessions" and shut the daemon and every session behind it down.
Fix (user decision 2026-09-28, "unknown = foreign, keep it"): only a
positive match may drain. cli_activation_resolve_scope returns
CLI_ACTIVATION_SCOPE_ACTIVE only for an OK admission (exact identity, cache
included, or an empty group) or for a conflict whose cache fingerprint
equals this activation's. BUSY and a blank active cache now return the new
CLI_ACTIVATION_SCOPE_UNCONFIRMED, handled like a foreign cohort: no lock is
taken, no drain request is sent, the mutation proceeds, and the audit log
records "quiesce_skipped: active cohort ownership unconfirmed (...)". A
read-only status probe names the daemon that was left (pid, version) and
the notice says how to stop it: close its sessions, then run
`codebase-memory-mcp daemon stop`. The confirmed paths are unchanged: a
normal self-upgrade still drains its own daemon, a clearly foreign cache is
still left alone.
Proof (product binaries; private CBM_RUNTIME_DIR; an own permanent test
daemon started from the same build under a host HOME/cache; a sandbox
HOME/cache on the same runtime dir; the cohort maintenance gate held by a
second process, i.e. a busy session group):
- origin/main: "Stopping active CBM sessions and operations for
install...", the test daemon was stopped (daemon status: not running),
and the install still failed (rc=1 after 23 s, "could not be stopped
safely").
- this change: "Leaving the running CBM daemon untouched (pid 76713,
version dev): this install could not confirm that it serves the cache
this install targets (the session group was busy, so its cache identity
could not be read)." The test daemon kept running with the same pid and
the install completed (rc=0, 10 s).
A lifetime record whose cache field is zeroed is refused (UNSAFE) by both
builds with the daemon kept; that path never drained and is unchanged.
Tests (cli suite; fixture daemon in a private runtime dir; sandbox HOME):
- cli_install_foreign_home_unreadable_cohort_cache_keeps_host_daemon
(the new seam cbm_cli_set_activation_scope_cache_unreadable_for_test
blanks the observed active cache fingerprint)
- cli_install_foreign_home_busy_cohort_keeps_host_daemon (the maintenance
gate is held through the product's own lock-directory handle)
Both were RED 3x on origin/main for the right reason (the host daemon was
drained: ASSERT(host_serving)), are green with the fix, and turn RED again
when the fix is reverted. Controls stay green:
cli_install_into_host_namespace_still_drains_host_cohort (confirmed same
cache: drained, as before) and
cli_install_{skip_binary,binary}_into_foreign_home_never_drains_host_cohort
(clearly different cache: kept, as before).
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The parallel-scheduler contract failed twice on test-windows CLANG64 while
running fake suites only:
- PR #2394 (run 36364479687): "Windows timeout race refused without a
surviving descendant to refuse over". The refusal came 38s after the
forced leader exit (a cold powershell/CIM descendant probe, once in the
wave loop and again in the cleanup pass), while the fixture's descendant
was a `time.sleep(30)` that had already exited on its own.
- PR #2345 (run 36199749518): "suite 'hang_after_summary' taskkill could
not prove process-tree cleanup". The scheduler bounded taskkill.exe with
--kill-grace, which the contract passes as 1s, so a slow cold start of
the tool was reported as a failed cleanup.
Both verdicts were a race between a clock and runner latency. An audit of
the fixture found more of the same shape:
- Every hanging fixture ended on its own after 30s. A starved scheduler
could therefore see a "hung" leader exit 0, and a descendant the
scheduler failed to kill could vanish before the leak check looked.
- The 1s --timeout could kill a slow leader before it had printed its
summary (asserted pass=1), spawned its descendant or written the
descendant pid. That pid was then read back with int() from a file
written create-then-write, so a reader could see no file or an empty one.
The empty-file read recorded earlier was the scheduler's ready file,
which 3b9052c9 already publishes atomically; the fixture's own writer
had the same shape.
- The descendant installed its SIGTERM-ignore during its own start-up. A
SIGTERM that won that race skipped the SIGKILL escalation the POSIX leg
exists to cover, silently and with the verdict unchanged.
- On Windows, the stubborn-tree leak check was a single look right after
taskkill, but TerminateProcess returns before its target has exited.
Fix it by construction, not by budget:
- A gate. Every process that must outlive the scheduler's decision blocks
reading stdin. That is a pipe whose only write end the contract holds,
handed down through the scheduler, and the read returns only at EOF. The
contract closes the pipe once the verdict is recorded. If the contract
dies, EOF releases everything, so nothing is orphaned and no fixture
ends on a clock.
- The three hanging scenarios run under the scheduler's existing
pre-terminate barrier. They are released only once the fixture has
published `<suite>.established`, which happens after the summary is
flushed, the descendant has confirmed it is armed, and its pid is known.
The marker is written to a temp file and moved into place with
os.replace, so a reader sees either no file or the whole file.
- The Windows leak check waits on the descendant's process handle. That
wait is exact, not a race: the descendant is held by the gate, so the
scheduler's kill is the only way it can end.
- run-test-wave.py runs taskkill through windows_taskkill_tree() with its
own stable-state budget (WINDOWS_TASKKILL_SECONDS = 15), as 132e8fc3 did
for the descendant probe. --kill-grace keeps bounding what its name
says. Real runs already pass --kill-grace 15, so they are unchanged.
The contract pins this structurally, the same way it pins the probe:
exactly one taskkill site, no kill-grace there, and a helper bounded by
the constant that fails closed on a timeout, a start failure or a
non-zero exit.
Every assertion keeps its contract:
- A leader that hangs after a green summary is recorded as rc=124 pass=1.
- A zero-test child is recorded as rc=97.
- The wave continues after a bounded failure.
- A SIGTERM-resistant tree is killed in full.
- A leader that exits between the timeout decision and termination is
still cleaned up on POSIX. On Windows the scheduler refuses (rc=2,
naming the cleanup failure) over a descendant that is really alive.
A new self-check fails loudly if a held leader or descendant is not live
while the barrier holds, that is, if the fixture no longer exercises what
those assertions claim.
Proof, on macOS arm64 and a Linux arm64 container. The Windows branch was
reasoned through; the VM is down.
- Torture hook (proof only) that delays each hanging leader 2s, past the
1s timeout. The old fixture is RED 3/3 on every scenario (pass=0,
FileNotFoundError on descendant.pid, "lost its bounded result"). The new
one is green, including at 8s, and 10/10 under the hook plus CPU load.
- The #2394 mechanism: after an injected 31s refusal latency the old
descendant is gone and the new one is live. It exits 0.02s after the
gate closes.
- The taskkill pin is RED on the old scheduler and green on the new one.
- Stress, 20 runs each under CPU load. The old full contract passed 20/20
on macOS and 20/20 on Linux, so these hosts do not reproduce the CI
timing. The rebuilt held scenarios passed 20/20 on macOS and 20/20 on
Linux. The full contract after the change passes a single run on macOS
and on Linux, and 20/20 on each leg under CPU load (4 load workers;
the Linux container is capped at 4 CPUs).
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>