Skip to content

feat(relayfile): symbol-level merge for concurrent Go edits (Phase 3)#373

Merged
khaliqgant merged 4 commits into
mainfrom
goal-b/phase-3-symbol-merge
Jul 25, 2026
Merged

feat(relayfile): symbol-level merge for concurrent Go edits (Phase 3)#373
khaliqgant merged 4 commits into
mainfrom
goal-b/phase-3-symbol-merge

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Jul 25, 2026

Copy link
Copy Markdown
Member

Summary

Phase 3 of the goal-B fix series. Builds on #370/#371/#372 (all merged). This is the piece that directly answers the original ask: "if two agents are working on backend they should be able to see [each other's changes] in real time across sandboxes" — specifically, two agents editing different functions in the same file should not have to serialize on a whole-file conflict.

Design was reconciled from two independent proposals (mine and Codex's) before any code was written — see docs/multi-agent-collaboration-assessment.md (Phase 3 section) for the reconciliation. Codex's version was adopted almost entirely: a client-supplied merge base verified against a server-side provenance ledger (not full content history), Go-only via go/parser for v1 (no cross-language heuristic), init() never auto-merged, same-function-different-lines still conflicts (function is the collaboration unit, not line ranges).

Explicitly out of scope for this PR (per an earlier decision): automatic mountsync routing of eligible files through this endpoint ("mount rollout") and non-Go languages are deferred follow-ups. This PR makes the primitive callable; it doesn't wire it into the mount's default write path yet.

What changed

Store layer (internal/relayfile/merge.go, merge_go.go):

  • Store.MergeFile: three-way merge (base/mine/theirs) scoped to top-level Go function declarations, matched by name (not byte position) across versions.
  • A new in-memory, bounded revision→content-hash provenance ledger (hooked into the same write path every other write already goes through) verifies a caller's claimed base actually matches history before trusting it for a merge — a caller can't fabricate a base to trick the algorithm.
  • Deliberately restrictive extraction: any non-func top-level declaration after the first function makes a file ineligible for v1 (falls back to plain conflict) rather than guessing at a more complex layout.
  • Snapshots live content under a read lock, computes the (parser-heavy) merge with no lock held, re-verifies nothing moved under a write lock before committing — never holds the store's single global mutex across parsing/diffing. Retries bounded by maxMergeCommitAttempts if the live revision moves mid-computation.
  • Non-negotiable safety invariant, enforced at multiple layers: a merge request either writes one fully validated merged file, or it writes nothing. Provenance miss, hash mismatch, parse failure, unsupported layout, or any unresolved conflict all fail closed with zero mutation — verified end-to-end, not just asserted (TestMergeFileConflictLeavesLiveFileUnchanged reads the file back after a conflict and checks it's byte-identical to before).

HTTP + SDK (built by Codex on top of the store layer):

  • POST /v1/workspaces/{workspaceId}/fs/merge?path=... with full error mapping (merge_conflict → 409 with per-unit details, merge_base_unavailable → 409, merge_ineligible → 422, invalid input → 400).
  • OpenAPI spec, TypeScript SDK mergeFile() method, MergeConflictError class.
  • A path-scoped authorization test suite I didn't even explicitly ask for.

Multi-agent workflow

Same split as phases 1-2: I (Claude) implemented and tested the store layer directly (23 tests, including a -race concurrency test and a dedicated "merged output must always parse as valid Go" safety test that caught a real bug during development — a missing separator when concatenating adjacent function texts). Codex (on sf-mac-mini, via codex exec) built the HTTP/SDK layer on top from a detailed spec. Codex's sandbox again couldn't push (no git write access, DNS blocked) or run tests needing loopback binding or a full npm install — picked those up directly, reviewed every diff, ran full independent validation outside that sandbox.

Test plan

  • Store layer: 23 tests (pure extraction/merge algorithm + full Store.MergeFile integration) — all passing, including 3x repeated -race runs
  • HTTP layer: full auto-merge round trip, per-unit conflict detail verification (exact base/mine/theirs text), no-mutation-on-conflict and no-mutation-on-unavailable-base guarantees, ineligible/invalid-input mapping, path-scoped authorization
  • go build ./..., go vet ./..., full go test ./... — all green, run directly on sf-mac-mini outside Codex's sandbox
  • scripts/check-contract-surface.sh — passing
  • TypeScript: npm test (233/233 passing), npx tsc --noEmit — clean
  • Live 3-machine proof (not just unit tests): server on sf-mac-mini; from this laptop, edited CreateUser based on a shared seed; from finn-mac-mini, independently edited DeleteUser in the same live file; merge call from the laptop auto-applied both changes with zero conflict. Then the inverse: both machines edited the same function (CreateUser) — correctly rejected with 409, exact per-unit base/mine/theirs text in the response, and the live file verified byte-identical to finn-mac-mini's version (no partial merge, no corruption) — all over the real Tailscale network across three distinct physical machines.

🤖 Generated with multi-agent assistance (Claude implementing the store layer + reviewing/verifying, Codex implementing the HTTP/SDK layer) as part of the goal-B initiative.

Review in cubic

khaliqgant and others added 2 commits July 25, 2026 14:35
….MergeFile)

Store-layer half of Phase 3 (docs/multi-agent-collaboration-assessment.md).
HTTP handler, OpenAPI spec, SDK, and mountsync integration land in a
follow-up commit.

Two agents editing DIFFERENT functions in the SAME Go file today still
hit a whole-file conflict (correct, per Phase 0 — no silent data loss —
but it forces a full discard-and-redo even though the actual changes
don't overlap). This adds an opt-in merge strategy that auto-applies
when the changes are provably disjoint at function granularity, and
falls back to a plain conflict — writing nothing — the instant anything
is ambiguous.

Design, reconciled from two independent proposals (mine and a second
pass from Codex) before any code was written — Codex's version was
adopted almost entirely; see the design docs for the full reconciliation:

- internal/relayfile/merge_go.go: goExtractUnits parses a Go file with
  go/parser and splits it into a "prologue" (package/imports/types/vars
  before the first function) plus one unit per top-level function,
  matched across versions by a stable key (name, or receiver-type-qualified
  for methods) rather than byte position. Deliberately restrictive for
  v1: any non-func top-level declaration AFTER the first function makes
  the file ineligible (falls back to plain conflict) rather than
  guessing at a more complex layout. goThreeWayMerge takes base/mine/theirs
  extractions and, per unit, auto-resolves when only one side changed it
  (or neither), and conflicts when both did — including same-function
  edits to different lines, which still conflict (function is the unit
  of collaboration, not line ranges). func init() is never auto-merged
  regardless of what changed, since its ordering is semantically
  significant. Two independently-added functions with the same name
  conflict unless byte-identical.

- internal/relayfile/merge.go: Store.MergeFile wires this into the
  store. Verifies the caller-supplied base content against a new
  in-memory, bounded revision->content-hash provenance ledger
  (recordRevisionProvenanceLocked, hooked into
  recordWriteWithContentIdentityLocked so every write path — WriteFile,
  BulkWrite, fork commits — feeds it automatically) before trusting it
  for the merge; a caller can't fabricate a base to trick the algorithm.
  Snapshots live content under a read lock, computes the (parser-heavy)
  merge with no lock held at all, then re-verifies nothing moved under
  a write lock before committing — never holds the store's single
  global mutex across parsing/diffing. If the live revision moved
  between snapshot and commit, recomputes against fresh state and
  retries, bounded by maxMergeCommitAttempts.

Non-negotiable safety invariant, enforced at multiple layers: a merge
request either writes one fully validated merged file, or it writes
nothing — provenance miss, hash mismatch, parse failure, unsupported
file layout, or any unresolved conflict all fail closed with zero
mutation. The merge result is re-parsed as a final self-check
immediately before commit, on top of the same invariant already proven
by TestMergeResultsAreAlwaysValidGo in isolation.

23 new tests across three files: the pure extraction/merge algorithm
(merge_go_test.go — distinct functions, same-function-different-lines,
new-function-plus-unrelated-change, delete-vs-modify, delete-vs-unchanged,
both-delete-same-unchanged, init() always-conflicts, file-scope
both-vs-one-sided, both-add-same-vs-different, and a dedicated
always-valid-Go safety test that caught a real concatenation bug during
development), and the full Store.MergeFile integration including a
concurrent-commit-retry race test run with -race.
HTTP + SDK half of Phase 3 (docs/multi-agent-collaboration-assessment.md),
built on the store-layer Store.MergeFile primitive.

- POST /v1/workspaces/{workspaceId}/fs/merge, handleMergeFile with full
  error mapping: merge_conflict -> 409 with per-unit conflict details,
  merge_base_unavailable -> 409, merge_ineligible -> 422 with reason,
  invalid input -> 400. Path-scoped fs:write authorization enforced the
  same way handleWriteFile enforces it, with ACL treating the target as
  always-existing (MergeFile only ever operates on a live file).
- openapi/relayfile-v1.openapi.yaml: documents the endpoint and
  MergeRequest/MergeResponse/MergeConflictDetail schemas.
- TypeScript SDK: mergeFile() method, matching types, MergeConflictError
  class (carrying conflicts + currentRevision) wired into throwForError
  for merge_conflict, plus merge_base_unavailable/merge_ineligible
  surfaced as typed RelayFileApiError with the right code.
- HTTP tests: full auto-merge round trip, per-unit conflict detail
  verification (exact base/mine/theirs text), no-mutation-on-conflict
  and no-mutation-on-unavailable-base guarantees verified by reading
  the file back, ineligible-strategy and invalid-input mapping, and a
  dedicated path-scoped authorization suite (missing scope, wrong-path
  grant, correct-path grant).
- SDK tests: mergeFile() request shape, MergeConflictError branch, and
  the two fallback codes staying typed RelayFileApiError.

Co-authored-by: Codex <noreply@openai.com>
@cursor

cursor Bot commented Jul 25, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@khaliqgant, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 32 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9f495373-4b15-4381-a1c7-654369a19c8c

📥 Commits

Reviewing files that changed from the base of the PR and between 3de3386 and fc812e6.

📒 Files selected for processing (5)
  • internal/relayfile/merge.go
  • internal/relayfile/merge_go.go
  • internal/relayfile/merge_go_test.go
  • internal/relayfile/merge_store_test.go
  • packages/sdk/typescript/src/client.ts
📝 Walkthrough

Walkthrough

Adds a three-way merge API for eligible Go files, including function-level conflict detection, revision verification, concurrent commit retries, path-scoped authorization, OpenAPI definitions, and TypeScript SDK support.

Changes

Filesystem merge feature

Layer / File(s) Summary
Merge contract and revision provenance
internal/relayfile/merge.go, internal/relayfile/store.go, openapi/relayfile-v1.openapi.yaml, .trajectories/compacted/*
Defines merge request/response and error types, documents the HTTP contract, and records bounded revision content hashes for base verification.
Go merge engine and store commits
internal/relayfile/merge_go.go, internal/relayfile/merge.go, internal/relayfile/*merge*_test.go
Extracts eligible Go functions, performs three-way merges, reports structured conflicts, retries concurrent commits, and validates persistence behavior.
HTTP endpoint and authorization
internal/httpapi/server.go, internal/httpapi/server_test.go
Adds POST merge routing, path-scoped fs:write checks, request handling, typed HTTP errors, and endpoint contract tests.
TypeScript SDK integration
packages/sdk/typescript/src/*, packages/sdk/parity.json
Adds merge types and conflict errors, implements mergeFile, invalidates the read cache, maps merge failures, and tests request/error behavior.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant HTTPAPI
  participant RelayfileStore
  participant GoMergeEngine
  Client->>HTTPAPI: POST /fs/merge with base and proposed content
  HTTPAPI->>HTTPAPI: authorize target path with fs:write
  HTTPAPI->>RelayfileStore: call MergeFile
  RelayfileStore->>GoMergeEngine: perform three-way function merge
  GoMergeEngine-->>RelayfileStore: merged content or conflicts
  RelayfileStore-->>HTTPAPI: target revision or typed error
  HTTPAPI-->>Client: HTTP response
Loading

Possibly related PRs

Suggested reviewers: kjgbot

Poem

I’m a rabbit with functions to merge,
Through conflicts and revisions I surge.
With paws on each path,
I guard write access’s gate,
Then cache-cleared clients hop away.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.90% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: symbol-level merge support for concurrent Go edits in Phase 3.
Description check ✅ Passed The description is directly related to the implemented store, HTTP, SDK, and test changes and matches the PR objectives.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch goal-b/phase-3-symbol-merge

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown

Relayfile Eval Review

Run: .relayfile/evals/runs/2026-07-25T13-27-32-797Z-HEAD-provider
Mode: provider
Git SHA: f0f8fe0

Passed: 4 | Needs human: 0 | Reviewable: 0 | Missing output: 0 | Failed: 0 | Skipped: 0

Human Review Cases

No reviewable human-review cases captured Relayfile output.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3de3386ea9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

start = docStart
}
}
end := offset(fn.End())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve comments outside function AST ranges

When a valid Go file contains a standalone comment between functions or after the final function, fn.End() excludes that comment and it is not attached as fn.Doc; reconstruction concatenates only these captured function texts. Any otherwise-successful merge therefore silently deletes such comments even when neither side changed them, and the parse validation cannot detect the loss because the result remains valid Go.

Useful? React with 👍 / 👎.

if eventType != "file.deleted" {
contentHash = storedContentHashForFile(ws.Files[path])
}
s.recordRevisionProvenanceLocked(workspaceID, path, revision, contentHash)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Record provenance for provider-applied revisions

The sole provenance-recording call added here is reached only through recordWriteWithContentIdentityLocked, while provider ingest mutates files in applyProviderUpsertLocked and emits events directly through appendWorkspaceEventLocked. If an agent reads a provider-synced Go revision, another writer changes it, and the agent then attempts a merge from that base, the ledger cannot find the provider revision and always returns merge_base_unavailable, making this feature unusable for provider-origin files.

Useful? React with 👍 / 👎.

Comment thread internal/relayfile/merge.go Outdated
func (s *Store) commitMergeResultLocked(workspaceID, path string, req MergeRequest, mergedContent, expectedRevision string) (MergeResponse, bool, error) {
s.mu.Lock()
ws := s.ensureWorkspaceLocked(workspaceID)
if ws.Revision != expectedRevision {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry only when the target file changes

This compares the workspace-wide revision rather than the target file revision, so any unrelated write in the same workspace invalidates the merge snapshot. In a busy workspace, five writes to other paths while the Go file is being parsed can exhaust maxMergeCommitAttempts and turn a conflict-free merge into a 500 even though the target file never moved; the commit guard should track the snapshotted target file instead.

Useful? React with 👍 / 👎.

Comment thread internal/relayfile/merge.go Outdated
Comment on lines +229 to +232
return MergeResponse{
TargetRevision: op.Revision,
Strategy: MergeStrategyGoTopLevelFunctions,
}, true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return complete metadata for deduplicated merge retries

When a response is dropped and the caller retries with the same contentIdentity, this branch returns only targetRevision and strategy, leaving baseRevision and mergedAgainstRevision empty even though the documented MergeResponse requires them and defines their provenance semantics. Preserve enough merge metadata with the operation, or reconstruct it from the retried request, so retries return the same complete response as the original call.

AGENTS.md reference: AGENTS.md:L171-L180

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (4)
internal/relayfile/merge_go_test.go (1)

56-81: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a case for a comment that is not attached as a func doc.

TestGoExtractUnitsDocCommentTravelsWithFunc covers the attached case, but a banner comment sitting between two functions (or after the last one) is captured by neither the prologue nor any unit — see the related finding in internal/relayfile/merge_go.go. A round-trip assertion (extract → goThreeWayMerge(base, base, base) preserves every comment) would lock the invariant down.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/relayfile/merge_go_test.go` around lines 56 - 81, Add a test
covering a standalone banner comment between functions or after the final
function, verifying extraction does not drop it from the prologue or units. Use
a round-trip assertion through goThreeWayMerge with identical base inputs to
confirm every comment is preserved, alongside the existing
TestGoExtractUnitsDocCommentTravelsWithFunc coverage.
openapi/relayfile-v1.openapi.yaml (1)

259-274: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Splitting merge_conflict and merge_base_unavailable under one 409 oneOf is fine, but neither branch discriminates.

Both variants pin code to a single-value enum, so validators can resolve them; consider adding discriminator: {propertyName: code} so generated clients narrow the union automatically instead of falling back to unknown/manual checks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openapi/relayfile-v1.openapi.yaml` around lines 259 - 274, Add a
discriminator to the 409 response schema’s oneOf definition, using the code
property to distinguish MergeConflictErrorResponse from
MergeBaseUnavailableErrorResponse. Keep both existing schema references and the
current 409 response behavior unchanged.
internal/relayfile/merge_go.go (1)

159-180: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

recvTypeString misses *ast.IndexListExpr, and funcKey lacks the nil-Name guard funcDisplayName has.

A receiver with two or more type parameters (func (f *Foo[T, U]) Bar()) parses to IndexListExpr, so it degrades to the "?" fallback and produces the key method:?.Bar — correctness is preserved by the duplicate-key refusal, but such files become needlessly ineligible. Also, Line 161 dereferences fn.Name.Name unguarded while Line 149 treats a nil Name as possible.

♻️ Proposed change
 func funcKey(fn *ast.FuncDecl) string {
+	name := "<anonymous>"
+	if fn.Name != nil {
+		name = fn.Name.Name
+	}
 	if fn.Recv == nil || len(fn.Recv.List) == 0 {
-		return "func:" + fn.Name.Name
+		return "func:" + name
 	}
-	return "method:" + recvTypeString(fn.Recv.List[0].Type) + "." + fn.Name.Name
+	return "method:" + recvTypeString(fn.Recv.List[0].Type) + "." + name
 }
 
 func recvTypeString(expr ast.Expr) string {
 	switch t := expr.(type) {
 	case *ast.StarExpr:
 		return "*" + recvTypeString(t.X)
 	case *ast.Ident:
 		return t.Name
 	case *ast.IndexExpr:
 		return recvTypeString(t.X)
+	case *ast.IndexListExpr:
+		return recvTypeString(t.X)
 	default:
 		return "?"
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/relayfile/merge_go.go` around lines 159 - 180, Update recvTypeString
to handle *ast.IndexListExpr by recursively resolving its base receiver
expression, matching the existing *ast.IndexExpr behavior so multi-parameter
generic receivers produce stable method keys. Update funcKey to guard fn.Name
before dereferencing it, reusing the nil-name handling established by
funcDisplayName and preserving the existing function/method key formats for
valid declarations.
internal/relayfile/merge_store_test.go (1)

101-136: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Eviction of a real-but-aged base revision isn't covered.

Both subtests exercise "never recorded" / "hash mismatch". The ring-buffer eviction path (a base revision that was recorded but has since aged past maxRevisionProvenancePerPath) is a distinct branch, and the comment at Lines 210-213 of the race test claims it is covered here. A subtest that writes 21+ revisions and then merges against the seed would close the gap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/relayfile/merge_store_test.go` around lines 101 - 136, The
TestMergeFileRejectsUnverifiableBase test covers unknown and hash-mismatched
revisions but not an evicted recorded revision. Add a subtest that writes at
least 21 revisions to the same workspace and path so the seed revision ages
beyond maxRevisionProvenancePerPath, then merge using the seed’s BaseRevision
and BaseContent and assert ErrMergeBaseUnavailable.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/relayfile/merge_go.go`:
- Around line 111-143: Preserve unattached comments in the function region
during extraction by extending each following unit’s start backward to include
the gap after the previous declaration, including any trailing comments after
the final function. Update the extraction logic around goExtractedFile and
goFuncUnit so comments are not dropped while keeping function boundaries and
duplicate-key validation unchanged.

In `@internal/relayfile/merge_store_test.go`:
- Around line 220-249: Make the concurrent churn test deterministic by reducing
its write count well below maxMergeCommitAttempts, while still ensuring the
merge encounters retries. Replace the churn goroutine’s swallowed WriteFile
error return with an error channel, and have the test receive and fail on that
error after waiting for the goroutine. Preserve the existing MergeFile success
assertion and cleanup behavior.

In `@internal/relayfile/merge.go`:
- Around line 239-245: Rename commitMergeResultLocked to commitMergeResult and
update all callers and references accordingly. Preserve its existing behavior of
acquiring the write lock internally, and retain or update the doc comment to
explicitly state that commitMergeResult takes the write lock.
- Around line 141-151: Update merge CAS to use the target file revision rather
than the workspace-wide revision: capture the live file’s revision alongside
liveContent in the merge flow, pass it through the merge result, and make
commitMergeResultLocked compare the current ws.Files[path].Revision against that
captured value. Preserve MergedAgainstRevision as ws.Revision for reporting.
- Around line 229-232: Update the deduplicated retry path that returns
MergeResponse to populate all required fields, setting BaseRevision from the
request and MergedAgainstRevision from the revision recorded on the operation.
Ensure the original merge flow stores the merged-against revision on op so
replayed responses match the initial response.

In `@internal/relayfile/store.go`:
- Around line 548-556: Bound revisionProvenance across paths, not only within
each path. Update the revisionProvenance storage and its write/delete handling
so stale path entries are removed when a file is deleted or a workspace is torn
down, or enforce a global cap/LRU across keys while preserving the existing
per-path bound and fail-closed provenance behavior.

In `@packages/sdk/typescript/src/client.ts`:
- Around line 1606-1626: Update mergeFile to default the optional
MergeFileInput.contentType to "text/x-go" when constructing the request body,
while preserving any explicitly supplied contentType. Ensure the default is
applied before the merge request is sent and the existing cache eviction and
response handling remain unchanged.

---

Nitpick comments:
In `@internal/relayfile/merge_go_test.go`:
- Around line 56-81: Add a test covering a standalone banner comment between
functions or after the final function, verifying extraction does not drop it
from the prologue or units. Use a round-trip assertion through goThreeWayMerge
with identical base inputs to confirm every comment is preserved, alongside the
existing TestGoExtractUnitsDocCommentTravelsWithFunc coverage.

In `@internal/relayfile/merge_go.go`:
- Around line 159-180: Update recvTypeString to handle *ast.IndexListExpr by
recursively resolving its base receiver expression, matching the existing
*ast.IndexExpr behavior so multi-parameter generic receivers produce stable
method keys. Update funcKey to guard fn.Name before dereferencing it, reusing
the nil-name handling established by funcDisplayName and preserving the existing
function/method key formats for valid declarations.

In `@internal/relayfile/merge_store_test.go`:
- Around line 101-136: The TestMergeFileRejectsUnverifiableBase test covers
unknown and hash-mismatched revisions but not an evicted recorded revision. Add
a subtest that writes at least 21 revisions to the same workspace and path so
the seed revision ages beyond maxRevisionProvenancePerPath, then merge using the
seed’s BaseRevision and BaseContent and assert ErrMergeBaseUnavailable.

In `@openapi/relayfile-v1.openapi.yaml`:
- Around line 259-274: Add a discriminator to the 409 response schema’s oneOf
definition, using the code property to distinguish MergeConflictErrorResponse
from MergeBaseUnavailableErrorResponse. Keep both existing schema references and
the current 409 response behavior unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e87c8f18-0e34-4a1b-b089-b631e6060c9e

📥 Commits

Reviewing files that changed from the base of the PR and between 442b3e8 and 3de3386.

📒 Files selected for processing (16)
  • .trajectories/compacted/compact_e4w7ic7jakya_2026-07-25.json
  • .trajectories/compacted/compact_e4w7ic7jakya_2026-07-25.md
  • internal/httpapi/server.go
  • internal/httpapi/server_test.go
  • internal/relayfile/merge.go
  • internal/relayfile/merge_go.go
  • internal/relayfile/merge_go_test.go
  • internal/relayfile/merge_store_test.go
  • internal/relayfile/store.go
  • openapi/relayfile-v1.openapi.yaml
  • packages/sdk/parity.json
  • packages/sdk/typescript/src/client.test.ts
  • packages/sdk/typescript/src/client.ts
  • packages/sdk/typescript/src/errors.ts
  • packages/sdk/typescript/src/index.ts
  • packages/sdk/typescript/src/types.ts

Comment thread internal/relayfile/merge_go.go
Comment thread internal/relayfile/merge_store_test.go Outdated
Comment on lines +141 to +151
ws := s.workspaces[workspaceID]
var liveContent string
var liveExists bool
var currentRevision string
if ws != nil {
if f, exists := ws.Files[path]; exists {
liveContent = f.Content
liveExists = true
}
currentRevision = ws.Revision
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Merge CAS uses the workspace revision, so unrelated writes force retries and can fail an otherwise-clean merge.

currentRevision is ws.Revision (workspace-wide), and commitMergeResultLocked rejects the commit whenever it moved (Line 242). Any concurrent write to any other path in the workspace invalidates the attempt, so on a busy workspace a merge that has no contention on its own file can burn all 5 attempts and return the "did not converge" error. Consider CASing on the target file's revision instead, keeping MergedAgainstRevision as the workspace revision for reporting.

♻️ Sketch
 		var liveContent string
 		var liveExists bool
 		var currentRevision string
+		var liveFileRevision string
 		if ws != nil {
 			if f, exists := ws.Files[path]; exists {
 				liveContent = f.Content
 				liveExists = true
+				liveFileRevision = f.Revision
 			}
 			currentRevision = ws.Revision
 		}

and compare ws.Files[path].Revision against liveFileRevision inside commitMergeResultLocked.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/relayfile/merge.go` around lines 141 - 151, Update merge CAS to use
the target file revision rather than the workspace-wide revision: capture the
live file’s revision alongside liveContent in the merge flow, pass it through
the merge result, and make commitMergeResultLocked compare the current
ws.Files[path].Revision against that captured value. Preserve
MergedAgainstRevision as ws.Revision for reporting.

Comment thread internal/relayfile/merge.go Outdated
Comment thread internal/relayfile/merge.go Outdated
Comment on lines +548 to +556
// revisionProvenance is an in-memory-only, bounded ledger of
// (workspaceID, path, revision) -> content hash, used by MergeFile to
// verify a caller-supplied merge base actually matches what was really
// at that revision before trusting it for a three-way merge. Keyed by
// workspaceID+"\x00"+path; not persisted (a restart just means an
// in-flight merge citing a pre-restart revision safely falls back to
// merge_base_unavailable, the same fail-closed behavior as any other
// provenance miss).
revisionProvenance map[string][]revisionProvenanceEntry

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

revisionProvenance is bounded per path but unbounded in the number of paths.

Each (workspaceID, path) key retains up to maxRevisionProvenancePerPath entries forever — nothing prunes on file delete or workspace teardown, and deletes only append a Deleted entry (Line 3814). A bulk import that writes tens of thousands of files leaves that ledger resident for the process lifetime, for paths that will never be merge bases. Consider a global cap/LRU across keys, or dropping the key when the path is deleted.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/relayfile/store.go` around lines 548 - 556, Bound revisionProvenance
across paths, not only within each path. Update the revisionProvenance storage
and its write/delete handling so stale path entries are removed when a file is
deleted or a workspace is torn down, or enforce a global cap/LRU across keys
while preserving the existing per-path bound and fail-closed provenance
behavior.

Comment thread packages/sdk/typescript/src/client.ts
codex review of PR #373 found several real issues in the store-layer
merge implementation. Fixed:

- [P2, most important] Merge commit CAS compared the whole-workspace
  revision counter instead of the target file's own revision, so any
  unrelated write to a different path in the workspace could force a
  retry or exhaust maxMergeCommitAttempts on a merge that had no real
  contention on its own file. This directly re-introduced the exact
  mistake Phase 1 fixed for forks (workspace-wide instead of per-path
  CAS) -- fixed the same way, comparing ws.Files[path].Revision.

- [P1] Comments not attached to any function as a doc comment -- a
  standalone banner comment between two functions, or anything after
  the last function -- were captured by neither the prologue nor any
  unit, so a successful (non-conflicting) merge silently deleted them,
  and the merge's own always-valid-Go self-check couldn't catch the
  loss since the result still parsed fine without them. Fixed by
  making extraction gapless: each function's span now starts exactly
  where the previous one ended (capturing any comment in between), and
  the last function's span extends to EOF. This introduced a
  side effect -- the same function's captured Text can now vary by
  leading/trailing whitespace alone depending on whether it happens to
  be first/last in a given version -- fixed by comparing with
  textsDiffer (trims whitespace, not comment content) instead of raw
  Text equality for all change-detection, while reconstruction still
  uses the raw untrimmed Text.

- [P2] A deduplicated retry (same ContentIdentity) returned a
  MergeResponse with empty BaseRevision/MergedAgainstRevision even
  though the OpenAPI contract declares both required. Now stashes
  merge metadata on the operation's ProviderResult map at commit time
  and reconstructs the complete response on a dedup hit.

- commitMergeResultLocked renamed to commitMergeResult -- it acquires
  the store's write lock itself, inverting the *Locked naming
  convention every other helper in this file follows (which require
  the caller to already hold the lock); the old name could have led a
  future caller to deadlock on the non-reentrant RWMutex.

- recvTypeString/funcKey: handle *ast.IndexListExpr (multi-parameter
  generic receivers, e.g. Foo[T, U]) the same way *ast.IndexExpr
  already was, and guard against a nil fn.Name the same way
  funcDisplayName already did.

New/updated tests: TestMergeUnattachedCommentsSurviveASuccessfulMerge,
a dedicated ring-buffer-eviction subtest in
TestMergeFileRejectsUnverifiableBase, and a fix to
TestMergeFileConcurrentCommitRetries's flakiness risk (reduced churn
count below maxMergeCommitAttempts, surfaced its previously-swallowed
error). All existing tests re-verified passing, 3x with -race.

Known, not fixed in this pass (documented as follow-ups, both
flagged by review but lower severity and larger scope than the above):
provenance is only recorded for the WriteFile/BulkWrite/fork-commit
paths (via recordWriteWithContentIdentityLocked), not for provider
ingest or rename/move, which bypass it via direct
appendWorkspaceEventLocked calls -- a merge citing a base from one of
those paths safely falls back to merge_base_unavailable rather than
misbehaving, it just can't succeed; and the provenance ledger is
bounded per-path but not across the total number of distinct paths
ever written, so a very large number of distinct files could grow it
unboundedly over a long-running process's lifetime.
Merge is restricted to .go files server-side; explicitly default here
rather than relying on an omitted contentType falling through to the
server's generic text/plain fallback for an unrelated code path.

Addresses CodeRabbit review feedback on PR #373.
@khaliqgant
khaliqgant merged commit 4f100a0 into main Jul 25, 2026
10 checks passed
@khaliqgant
khaliqgant deleted the goal-b/phase-3-symbol-merge branch July 25, 2026 13:30
khaliqgant added a commit that referenced this pull request Jul 25, 2026
Phase 2 (RebaseFork, PR #372) and Phase 3 (Store.MergeFile symbol-level
merge, PR #373) both landed and merged since this doc was last updated.
Renumbers the remaining gaps, updates the verdict/recommendation sections,
and notes what's explicitly still out of scope (mount rollout, non-Go
languages) per the user's Phase 3 scope decision.
khaliqgant added a commit that referenced this pull request Jul 25, 2026
…rough symbol merge (Phase 4) (#374)

* feat(mountsync): mount rollout — route same-path .go conflicts through symbol merge

Phase 4 of goal-B collaborative-engine work. Before today, a mounted
workspace push that conflicted on a same path always fell straight to
materializeConflict: park the loser's bytes in .relay/conflicts/*.local,
pull the winner, overwrite local. Correct, but a whole-file discard even
when two agents touched different functions in the same Go file — exactly
the case Phase 3's Store.MergeFile (PR #373) already knows how to reconcile.

This wires mount rollout: on ErrConflict for a merge-eligible (.go) path,
attempt a symbol-level merge before falling back to today's behavior
unchanged.

The blocking design gap: Store.MergeFile requires actual BaseContent bytes
(hash-verified against the server's provenance ledger), not just a revision
string — and mountsync tracked only revision/hash per file, never content
as-of-a-revision. Added an opportunistic shadow cache (.relay/.mount-shadow/)
that snapshots .go content at the one point in the sync loop where local
content is proven to exactly match server state (preparePendingBulkWrite's
"confirmed clean" branch). A cache miss is always a safe, expected outcome
— attemptMountRolloutMerge falls through to materializeConflict exactly as
before, never treating a missed opportunity as an error.

- RemoteClient gains MergeFile; HTTPClient.MergeFile POSTs /fs/merge.
- doJSON's blanket 409-to-ConflictError collapse is narrowed to only the
  revision_conflict shape, so merge_conflict/merge_base_unavailable survive
  as typed HTTPError with Code intact (existing callers only ever produced
  revision_conflict, so this is additive).
- attemptMountRolloutMerge only reports success once the merged content is
  confirmed via readback and written locally; any failure after the server
  accepts the merge still falls back safely (the fallback path re-reads
  current server state on its own terms).
- Mock RemoteClient implementations (bootstrap_test.go, syncer_test.go,
  mountfuse's fakeRemoteClient/layoutRemoteClient) default MergeFile to
  merge_ineligible so no pre-existing test starts auto-merging.

Test coverage for the new mechanism (shadow cache, merge success/fallback
paths, updated same-file-conflict assertions) follows in a subsequent
commit.

* test(mountsync): cover mount rollout — shadow cache, merge attempt, real conflict race

Unit coverage for the new mechanism (shadow-cache round-trip/prune/miss,
attemptMountRolloutMerge's success/merge_conflict/merge_base_unavailable/
readback-failure paths, no-network-on-cache-miss) plus two integration-level
tests exercising the real syncer entrypoints (SyncOnce/HandleLocalChange)
rather than calling the new methods directly.

TestAssessSameFileNearSimultaneousWriteGoMergeAutoMerges is the Go-file
sibling of the existing same-path race test: two syncers against a real
httptest server + real Store.MergeFile, racing goroutines editing disjoint
functions in the same file, asserting the server and both local mirrors
converge to a single file with both edits and neither side has a
.relay/conflicts/*.local artifact — the mount-rollout counterpart to the
existing test's coverage of the classic same-path conflict-artifact path.

Written by Codex (finn-mac-mini) against the mechanism from the prior
commit; reviewed and independently re-run (build/vet/full suite, and the
new tests specifically at -race -count=3) before merging into this branch.

* fix(mountsync): populate mount-rollout shadow cache from the default poll-mode scan

Found live, driving relayfile-mount through a real bootstrap + reconcile
cycle across two physical machines (not just unit tests) — the shadow
cache never populated for a file that came in clean and was never edited
again, because pushLocal's own confirmed-clean short-circuit
(scanLocalFiles's hash==tracked.Hash && !Dirty check) returns before ever
calling preparePendingBulkWrite, where the cache-population hook lived.
That short-circuit is the only place the DEFAULT, recommended poll-mode
scan proves a .go file's on-disk content exactly matches its tracked
revision — the watcher-driven path (HandleLocalChange) reaches
preparePendingBulkWrite's clean branch directly and was unaffected, which
is exactly why the existing unit tests (built against HandleLocalChange)
didn't catch this: they never exercised the pushLocal/scanLocalFiles scan
path this bug lived in.

Net effect before this fix: mount rollout was silently inert under the
default poll mode, and only ever worked if the watcher path happened to
fire a redundant clean-file event — never in practice.

Also fixes TestMountRolloutConflictPathWithoutShadowFallsBack, whose
"fresh tracked file has no shadow entry after one SyncOnce" assumption is
no longer true now that bootstrap-then-immediately-clean populates the
cache within the same cycle (a real, intended improvement) — the test now
explicitly evicts the shadow directory to still exercise the cold-cache
fallback path it's meant to cover.

* fix(mountsync): shadow cache was caching empty content from pushLocal's poll path

Second bug found continuing the live cross-machine proof (first was the
previous commit — the shadow cache not populating at all under poll mode;
this is worse — it populates, with the wrong content).

pushLocal's confirmed-clean short-circuit (the site the prior commit
wired up) runs on the snapshot scanLocalFiles produced via
readLocalSnapshot(path, false) — a deliberate cheap hash-only read that
never populates RawContent. Every other caller of recordShadowContent
passes a full-content snapshot; this was the one exception, and it was
silently caching an empty byte slice as the merge base for every .go file
observed clean through periodic polling.

An empty cached "base" still round-trips through readShadowContent as a
cache hit — recordShadowContent's revision-emptiness guard doesn't catch
empty *content* — so attemptMountRolloutMerge would confidently send
baseContent="" to the server on every real conflict. The server's
provenance ledger verifies baseContent's hash before trusting it (this is
the exact mechanism Store.MergeFile uses to reject a fabricated base), so
every one of these merge attempts would fail as merge_base_unavailable and
silently fall back to the ordinary conflict-artifact path. Net effect:
mount rollout would never actually succeed under the default poll mode,
even after the previous commit's fix — degraded safely (no corruption,
same fallback as always), but the feature was still fully inert.

Fix: refreshShadowContentFromDisk does one real, explicit content read
(bounded to .go paths, and skipped once the exact revision is already
cached, so steady-state cost is unchanged) instead of reusing the cheap
scan's contentless snapshot.

Added TestMountRolloutShadowCachePopulatesFromPollModeAlone as a
regression test: it drives ONLY SyncOnce (no HandleLocalChange), which is
what isolates this bug — every existing test happened to also call
HandleLocalChange at some point, which always reads full content and
silently overwrote pushLocal's empty write before any assertion observed
it. This is exactly why the live cross-machine proof — not unit tests
alone — is the standing bar for calling a phase done.

* fix(mountsync): address PR #374 review — shadow write cost, field reset, merge idempotency

- recordShadowContent now short-circuits with a stat/size check before the
  atomic write + glob + prune, since it's called every scan/push cycle for
  every clean .go file but the cached base for a given revision never
  changes once written. Fixes real per-cycle disk churn on a large Go tree.
  (CodeRabbit, Major/perf)

- attemptMountRolloutMerge's success path now mutates the existing tracked
  entry instead of rebuilding it, preserving DeletePending/Denied/
  WriteDenied/DeniedHash instead of silently resetting them just because
  this particular write went through the merge path instead of an ordinary
  push. (CodeRabbit, Minor)

- HTTPClient.MergeFile now sends a contentIdentity derived from the merge's
  logical inputs (workspace/path/strategy/baseRevision/content hash, no
  timestamp), so doJSON's automatic retry on a lost response or 5xx/429
  resends an identical key and the server's existing merge-operation dedup
  (findExistingMergeOpLocked, from Phase 3) returns the original result
  instead of committing the same merge twice. Without this, a retried
  merge could mint an extra revision, filesystem event, and provider
  writeback for content that already landed. (Codex, P1)

Full suite green (go build/vet/test ./...), mount-rollout tests re-run at
-race -count=3.
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