Symbol leases and a structural merge: AI agents in parallel on one file (Lonca series, part 2)

In part 1 I wrote about the gate: one writer, and a pure decide(). At the end I said the demo had done something without my saying much about it: two agents worked on the same file at the same time, and both changes went in. This post is about how. It covers symbol leases, the claim an agent makes about its change, and the structural merge that joins changes function by function instead of line by line.

Every output below comes from Lonca 0.2.3 on my machine. I ran a small scenario like the demo’s: one Rust file with a type and two methods, one more file that calls one of them, and two agents, claude and codex. I did the edits with a script, so the agents here are only names. What they ask for and what Lonca answers is real.

Why lines are the wrong unit

Git merges text. It looks at the lines each side changed, and if the changes do not touch, it joins them. That is a good rule for people, who talk to each other before they work in the same file. With several agents it fails in two directions.

  • Unrelated work conflicts. Agents like to add a new method at the end of a class. Two of them doing that, each for its own task, change the same lines, and Git stops with a conflict. Nothing was wrong; the two functions only stood next to each other.
  • Related work merges silently. Two agents that change the same function at lines far apart get no conflict at all. Git joins them, and nobody ever read the result as one function.

And in both cases the problem shows up at the end, when the work is already done. An agent that learns about a conflict after an hour of work has wasted the hour, and somebody has to clean up.

So Lonca moves the unit from the line to the symbol, and it moves the moment of the conflict from the end to the start.

Diagram: src/billing.rs as symbols. claude holds an edit lease on Client.charge and an insert lease reserving fee; codex holds an insert lease reserving limit. Git merges the same two edits by lines and reports a conflict; Lonca merges them by symbol and both land. Below, the path of one change: lease, propose with a claim that must equal the facts, merge onto trunk symbol by symbol, verify the merged tree, then move trunk.
Leases decide who works where before the work starts. The merge joins what they allowed.

What a symbol is

Lonca parses source files with tree-sitter and gives every function, method, type and field a name. A name is the path and the symbol: src/billing.rs#Client.charge. This is what the scenario’s file looks like to it:

Terminal
$ lonca symbols src/billing.rs
Client  (definition: Client.<def>)
impl:Client  (definition: Client.<def>)
  Client.charge
  Client.refund

Client.charge and Client.refund are the methods. Client.<def> is the type’s own text: the struct and its fields, and the impl header. Client is the whole type, all of it together.

This works the same way in the 13 languages I listed in part 0. Markdown, TOML, JSON and YAML files are split into sections, so README.md#Install.Linux is a symbol too. Any other file is one symbol, path#<file>.

Leases: ask first, never wait

Before an agent changes a symbol, it leases it. A lease says who is working on what, why, and until when. Here claude takes the method it will change, and an insert lease on the type that reserves the name of a method it will add:

Terminal
$ lonca lease acquire 'src/billing.rs#Client.charge' -i 'reject non-positive amounts'
lease set lse_16f896b7578579a9
#3 allow

$ lonca lease acquire 'src/billing.rs#Client' --kind insert --name fee -i 'add a fee helper'
lease set lse_cec57cc69dc37d8b
#4 allow

Then codex asks for the same method, and for the same new name. Both are refused. Then it asks for a different new name, and for the function in the other file, and gets them:

Terminal
$ lonca lease acquire 'src/billing.rs#Client.charge' -i 'log every charge'
#5 deny: lease_held:src/billing.rs#Client.charge:ses_3400efba826b9ed4:reject non-positive amounts
claude (session ses_3400efba826b9ed4) is on src/billing.rs#Client.charge: "reject non-positive amounts". Do not edit these. Settle it between you: tell that agent what you need and why with negotiate, to_session as above. Or lease_interest puts you next. Go on with other work meanwhile.

$ lonca lease acquire 'src/billing.rs#Client' --kind insert --name fee -i 'fee too'
#6 deny: lease_held:src/billing.rs#Client:ses_3400efba826b9ed4:add a fee helper

