Skip to content

[mache-cb8fb9] fix(graph): resolve callees from the container edge, not from a "source" child that leyline never writes - #616

Merged
jamestexas merged 3 commits into
mainfrom
fix/callees-leyline
Aug 7, 2026
Merged

[mache-cb8fb9] fix(graph): resolve callees from the container edge, not from a "source" child that leyline never writes#616
jamestexas merged 3 commits into
mainfrom
fix/callees-leyline

Conversation

@jamestexas

Copy link
Copy Markdown
Contributor

Summary

  • find_callees was empty for every construct on a leyline-backed .db: leyline's projections give a construct's children as parse-tree nodes (block, identifier, parameter_list), never a source leaf, so the existing lookup's sourceID came back empty and GetCallees returned nil before any fallback ran.
  • Adds calleeTokensFromContainer, tried before the source-child lookup when useNodesTable is set: reads callees straight from node_refs.container_node_id, the edge leyline already writes at parse time — nothing new is derived, just read from where the producer put it.
  • DISTINCT on the token query is load-bearing: a function calling the same helper twice is one callee, two call sites.

Test plan

  • examples/publicapi/browse_test.go extended to cover the container-edge path
  • task ci (full local gate, matches CI) green

…ot from a "source" child that leyline never writes

find_callees returned nothing for EVERY construct on a leyline
projection — mache's primary backend. Reproduced before fixing: a
two-function corpus where Caller calls Helper twice gave
GetCallees(Caller) -> 0.

The reported root cause was the missing `props` column, which does
disable the scoped-extractor path. But that is not where it ends: the
fallback then looks for a child node named "source" before consulting
node_refs, and a leyline construct's children are parse-tree nodes —
block, identifier, parameter_list, type_identifier. There is no "source"
leaf, so sourceID came back empty and GetCallees returned nil BEFORE
reaching the node_refs fallback that would have answered. The "source"
child is a mache-schema shape being assumed on a schema that does not
have it.

The edge was already there. leyline writes node_refs rows whose
container_node_id is the enclosing definition's node_id:

  token=Helper  container_node_id=a.go/function_declaration_1  (= Caller)

So a construct's callees are exactly the tokens its container owns. This
derives nothing the producer had not already computed at parse time — it
reads an edge mache was ignoring. Tried BEFORE the source-child lookup,
since on a leyline db that lookup can only fail.

DISTINCT is load-bearing: Helper is called twice, which is two call
SITES and one callee. GetCallees answers "what does this call", not
"how many times".

Verified end-to-end on a real leyline projection:
  GetCallees(Caller) -> 1 [Helper]     (was 0)
  GetCallees(Helper) -> 0              (correct — calls nothing)

The regression lives in examples/publicapi, the consumer-shaped ladder,
and asserts BOTH directions agree: what Run calls must name Run among
its callers. Empty was the dangerous failure mode here — an agent cannot
tell "this function calls nothing" from "this backend cannot answer",
so it draws a wrong conclusion instead of asking a different question.

Falsified rather than assumed: stubbing the container lookup fails the
test.

task check: exit 0, 42 packages.
…its root, instead of only in `mache init`

The ?project= resolution path shipped, but nothing populated the thing
it resolves against. registerProject had exactly one caller — `mache
init` — so a daemon could serve a project for days with
~/.mache/projects.json absent entirely, and every token lookup
necessarily missed.

Found live rather than by reading: a supervised `mache serve --http`
had been serving this repo for days with no registry file at all, no
?project= token in the client URL, and a client that could not answer
roots/list. get_overview simply hung — the session never resolved a
workspace, so there was no graph to query.

Registration is now a byproduct of serving. Any session that learns a
root — from client roots, from --path, from a stdio positional arg —
records it, so the token exists for the NEXT client. That ordering is
the whole point: the clients that most need token resolution (plain
request/response HTTP, no channel for roots/list) are exactly the ones
that can never populate the registry themselves.

`mache init` keeps its distinct job — writing the ?project= token into
the client's config, which a server cannot do for it.

Deliberately non-fatal and quiet: a read-only HOME or corrupt registry
must not take down a session that is otherwise resolving fine. Serving
the graph is the job; registration is an optimization for later. The
existence check keeps the steady state read-only — this runs per tool
call on an unmapped session, and rewriting the file every time would be
constant churn for no gain.

The failure message now leads with `mache init` too. It previously said
"configure MCP roots or start mache with an explicit --path" — and for
the clients that actually reach that branch, roots is the one remedy
they cannot act on. Naming it first sent people to fix the thing they
could not fix.

task check: exit 0, 42 packages.
…sion resolution was losing 98% of them

A cold review of this PR found the feature silently defeating its own
purpose, and reproduced it. Verified independently before fixing: 50
goroutines registering 50 distinct roots left 1-3 in the file.

    registered 1/50 roots
    registered 2/50 roots
    registered 1/50 roots

The registry is a read-modify-write over one JSON file, and this now
runs on session resolution — which a shared HTTP daemon does
concurrently, one goroutine per request. Every caller wrote back a map
it had read BEFORE the others' inserts, so all but the last were
dropped. Nothing crashed and nothing was corrupted: writeFileAtomic
renames, so the file is never torn. It just lost almost everything,
which is worse — the token a later ?project= lookup needs was never
written, and the very burst this feature targets (many clients against a
shared daemon) is the case that loses the most.

Two locks, because there are two independent writers:

  - a package mutex, for goroutines inside one daemon;
  - an advisory flock, because `mache init`, a supervised
    `mache serve --http`, and any number of `mache serve --stdio`
    subprocesses are separate PROCESSES sharing ~/.mache/projects.json,
    and a mutex cannot see across them.

Neither suffices alone. The lock lives on a dedicated .lock file rather
than on the registry itself: writeFileAtomic replaces the registry by
rename, so a lock held on it would end up on an unlinked inode while a
new one took its place, protecting nothing.

ensureProjectRegistered's check-then-write is now ONE critical section.
Split, two callers both observe "absent" and both proceed. It inlines
the write rather than delegating to registerProject because
withRegistryLock is not reentrant — flock on a second descriptor from
the same process deadlocks against itself on Linux.

Also from the same review:

  - calleeTokensFromContainer swallowed ALL query errors as (nil, nil),
    the exact thing its sibling calleeTokensFromRefs' doc comment warns
    against. A transient DB fault would have surfaced to an agent as
    "this function calls nothing". Now probes for the column with
    ColumnExists — the convention cmd/smell_refs_views.go already
    established for this same leyline-vs-native divergence — and
    surfaces real failures.

  - The unwritable-registry test asserted only NotPanics and discarded
    the return value, so it would have passed had the write silently
    SUCCEEDED. It now pins both halves of the contract: returns false,
    and writes nothing.

The concurrency regression asserts an EXACT count. A lost-update bug
passes any weaker assertion, which is why there was no coverage before.

task check: exit 0, 42 packages.
@jamestexas
jamestexas force-pushed the fix/callees-leyline branch from f1f1a1a to bbb967a Compare August 7, 2026 17:46
@jamestexas
jamestexas merged commit 9f42e05 into main Aug 7, 2026
19 checks passed
@jamestexas
jamestexas deleted the fix/callees-leyline branch August 7, 2026 18:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant