Skip to content

fix(delegation): serialize delegateParentAndOpenChild with atomicReadAndUpdate#691

Merged
navedmerchant merged 4 commits into
mainfrom
issue/364
Jun 25, 2026
Merged

fix(delegation): serialize delegateParentAndOpenChild with atomicReadAndUpdate#691
navedmerchant merged 4 commits into
mainfrom
issue/364

Conversation

@edelauna

@edelauna edelauna commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Related GitHub Issue

Closes: #364

Description

Replaces the unguarded getTaskWithId + updateTaskHistory read-modify-write in delegateParentAndOpenChild with a new atomicReadAndUpdate(taskId, updater) API on TaskHistoryStore that holds the store's mutex for the entire read-modify-write sequence.

Root cause: Two concurrent delegations from the same parent could each read the parent's HistoryItem before either had written back, causing the second write to clobber fields set by the first (e.g. awaitingChildId, childIds).

Key implementation details:

  • _upsertUnlocked(item) — extracted private helper containing the upsert body without acquiring the lock; safe to call from inside an existing lock holder.
  • atomicReadAndUpdate(taskId, updater) — typed API: reads from the in-memory cache, applies a pure synchronous updater, then writes via _upsertUnlocked, all within a single withLock acquisition. The updater cannot call upsert/withLock (deadlock-safe by design).
  • child.start() is intentionally kept outside the lock — it calls saveClineMessages → upsert → withLock and would deadlock if called inside.

Test Procedure

New test file src/__tests__/delegation-concurrent.spec.ts covers:

  1. Serialization — second concurrent caller reads state written by the first (both childIds present after two concurrent delegations)
  2. Full field consistencystatus, delegatedToId, awaitingChildId, and childIds are consistent after concurrent writes
  3. Deadlock-freedom — the typed API completes in < 100 ms (not the 100 ms timeout)
  4. Not-found error — throws with the task ID when the item isn't in the cache

Updated src/__tests__/provider-delegation.spec.ts to match the new atomicReadAndUpdate signature and extracted a shared makeStoreStub() helper.

Run tests with:

npx vitest run __tests__/delegation-concurrent.spec.ts
npx vitest run __tests__/provider-delegation.spec.ts

Pre-Submission Checklist

  • Issue Linked: This PR is linked to an approved GitHub Issue (see "Related GitHub Issue" above).
  • Scope: My changes are focused on the linked issue (one major feature/fix per PR).
  • Self-Review: I have performed a thorough self-review of my code.
  • Testing: New and/or updated tests have been added to cover my changes (if applicable).
  • Documentation Impact: No documentation updates required (internal concurrency fix).
  • Contribution Guidelines: I have read and agree to the Contributor Guidelines.

Additional Notes

Ticket #365 (reopenParentFromDelegation) has a similar unguarded read-modify-write — intentionally left out of scope for that follow-up ticket.

Summary by CodeRabbit

  • New Features
    • Added atomicReadAndUpdate to the task history store for atomic, locked read-modify-write updates.
  • Bug Fixes
    • Improved delegation reliability by updating parent delegation metadata atomically and ensuring correct sequencing for child startup and webview updates.
  • Refactor
    • Refactored task history upsert to separate unlocked mutation logic from the lock acquisition.
  • Tests
    • Expanded coverage for concurrent store updates, delegation sequencing, webview messaging behavior, and rollback on persistence failures.

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 886e667c-8f15-49f9-8ddd-1b3d52006152

📥 Commits

Reviewing files that changed from the base of the PR and between 3c2ef6b and c8477f4.

📒 Files selected for processing (1)
  • src/core/task-persistence/TaskHistoryStore.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/core/task-persistence/TaskHistoryStore.ts

📝 Walkthrough

Walkthrough

Adds an atomic read-modify-write API to TaskHistoryStore, uses it in delegation persistence, and updates tests for concurrent updates, ordering, messaging, and rollback.

Changes

Atomic delegation persistence