$ lonca lease acquire 'src/billing.rs#Client' --kind insert --name limit -i 'add a spending limit check'
lease set lse_15d2b6876d2ca981
#7 allow

$ lonca lease acquire 'src/checkout.rs#checkout' -i 'checkout keeps a receipt'
lease set lse_feef915cc32fef5b
#8 allow

The refusal is written for an agent. It says who holds the symbol, what they are doing, and what to do now: negotiate, get in line, or do something else. That costs the second agent one tool call, not an hour of work.

A few rules make this work with agents in particular:

  • A lease never waits. An acquire is all or nothing: if one symbol in the request is held, nothing is given. Nobody holds something while waiting for something else, so there is no deadlock to find. An agent that wants to be next says so with an interest, and when the symbols are free the oldest interest gets them reserved for a minute.
  • Kinds. edit is for one symbol’s body, and only one session can hold it. insert adds members to a type or a file; several sessions can hold it at once, as long as the names they reserve differ. rename covers renames and deletes.
  • Nesting counts. A lease on Client and one on Client.charge conflict, because the first contains the second. A lease on Client.<def> does not conflict with one on a method, so changing a field does not stop everyone who works on the type.
  • Leases end. A lease has a lifetime (10 minutes by default, 30 at most). lonca mcp, the MCP server the agent talks to, renews it with heartbeats while the agent runs. When the agent’s process dies, the heartbeats stop, and five minutes later the symbols are free again.
  • A proposal pins them. Once a change is proposed, the leases it needs stay with it until it leaves the queue, even past their lifetime. Otherwise a change waiting for a human could lose its symbols to the next agent, which would then do work certain to conflict.

Every acquire, and every refusal, is an entry in the log, decided by the same decide() from part 1. The time a lease ends is computed from the time in the entry, not from a clock, so a replay a year later still sees who held what.

The claim and the facts

Now codex adds its method. This is its diff:

Terminal
$ git diff
@@ -14,4 +14,8 @@ impl Client {
     pub fn refund(&mut self, amount: i64) {
         self.balance += amount;
     }
+
+    pub fn limit(&self, amount: i64) -> bool {
+        amount <= self.balance / 2
+    }
 }

When an agent proposes a change, it has to say what the change does to the code: which symbols it edited, added and deleted, which signatures changed, which files went away. That is the claim. Here codex claims something wrong, that it edited refund:

Terminal
$ lonca propose -i 'spending limit' --edits 'src/billing.rs#Client.refund'
#9 deny: claim_mismatch:edits, claim_mismatch:adds
change ryrtynnpxsootnrkyoryuyruxuyvqkzk

The daemon does not take the claim’s word for it. It computes what really changed from the Git objects, which Lonca calls the facts, and compares the two field by field. lonca diff shows the facts before you propose. Here and below I left out the fields that are empty or do not matter:

Terminal
$ lonca diff
{
  "edits": [],
  "adds": [
    "src/billing.rs#Client.limit"
  ],
  "modified_files": [
    "src/billing.rs"
  ],
  "signature_changes": [],
  "lines_added": 4,
  "lines_removed": 0
}

$ lonca propose -i 'spending limit' --adds 'src/billing.rs#Client.limit'
#10 escalate: unobserved_session, approvals=1
change oozqpzvtwnottymxtzrokrzmvtpwsxww

With the right claim the proposal goes through. It is escalated to a human, but for a reason that has nothing to do with this post: no harness watches these sessions (unobserved_session), and that is part 3’s subject. claude proposes its change the same way: an edit of Client.charge and a new Client.fee, added at the same place as codex’s method.

Why make an agent claim anything, if the daemon computes the facts anyway? Because the difference between the two is the signal. An agent whose picture of its own change is wrong has made a mistake somewhere: it touched a function it did not mean to, or it believes it changed one it did not. That is worth stopping, and it can be stopped without understanding the code. The comparison is exact and needs no AI.

The free text, the -i intent, is for people. It is never an input of the decision. The facts are, and every symbol in them must be leased by the session that proposes; otherwise the answer is lease_missing with the symbol named.

