Skip to content

Conversation

@matleh
Copy link
Contributor

@matleh matleh commented Jan 22, 2026

Motivation

A previous PR introduced replaceInBody and appendToBody mutations for modifying bean body content. While these worked well for single operations, we quickly realized limitations:

  1. No way to combine multiple operations atomically - Applying multiple text replacements required separate mutation calls with etag chaining
  2. Couldn't combine body modifications with metadata updates - Changing status and updating body content required two mutations
  3. Race condition in etag validation - Etag validation happened before acquiring the write lock, allowing concurrent updates to both pass validation

This PR consolidates body modification capabilities into a single atomic operation and fixes the concurrency issue.

Changes

GraphQL API Improvements

Consolidated body modifications into updateBean:

  • Added BodyModification input type supporting multiple replace operations and append
  • Added ReplaceOperation input type with old and new fields
  • Added bodyMod field to UpdateBeanInput for structured body modifications
  • Removed replaceInBody and appendToBody mutations (redundant, breaking change)

New capabilities:

  • Apply multiple text replacements in a single transaction
  • Combine body modifications with metadata updates (status, priority, etc.)
  • All operations atomic with single etag validation
  • Sequential replacement execution (each operates on result of previous)
  • Transactional semantics (any failure = no changes saved)

Race Condition Fix

Moved etag validation under write lock:

  • Added ETagMismatchError and ETagRequiredError to beancore package
  • Updated Core.Update() signature to accept ifMatch parameter
  • Moved etag validation from GraphQL resolver into Core.Update() under write lock
  • Fixed Go pointer aliasing issue by validating etag against on-disk content
  • Updated all mutation call sites to pass ifMatch through to Core

The race condition occurred when two threads validated etags before either acquired the write lock, allowing both to pass validation. The second update would silently overwrite the first. Now validation happens atomically with the update operation.

CLI Updates

  • Updated flag mutual exclusivity to allow --body-replace-old/new and --body-append together
  • Fixed applyBodyMod() to properly check for bodyMod field
  • Maintained exclusivity with --body and --body-file (full replacement)
  • Multiple replacements available via beans query command

Documentation

  • Updated cmd/prompt.tmpl with concise body modification examples
  • Documented GraphQL bodyMod usage for multiple replacements
  • Explained execution order and transactional behavior

Examples

CLI - Combined operations:

beans update <id> \
  --status completed \
  --body-replace-old "- [ ] Deploy" \
  --body-replace-new "- [x] Deploy" \
  --body-append "## Summary\n\nDeployment completed"

GraphQL - Multiple replacements:

mutation {
  updateBean(id: "bean-xyz", input: {
    status: "completed"
    bodyMod: {
      replace: [
        { old: "- [ ] Write tests", new: "- [x] Write tests" }
        { old: "- [ ] Deploy", new: "- [x] Deploy" }
        { old: "Status: pending", new: "Status: done" }
      ]
      append: "## Summary\n\nAll tasks completed!"
    }
    ifMatch: "abc123"
  }) {
    id
    body
    etag
  }
}

Execution Order

  1. All replace operations applied sequentially (each operates on result of previous)
  2. append operation applied to final result
  3. Single etag validation for entire operation
  4. If any operation fails, entire mutation fails (transactional)

Breaking Changes

⚠️ GraphQL API:

  • replaceInBody mutation removed (use updateBean with bodyMod.replace)
  • appendToBody mutation removed (use updateBean with bodyMod.append)

Migration:

# Before - separate mutations
- replaceInBody(id: "bean-xyz", old: "foo", new: "bar", ifMatch: "abc")
- appendToBody(id: "bean-xyz", content: "## Notes\n\nDone")

# After - single atomic mutation
+ updateBean(id: "bean-xyz", input: {
+   bodyMod: {
+     replace: [{ old: "foo", new: "bar" }]
+     append: "## Notes\n\nDone"
+   }
+   ifMatch: "abc"
+ })

Testing

Manual testing performed:

  • ✅ 10 CLI test scenarios (single/combined operations, error handling)
  • ✅ 20 GraphQL test scenarios (mutations, queries, relationships, edge cases)
  • ✅ Transactional rollback verification
  • ✅ Etag validation and concurrency control
  • ✅ File persistence verification