Layer / File(s) Summary
TaskHistoryStore atomic read-modify-write
src/core/task-persistence/TaskHistoryStore.ts
Extracts the locked upsert mutation into _upsertUnlocked and adds atomicReadAndUpdate(taskId, updater) to read the cached item under lock, apply a synchronous updater, persist the result, and return the updated history list.
ClineProvider delegation persistence
src/core/webview/ClineProvider.ts
Replaces the parent delegation read-then-write sequence in delegateParentAndOpenChild with taskHistoryStore.atomicReadAndUpdate, updating delegation status, parent-child links, cache invalidation, and conditional webview messaging after the atomic write completes.
Provider delegation tests
src/__tests__/provider-delegation.spec.ts
Refactors delegation tests around an atomicReadAndUpdate store stub, verifies the updated child creation and persistence order, adds webview message assertions, and checks rollback behavior when the atomic update rejects.
Concurrent store tests
src/__tests__/delegation-concurrent.spec.ts
Adds a new spec for TaskHistoryStore.atomicReadAndUpdate with filesystem mocks and tests for concurrent child ID merging, multi-field delegation updates, completion without deadlock, and missing-task rejection.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

  • #364 — This PR updates delegateParentAndOpenChild to use an atomic store update and adds concurrency-focused coverage around that flow.
  • #369 — This PR changes concurrent delegation handling in ClineProvider.delegateParentAndOpenChild.
  • #365 — This PR adds the TaskHistoryStore.atomicReadAndUpdate API and uses it for delegation persistence consistency.
  • #698 — This PR touches TaskHistoryStore locking and atomic update behavior.

Suggested reviewers

  • JamesRobert20
  • hannesrudolph
  • taltas
  • navedmerchant

Poem

🐇 I hop through locks with careful grace,
A history item keeps its place.
Two delegations, side by side,
Now read and write in one safe stride.
No stale trail, no tangled thread —
The rabbit smiles, the races fled.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: serializing delegateParentAndOpenChild with atomicReadAndUpdate.
Description check ✅ Passed The PR description matches the template with issue link, implementation summary, testing steps, checklist, and extra notes.
Linked Issues check ✅ Passed The changes satisfy the race-fix goal by atomically updating delegation metadata, keeping child.start() outside the lock, and adding concurrency/deadlock tests.
Out of Scope Changes check ✅ Passed The modified files stay focused on the delegation concurrency fix and its tests, with no unrelated feature work evident.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue/364

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed: private package registry requires authentication. Disable ESLint in CodeRabbit settings or use public packages.


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.

@edelauna edelauna changed the title fix(delegation): serialize delegateParentAndOpenChild read-modify-wri… fix(delegation): serialize delegateParentAndOpenChild with atomicReadAndUpdate Jun 23, 2026
@codecov

codecov Bot commented Jun 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.88889% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/core/task-persistence/TaskHistoryStore.ts 89.47% 1 Missing and 1 partial ⚠️
src/core/webview/ClineProvider.ts 87.50% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@edelauna edelauna marked this pull request as ready for review June 23, 2026 01:03

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (1)
src/__tests__/provider-delegation.spec.ts (1)

162-200: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Make the race test actually concurrent.

This test checks a single-call order (createTask → atomicReadAndUpdate → child.start), so it would still pass if two same-parent delegations both created and started children. Add a Promise.allSettled/controlled-promise case for two calls with the same parentTaskId and assert only the serialized winner starts, or that the second call rejects after the first changes the current task.

🤖 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 `@src/__tests__/provider-delegation.spec.ts` around lines 162 - 200, The test
only verifies sequential execution order for a single delegateParentAndOpenChild
call and would not catch race conditions if two concurrent delegations with the
same parentTaskId executed simultaneously. Modify the test to use
Promise.allSettled to invoke delegateParentAndOpenChild concurrently with
identical parentTaskId values twice, and then assert that the concurrent calls
are properly serialized by verifying either that only one child's start method
is actually called or that the second concurrent invocation rejects after the
first one modifies the current task state.
🤖 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 `@src/__tests__/delegation-concurrent.spec.ts`:
- Around line 109-114: The test is missing assertions to verify that delegated
fields are properly maintained during concurrent operations. In addition to the
existing assertions checking final.childIds and final.status, add assertions to
verify that final.delegatedToId and final.awaitingChildId are consistent and
present as expected. This ensures the test actually validates the full field set
as its name suggests, rather than only checking child IDs and status.

In `@src/__tests__/provider-delegation.spec.ts`:
- Line 34: The parent task doubles in the test file are missing the
flushPendingToolResultsToHistory method stub, which causes
delegateParentAndOpenChild to throw a TypeError during the flush step and follow
the error-handling branch instead of the intended happy path. Add the
flushPendingToolResultsToHistory stub method (using vi.fn()) to all parent task
double objects at lines 34, 101, 134, 165, and 205 so that the tests exercise
the correct delegation flow rather than the flush-failure branch.