The structural merge

A human approves both proposals, and the daemon’s worker merges them onto trunk one after the other. This is the file on trunk afterwards:

Terminal
$ git -C ~/.lonca/repos/demo.git show main:src/billing.rs
pub struct Client {
    pub balance: i64,
}

impl Client {
    pub fn charge(&mut self, amount: i64) -> bool {
        if amount <= 0 || amount > self.balance {
            return false;
        }
        self.balance -= amount;
        true
    }

    pub fn refund(&mut self, amount: i64) {
        self.balance += amount;
    }

    pub fn fee(&self, amount: i64) -> i64 {
        amount / 100
    }

    pub fn limit(&self, amount: i64) -> bool {
        amount <= self.balance / 2
    }
}

And this is plain Git with the same two edits on two branches:

Terminal
$ git merge codex
Auto-merging src/billing.rs
CONFLICT (content): Merge conflict in src/billing.rs
Automatic merge failed; fix conflicts and then commit the result.

$ git diff
@@@ -15,7 -15,7 +15,12 @@@ impl Client
          self.balance += amount;
      }

++<<<<<<< HEAD
 +    pub fn fee(&self, amount: i64) -> i64 {
 +        amount / 100
++=======
+     pub fn limit(&self, amount: i64) -> bool {
+         amount <= self.balance / 2
++>>>>>>> codex
      }
  }

Git is not wrong here. It sees two sides that wrote different lines in the same place, and it can not know that they are two separate functions. Lonca knows, because it merges symbols:

  • It takes the incoming change’s symbol-level changes against the common base (edited, added and deleted symbols, added and removed imports) and applies them to trunk’s text.
  • Changes to different symbols never conflict, however close their lines are. A change to the same symbol from both sides is a conflict, and the change becomes conflicted with the symbol named.
  • Two additions at the same place stand in the order of the queue: the one integrated last stands nearest the neighbour they share. That is why fee, which went in second, is above limit. The order is a rule, so the auditor gets the same file.
  • Code outside symbols, like statements at the top of a file, falls back to a line merge. So do moves and reorders, because order can matter and a merge in place can not express it.
  • The result must parse and must still contain every symbol either side added. If it does not, the merge does not trust itself: it falls back to the line merge, or reports the file as a conflict.

The merge is Lonca’s own code. The obvious existing tool, Mergiraf, is GPL, and that does not fit an Apache-2.0 project. Writing my own also gave me something I needed: the merge has to be deterministic and recomputable, because the auditor computes every merge in the log again and compares it with the commit that was recorded.

That brings a cost I did not see coming. Every rule of the merge, and of symbol extraction, is part of the log’s meaning. If I change how a symbol is named or how two additions are ordered, an old entry would recompute to something else. So the extraction and the merge have their own version, the symbol engine, recorded in every proposal and integration entry. 0.2.3 has engine 19. The code of every older engine stays in the crate, frozen, and the auditor uses the version the entry names. A test pins each engine’s output for every language, and a failing snapshot is never edited: it means the change needs a new engine.

Leases are not enough: the merge queue

Two changes to different symbols merge cleanly, but they can still be wrong together. Agent A changes what f returns. Agent B, at the same time, writes g, which calls f and expects the old behaviour. Their leases never met, and the merge is clean. The code is broken.

No lease can see this, so Lonca does not pretend to. The only way into trunk is the merge queue, and the queue checks the merged result, not each change alone:

  1. It merges the change onto the trunk of that moment, symbol by symbol.
  2. It runs the repository’s own build and test commands on the merged tree, in a sandbox with no network.
  3. It moves trunk only if they pass. Otherwise the change is rejected with verification_failed and the check named, and the evidence (exit code, duration, the end of the log, the toolchain) goes into the log.

This catches the A and B case if the tests cover it, and only then. With many agents, the queue can verify several changes as one batch; when a batch fails, it tries smaller parts of it until the change that broke it is left alone.

When a signature changes