Unit tests added:

  • Comprehensive UpdateBean with bodyMod test coverage (10 cases)
  • Etag validation tests for all GraphQL mutations
  • Concurrency and race condition tests in beancore

All tests passing ✅

Copy link
Owner

@hmans hmans left a comment

Choose a reason for hiding this comment

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

Wonderful PR, thank you very much! Also, apologies for taking a bit to respond -- just working on too much stuff in parallel. Aaaaaah!

@hmans
Copy link
Owner

hmans commented Jan 29, 2026

@matleh May I ask you to resolve the merge conflicts with main? You'll have a better idea of what to look out for. Other than that, ready to merge!

Agents can now atomically update status, metadata, and body content together
with a single etag validation, eliminating race conditions.

- Add BodyModification input type supporting multiple replacements and append
- Add bodyMod field to UpdateBeanInput for structured body modifications
- Remove separate replaceInBody/appendToBody mutations (consolidated)
- Update CLI to allow combining --body-replace-old and --body-append
- Implement transactional all-or-nothing semantics
- Add comprehensive test coverage
Move etag validation inside Core.Update() under write lock to prevent
lost updates in concurrent scenarios.

- Add ETagMismatchError and ETagRequiredError to beancore package
- Update Core.Update() signature to accept ifMatch parameter
- Validate etag against on-disk content to handle Go pointer aliasing
- Update all Core.Update() call sites to pass ifMatch
- Remove validateETag from graph resolver (now in Core)
- Add comprehensive tests for etag validation scenarios
- Add setupTestCoreWithRequireIfMatch helper for testing

The race condition occurred when two threads validated etags before either
acquired the write lock, allowing both to pass validation and the second
update to overwrite the first. Now validation happens atomically with the
update under the write lock, preventing lost updates.
@matleh matleh force-pushed the feature/combine-body-modifications branch from d1e9b77 to a186d7e Compare January 29, 2026 22:02
@matleh
Copy link
Contributor Author

matleh commented Jan 29, 2026

Rebase Completed - Conflicts Resolved

I've resolved the merge conflicts and rebased this PR on main.


Upstream Changes Incorporated

From PR #67: Added addBlockedBy and removeBlockedBy mutations with bidirectional cycle detection

From PR #60: Added replaceInBody and appendToBody standalone mutations


Conflict Resolution Decisions

1. Removed replaceInBody and appendToBody mutations

These are superseded by the bodyMod field in updateBean (the core feature of this PR). Keeping them would create API fragmentation - multiple ways to do the same thing.

The bodyMod approach is better because:

  • ✅ Atomic - all body modifications happen in one mutation with one ETag
  • ✅ No ETag chaining needed between separate mutations
  • ✅ Consistent with this PR's goal of consolidating operations

2. Applied ETag race condition fix to new upstream mutations

The upstream addBlockedBy and removeBlockedBy mutations didn't include the ETag race condition fix from commit f87e74d. I applied the fix to maintain consistency:

  • Removed manual ETag validation calls
  • Let Core.Update() handle validation under write lock (prevents race conditions)

Testing

✅ All tests passing
✅ Build succeeds
✅ No functionality lost - bodyMod provides same capabilities as removed mutations


Let me know if you'd like me to adjust anything about the conflict resolution approach!

@matleh
Copy link
Contributor Author

matleh commented Jan 29, 2026

also see follow-up PR #81

@hmans
Copy link
Owner

hmans commented Jan 30, 2026

There seems to be a driveby addition of a session Markdown file, may I ask you to remove it?

Add comprehensive etag validation tests for:
- updateBean
- setParent
- addBlocking
- removeBlocking

Each mutation now has tests verifying:
- Success with correct etag
- Failure with wrong etag returning ETagMismatchError
@matleh matleh force-pushed the feature/combine-body-modifications branch from a186d7e to 2b39d4b Compare January 30, 2026 11:18
@matleh
Copy link
Contributor Author

matleh commented Jan 30, 2026

There seems to be a driveby addition of a session Markdown file, may I ask you to remove it?

I am sorry - removed.

@hmans hmans merged commit 0845fde into hmans:main Jan 30, 2026
1 check passed
@hmans
Copy link
Owner

hmans commented Jan 30, 2026

Merged, thanks for the PR! 🙏

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.

2 participants