In `@src/core/task-persistence/TaskHistoryStore.ts`:
- Around line 552-556: In the atomicReadAndUpdate method, the live cached object
is being passed directly to the updater function, which allows mutations to
affect the cache before persistence occurs. Create a deep snapshot or immutable
copy of current and pass that to the updater instead. Additionally, after the
updater returns the modified task object, validate that the returned object has
the same id as the input taskId parameter to enforce the task id invariant and
prevent writing another task's history item. Throw an error if the returned task
id does not match the expected taskId.
- Around line 551-556: The invalidate() and invalidateAll() methods in
TaskHistoryStore.ts mutate the cache directly without acquiring the lock via
withLock(), while atomicReadAndUpdate() serializes operations through
withLock(). This creates a race condition where invalidation can interleave with
file I/O in _upsertUnlocked, causing stale cache state. Wrap the cache mutations
in both invalidate() and invalidateAll() with withLock() to ensure serialization
with other operations. Additionally, update the call to invalidateAll() at
webviewMessageHandler.ts:954 to await the result since it will now be an async
operation.

In `@src/core/webview/ClineProvider.ts`:
- Around line 3499-3508: The delegation transition has a race condition where
two calls with the same parent task can both pass the current-parent check
before either completes, causing both to create and start child tasks. To fix
this, wrap the entire pre-start transition logic (the state updates with
delegatedToId, awaitingChildId, and the child creation) in a call to
runDelegationTransition(parentTaskId, ...) instead of just using the
atomicReadAndUpdate on taskHistoryStore. This function should return the paused
child task. Then keep the child.start() call after the runDelegationTransition
completes so it remains outside the TaskHistoryStore lock, ensuring the full
delegation transition is atomic and prevents both children from running
simultaneously.

---

Nitpick comments:
In `@src/__tests__/provider-delegation.spec.ts`:
- Around line 162-200: The test only verifies sequential execution order for a
single delegateParentAndOpenChild call and would not catch race conditions if
two concurrent delegations with the same parentTaskId executed simultaneously.
Modify the test to use Promise.allSettled to invoke delegateParentAndOpenChild
concurrently with identical parentTaskId values twice, and then assert that the
concurrent calls are properly serialized by verifying either that only one
child's start method is actually called or that the second concurrent invocation
rejects after the first one modifies the current task state.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 767e34e4-85f6-47d5-8b54-d74c28aa55fe

📥 Commits

Reviewing files that changed from the base of the PR and between 0f1054e and 0333660.

📒 Files selected for processing (4)
  • src/__tests__/delegation-concurrent.spec.ts
  • src/__tests__/provider-delegation.spec.ts
  • src/core/task-persistence/TaskHistoryStore.ts
  • src/core/webview/ClineProvider.ts