One case sits between the two: a change that alters a function’s signature can break callers it never touches. Here claude, after syncing, gives charge a new parameter. src/checkout.rs calls charge, and codex holds a lease on that function:

Terminal
$ lonca diff
{
  "edits": [
    "src/billing.rs#Client.charge"
  ],
  "signature_changes": [
    "src/billing.rs#Client.charge"
  ],
  "lines_added": 1,
  "lines_removed": 1,
  "affected": [
    "src/checkout.rs#checkout"
  ]
}

$ lonca propose -i 'charge takes a currency' --edits 'src/billing.rs#Client.charge' --signature-changes 'src/billing.rs#Client.charge'
#17 escalate: unobserved_session, signature_change:src/billing.rs#Client.charge, affected_lease:ses_39c8cb048dc81ae5:lse_feef915cc32fef5b, approvals=1

The facts list what the change may affect, and the decision names the leases that hold any of it. codex hears about it at the proposal, not after the merge, while somebody can still answer:

Terminal
$ lonca events
{"kind":"approved","seq":12,"target_seq":10}
{"kind":"integrated","seq":13,"change":"oozqpzvtwnottymxtzrokrzmvtpwsxww"}
{"kind":"symbols_changed","seq":17,"change":"puqtqzzxuotpmqtrszwsukznosouokrz","from":"agent:claude/ses_3400efba826b9ed4","sets":["lse_feef915cc32fef5b"],"symbols":["src/billing.rs#Client.charge"]}
next 18

affected is a guess, and it says so. It is every symbol that mentions the changed name, found with tree-sitter and nothing more. There is no compiler or language server behind it, so a call through a variable is missed and an unrelated function with the same name is reported. It errs towards one notice too many. The real check is the build, and a project can set a slower extra check that runs only for changes like this one.

I should say what would happen if I approved this proposal. The scenario’s repository has no build command set, so the queue has nothing to run, and code that does not compile would reach trunk. That is not a hidden bug, it is the setup: Lonca does not know your language’s compiler, it runs yours. In a real repository, set the build and test commands and make verification required.

The scenario ends with the auditor, which replays every decision and recomputes both merges:

Terminal
$ lonca-audit verify ~/.lonca/repos/demo.git
operations: 18
head:       59f1504d9b96aac5473a751b8b7c6ddd25f32947c0a462f4fd86b8216a24f6ff
ok

What it costs

  • The grammar is older than your language. Languages get new syntax every year, and a grammar learns it later. A definition with syntax the grammar can not read is not leased by itself: changing it needs the lease on the whole file, the file merges by lines, and a person has to agree once (unread_file). Valid code is never refused for it, and everything the grammar did read keeps working as before.
  • Languages outside the list are leased by file. Two agents can not work on one file of such a language at the same time. A language gets added when somebody asks for it.
  • One function, one agent. Two agents in one long method take turns. There is a shared lease that joins their edits by lines, but it is off unless the repository verifies every merge, because two edits that do not share a line can still break each other.
  • More steps for the agent. It has to lease before it edits and describe its change when it proposes. The MCP tools tell it which leases a change needs and offer the facts as the claim, but it is still a few more tool calls, and the rules text the agent reads is longer.
  • No real caller analysis. Signature changes are found by name, as above. Precise analysis with a compiler or a language server is a later phase.
  • Moves and reorders are line merges. A method moved to another place in the file merges like text and can still conflict with changes nearby.
  • Every merge rule is forever. Twenty engine versions, 0 to 19, live in the code, frozen, so that old logs replay. Fixing a gap in a grammar or in the merge always means a new version, never an edit.

Next in the series

Every proposal in this post was escalated with unobserved_session, because no harness was watching the agents. Part 3 is about that: what Lonca sees of an agent’s tool calls, what makes a session tainted when it reads outside content, and why that is the structural answer to prompt injection.

Every post of the series is under the Lonca category. The code is on GitHub, the documentation is on Lonca’s site. Questions and ideas go to Discussions, bugs to issues.

#AIAgents #Rust #VersionControl #OpenSource #DevTools

Yorum yapın