Comment thread src/__tests__/delegation-concurrent.spec.ts
Comment thread src/__tests__/provider-delegation.spec.ts Outdated
Comment on lines +551 to +556
return this.withLock(async () => {
const current = this.cache.get(taskId)
if (!current) {
throw new Error(`[TaskHistoryStore] atomicReadAndUpdate: task ${taskId} not found in cache`)
}
return this._upsertUnlocked(updater(current))

@coderabbitai coderabbitai Bot Jun 23, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find invalidation call sites that may need to await a locked async invalidation API.
rg -nP -C3 '\.\s*invalidate(All)?\s*\(' --type=ts

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 159


🏁 Script executed:

#!/bin/bash
# First, find the TaskHistoryStore.ts file
find . -name "TaskHistoryStore.ts" -type f

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 207


🏁 Script executed:

#!/bin/bash
# Read the relevant sections of the file to understand the implementation
# Lines 551-556 (atomicReadAndUpdate), and lines 303-320 (invalidate/invalidateAll)
cat -n src/core/task-persistence/TaskHistoryStore.ts | sed -n '295,330p'

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 1268


🏁 Script executed:

#!/bin/bash
# Read the atomicReadAndUpdate section
cat -n src/core/task-persistence/TaskHistoryStore.ts | sed -n '545,560p'

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 948


🏁 Script executed:

#!/bin/bash
# Search for invalidate method calls to find all callers
rg "invalidate\(" --type ts -A 2 -B 2

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 159


🏁 Script executed:

#!/bin/bash
# Search for all references to invalidate (with various patterns)
rg "invalidate" --type ts -B 1 -A 1

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 159


🏁 Script executed:

#!/bin/bash
# Also check what withLock does and understand the locking mechanism
cat -n src/core/task-persistence/TaskHistoryStore.ts | sed -n '1,100p' | head -50

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 2236


🏁 Script executed:

#!/bin/bash
# Find the withLock implementation
rg -A 10 "withLock" --type ts | head -50

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 159


🏁 Script executed:

#!/bin/bash
# Search for direct method calls in the file
grep -n "invalidate\|withLock" src/core/task-persistence/TaskHistoryStore.ts | head -30

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 592


🏁 Script executed:

#!/bin/bash
# Find withLock method definition
cat -n src/core/task-persistence/TaskHistoryStore.ts | grep -A 15 "private.*withLock\|withLock\s*("

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 3598


🏁 Script executed:

#!/bin/bash
# Search for any references to these methods in the entire codebase
fd -e ts -e tsx | xargs grep -l "invalidate\|TaskHistoryStore" | head -20

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 1167


🏁 Script executed:

#!/bin/bash
# Get a sense of the full file structure - lines 460-500 might have withLock
cat -n src/core/task-persistence/TaskHistoryStore.ts | sed -n '460,510p'

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 1844


🏁 Script executed:

#!/bin/bash
# Search for calls to invalidate methods across the codebase
rg "\.invalidate\(|\.invalidateAll\(" --type ts -B 2 -A 2

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 159


🏁 Script executed:

#!/bin/bash
# Also check the test files to understand usage patterns
cat -n src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts | grep -A 3 -B 3 "invalidate"

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 1217


🏁 Script executed:

#!/bin/bash
# Search with simpler patterns
grep -r "invalidate" src --include="*.ts" --include="*.tsx" | grep -v "test\|spec\|\.invalidate\(taskId\)" | head -30

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 620


🏁 Script executed:

#!/bin/bash
# Look for exact method calls more carefully
grep -rn "invalidate(" src --include="*.ts" --include="*.tsx" | grep -v "test" | grep -v "spec"

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 260


🏁 Script executed:

#!/bin/bash
# Check if there are any awaits of invalidate or invalidateAll
grep -rn "await.*invalidate\|invalidate.*await" src --include="*.ts" --include="*.tsx"

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 493


🏁 Script executed:

#!/bin/bash
# Search in test files more specifically for how invalidate is called
cat -n src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts | sed -n '400,450p'

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 1800


🏁 Script executed:

#!/bin/bash
# Find the invalidateAll call in webviewMessageHandler.ts
grep -n "invalidateAll" src/core/webview/webviewMessageHandler.ts -B 5 -A 5

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 645


🏁 Script executed:

#!/bin/bash
# Check if there are any other callers we might have missed
find src -name "*.ts" -o -name "*.tsx" | xargs grep -l "invalidate" | xargs grep -n "invalidate" | grep -v "test" | grep -v "spec" | grep -v "^\s*//"

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 638


🏁 Script executed:

#!/bin/bash
# Get more context around the invalidateAll call at line 954
cat -n src/core/webview/webviewMessageHandler.ts | sed -n '940,970p'

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 1509


🏁 Script executed:

#!/bin/bash
# Check what reconcile() does - does it also use withLock?
cat -n src/core/task-persistence/TaskHistoryStore.ts | sed -n '245,290p'

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 1798


🏁 Script executed:

#!/bin/bash
# Check if there are any concurrent operations that might demonstrate the race
cat -n src/core/task-persistence/TaskHistoryStore.ts | sed -n '540,575p'

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 1706


🏁 Script executed:

#!/bin/bash
# Check if _upsertUnlocked awaits I/O while holding the lock
cat -n src/core/task-persistence/TaskHistoryStore.ts | sed -n '164,190p'

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 1097


🏁 Script executed:

#!/bin/bash
# Check the scheduleIndexWrite and debounced behavior to understand cache state
cat -n src/core/task-persistence/TaskHistoryStore.ts | sed -n '380,420p'

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 1328


🏁 Script executed:

#!/bin/bash
# Double-check all calls to invalidateAll - search more broadly
rg "invalidateAll" --type ts --type tsx -B 2 -A 2

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 187


🏁 Script executed:

#!/bin/bash
# Verify the full context where invalidateAll is called - is this in an async function?
cat -n src/core/webview/webviewMessageHandler.ts | sed -n '920,970p'

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 2132


Wrap cache mutations in both invalidate() and invalidateAll() with withLock to prevent race conditions.

atomicReadAndUpdate serializes operations through withLock, but invalidate() and invalidateAll() mutate cache directly without the lock. If invalidation interleaves while _upsertUnlocked is awaiting file I/O (line 175), it can restore or clear stale cache state. This violates the lock's serialization invariant and can cause the debounced index to persist stale data.

Both methods must acquire the lock before any cache mutation:

Suggested fix
-	async invalidate(taskId: string): Promise<void> {
-		try {
-			const item = await this.readTaskFile(taskId)
-			if (item) {
-				this.cache.set(taskId, item)
-			} else {
-				this.cache.delete(taskId)
-			}
-		} catch {
-			this.cache.delete(taskId)
-		}
-	}
+	async invalidate(taskId: string): Promise<void> {
+		return this.withLock(async () => {
+			try {
+				const item = await this.readTaskFile(taskId)
+				if (item) {
+					this.cache.set(taskId, item)
+				} else {
+					this.cache.delete(taskId)
+				}
+			} catch {
+				this.cache.delete(taskId)
+			}
+		})
+	}

-	invalidateAll(): void {
-		this.cache.clear()
-	}
+	async invalidateAll(): Promise<void> {
+		return this.withLock(async () => {
+			this.cache.clear()
+		})
+	}

Update the call at webviewMessageHandler.ts:954 to await invalidateAll().

🤖 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 `@src/core/task-persistence/TaskHistoryStore.ts` around lines 551 - 556, The
invalidate() and invalidateAll() methods in TaskHistoryStore.ts mutate the cache
directly without acquiring the lock via withLock(), while atomicReadAndUpdate()
serializes operations through withLock(). This creates a race condition where
invalidation can interleave with file I/O in _upsertUnlocked, causing stale
cache state. Wrap the cache mutations in both invalidate() and invalidateAll()
with withLock() to ensure serialization with other operations. Additionally,
update the call to invalidateAll() at webviewMessageHandler.ts:954 to await the
result since it will now be an async operation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Acknowledged — this is a real defect but predates this PR and is out of scope here. Tracked as a standalone story in Epic 2: #698. The fix recommended there is an invalidateAndReconcile() method that clears and rebuilds the cache under a single lock acquisition, eliminating the empty-cache window between the two steps.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@edelauna, understood — thanks for the context. The invalidateAndReconcile() approach (clear + rebuild under a single lock) is a cleaner fix than wrapping the two methods separately anyway, since it eliminates the empty-cache window. I'll keep this comment open and it can be resolved when #698 lands.


✏️ Learnings added
Learnt from: edelauna
Repo: Zoo-Code-Org/Zoo-Code PR: 691
File: src/core/task-persistence/TaskHistoryStore.ts:0-0
Timestamp: 2026-06-23T13:43:33.766Z
Learning: In `src/core/task-persistence/TaskHistoryStore.ts`, the `invalidate(taskId)` and `invalidateAll()` methods mutate the in-memory cache outside `withLock`, creating a race with concurrent `_upsertUnlocked` I/O. This is a known pre-existing defect tracked as issue `#698`. The planned fix is a new `invalidateAndReconcile()` method that clears the cache and rebuilds it from disk within a single `withLock` acquisition, eliminating the empty-cache window between the two steps.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment thread src/core/task-persistence/TaskHistoryStore.ts Outdated
Comment thread src/core/webview/ClineProvider.ts
throw new Error(`[TaskHistoryStore] atomicReadAndUpdate: task ${taskId} not found in cache`)
}
// Deep-copy so a mutating updater cannot alter cached state before persistence.
const snapshot = JSON.parse(JSON.stringify(current)) as HistoryItem

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

use structuredClone() here? instead of JSON.stringify which is hacky. can also use cloneDeep which is used in UpdateTodoListTool

@navedmerchant navedmerchant left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One comment on deep cloning, can use loadash or clone-deep for the deep cloning, otherwise lgtm

@navedmerchant navedmerchant added this pull request to the merge queue Jun 25, 2026
Merged via the queue into main with commit 6670962 Jun 25, 2026
11 checks passed
@navedmerchant navedmerchant deleted the issue/364 branch June 25, 2026 14:00
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.

[Story 2.1] Serialize delegateParentAndOpenChild with withLock

2 participants