Skip to content

fix: gather exception isolation, handoff chain guard, atomic memory writes - #3586

Merged
MervinPraison merged 2 commits into
mainfrom
claude/issue-3585-20260802-0714
Aug 3, 2026
Merged

fix: gather exception isolation, handoff chain guard, atomic memory writes#3586
MervinPraison merged 2 commits into
mainfrom
claude/issue-3585-20260802-0714

Conversation

@praisonai-triage-agent

@praisonai-triage-agent praisonai-triage-agent Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #3585

Summary

Three independent core-SDK correctness fixes. All are minimal and backward-compatible — no new params, exports, or dependencies.

1. Async fan-out exception isolation (agents/agents.py)

asyncio.gather(*tasks) used the default return_exceptions=False, so one failing async task propagated immediately and left its siblings running in the background, mutating shared self.tasks state out of band. Added _gather_with_isolation() (uses return_exceptions=True, awaits all siblings, then re-raises the first exception) and applied it at all 3 gather sites (workflow + sequential flush).

2. Handoff safety-chain corruption (agent/handoff.py)

_check_safety() raises before _push_handoff(), but finally: _pop_handoff() ran unconditionally — so a rejected handoff popped an ancestor's entry, silently eroding the cycle/depth guard. Guarded each finally with a pushed flag at all 5 call sites (sync/async Handoff.execute + LLM tool + sync/async TypedHandoff).

3. Non-atomic memory writes (memory/file_memory.py)

open(path, 'w') truncated the memory file before flock could be acquired, so a crash mid-write left it permanently empty. Now writes to a temp file, fsyncs, and os.replaces atomically into place (mirrors the existing BaseJSONStore pattern).

Test plan

  • Behavioral checks for all three fixes (gather siblings settle + first error re-raised; handoff ancestor chain preserved on safety rejection; atomic write leaves no temp file, round-trips)
  • tests/test_file_memory.py, tests/unit/test_handoff_unified.py — pass (73 passed, 3 skipped)
  • Pre-existing failures in test_handoff_tool_policy.py confirmed unrelated (fail identically on base commit)

Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved agent handoff reliability by preserving parent context when safety checks prevent a handoff.
    • Ensured asynchronous workflows wait for all tasks to finish before reporting the first failure.
    • Improved memory persistence reliability by making file updates safer and reducing the risk of partial writes.

…rites (fixes #3585)

- agents.py: add _gather_with_isolation so a failing async task no longer
  orphans its siblings; siblings are awaited to completion and the first
  exception is re-raised (return_exceptions=True) across all 3 gather sites.
- handoff.py: guard finally:_pop_handoff() with a `pushed` flag at all 5
  call sites so a _check_safety rejection (which raises before _push_handoff)
  no longer pops an ancestor's entry and corrupts the cycle/depth guard.
- file_memory.py: write JSON to a temp file + fsync + os.replace so a crash
  mid-write can no longer truncate the persisted memory store.

Co-authored-by: MervinPraison <MervinPraison@users.noreply.github.com>
@MervinPraison

Copy link
Copy Markdown
Owner

@coderabbitai review

@MervinPraison

Copy link
Copy Markdown
Owner

/review

@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@MervinPraison MervinPraison added pipeline/blocked:ci Blocked: CI not green on HEAD pipeline/blocked:manual-review Blocked: requires manual review pipeline/blocked:no-final Blocked: no FINAL @claude trigger yet pipeline/final-claude-pending Reviews done; waiting for FINAL @claude labels Aug 2, 2026
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR updates handoff-chain cleanup across execution paths, isolates asynchronous task exceptions until sibling tasks settle, and makes FileMemory JSON writes synchronized and atomic.

Changes

Handoff chain safety

Layer / File(s) Summary
Conditional handoff-chain cleanup
src/praisonai-agents/praisonaiagents/agent/handoff.py
Synchronous, asynchronous, tool-generated, and typed handoffs now push onto the chain after safety checks and pop only when the current invocation performed the push.

Asynchronous task isolation

Layer / File(s) Summary
Exception-isolated asynchronous gathering
src/praisonai-agents/praisonaiagents/agents/agents.py
_gather_with_isolation waits for all tasks to settle before re-raising the first exception. Workflow and sequential execution paths use the helper when flushing asynchronous tasks.

Atomic file memory persistence

Layer / File(s) Summary
Atomic JSON persistence
src/praisonai-agents/praisonaiagents/memory/file_memory.py
_write_json writes to a temporary file, flushes and synchronizes it, atomically replaces the destination, and cleans up temporary files after write errors.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

Suggested reviewers: mervinpraison

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the three primary fixes: async exception isolation, handoff chain guarding, and atomic memory writes.
Linked Issues check ✅ Passed The changes address all objectives in issue #3585, including async task isolation, five handoff paths, and atomic FileMemory writes.
Out of Scope Changes check ✅ Passed All changes directly support the requirements in issue #3585, with no unrelated code changes identified.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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 claude/issue-3585-20260802-0714

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.

@greptile-apps

greptile-apps Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR isolates asynchronous fan-out failures, preserves handoff-chain state when safety checks reject a handoff, and replaces direct memory-file writes with temporary-file replacement. The memory change still needs destination-level serialization to prevent concurrent updates from silently overwriting one another.

  • Waits for all asynchronous siblings before re-raising the first failure.
  • Pops handoff-chain state only when the current call successfully pushed it.
  • Uses unique temporary files, fsync, and atomic replacement for memory persistence.

Confidence Score: 4/5

The PR is not yet safe to merge because concurrent memory updates can still complete successfully while silently discarding one writer's data.

Each write now locks a unique temporary inode, while episodic updates perform an unlocked read-modify-write against a shared destination; competing atomic replacements therefore use stale snapshots and the last replacement silently wins.

Files Needing Attention: src/praisonai-agents/praisonaiagents/memory/file_memory.py

Important Files Changed

Filename Overview
src/praisonai-agents/praisonaiagents/agent/handoff.py Tracks whether each handoff invocation pushed chain state before conditionally popping it during cleanup.
src/praisonai-agents/praisonaiagents/agents/agents.py Adds an exception-isolating gather helper and applies it to workflow and sequential async batches.
src/praisonai-agents/praisonaiagents/memory/file_memory.py Uses unique temporary files and atomic replacement, but locks distinct temporary inodes and therefore does not serialize concurrent updates to one destination.

Reviews (2): Last reviewed commit: "fix: use unique temp name for atomic mem..." | Re-trigger Greptile

Comment thread src/praisonai-agents/praisonaiagents/memory/file_memory.py Outdated
@MervinPraison

Copy link
Copy Markdown
Owner

@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.

Phase 1: Review per AGENTS.md

  1. Protocol-driven: check heavy implementations vs core SDK
  2. Backward compatible: ensure zero feature regressions
  3. Performance: no hot-path regressions
  4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep
  5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
  6. Repo routing: agent-callable tools → PraisonAI-Tools; lifecycle plugins → PraisonAI-Plugins; optional sandbox backends → PraisonAI-Plugins (praisonai.sandbox entry point) — request changes if wrongly added to praisonaiagents/

Phase 2: FIX Valid Issues
7. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix
8. Also independently identify and fix any gaps or issues you find in the changed code — do not rely only on prior reviewer feedback
9. Push all code fixes directly to THIS branch (do NOT create a new PR)
10. Comment a summary of exact files modified and what you skipped

Phase 3: Final Verdict
11. If all issues are resolved, approve the PR / close the Issue
12. If blocking issues remain, request changes / leave clear action items

@MervinPraison MervinPraison added pipeline/awaiting-merge-gate FINAL done; waiting for merge gate / CI pipeline/blocked:cooldown Blocked: post-push or @claude cooldown and removed pipeline/final-claude-pending Reviews done; waiting for FINAL @claude pipeline/blocked:no-final Blocked: no FINAL @claude trigger yet labels Aug 2, 2026
@praisonai-triage-agent

praisonai-triage-agent Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Claude finished @MervinPraison's task —— View job


Final Architecture Review — PR #3586

  • Read AGENTS.md + changed files
  • Review 3 fixes (gather isolation, handoff guard, atomic writes)
  • Validate Greptile P1: PID-only temp path collision → CONFIRMED VALID
  • Fix: use tempfile.mkstemp (unique per-write name, matches codebase convention)
  • Test (73 passed, 3 skipped — matches baseline)
  • Push + verdict

Phase 1 — Review (AGENTS.md)

All three fixes are correctness bug fixes in the core SDK, not scope creep. No new params, exports, dependencies, or Agent-class bloat. Zero feature-surface growth. Correctly routed (core SDK behavior, not tools/plugins). Backward-compatible; no hot-path regressions (atomic write already existed; only temp-naming changed).

Fix Verdict
1. Async gather isolation (agents/agents.py) ✅ Correct. _gather_with_isolation uses return_exceptions=True, awaits all siblings, re-raises the first BaseException. Applied at all 3 gather sites. Prevents orphaned siblings mutating self.tasks out of band.
2. Handoff pushed guard (agent/handoff.py) ✅ Correct at all 5 sites (sync/async Handoff, LLM tool, sync/async TypedHandoff). _check_safety() raises before _push_handoff(); guarding finally with pushed stops a rejected handoff from popping an ancestor's chain entry.
3. Atomic memory write (memory/file_memory.py) ⚠️→✅ Original used .tmp.{os.getpid()} — a fixed name per (dir, pid). Two threads / FileMemory instances in one process writing the same file share that name; the first os.replace removes the temp the sibling still needs → lost/failed update. Fixed.

Phase 2 — Fix applied

memory/file_memory.py::_write_json: switched to tempfile.mkstemp(dir=..., prefix=f".{name}.", suffix=".tmp") for a unique temp name per write (via os.fdopen), keeping the flock + fsync + os.replace sequence. This matches the SDK's existing atomic-write convention (storage/base.py:208, session/store.py:716). Resolves the Greptile P1.

Verification (same-process concurrency): 4 threads × 20 writes to one file → all 80 items persisted, no leftover .tmp files, cross-instance roundtrip OK.

Skipped: nothing. No praisonai-rust/praisonai-ts touched (out of scope). Pre-existing unrelated failures in test_handoff_tool_policy.py (fail identically on base) left as-is.

Phase 3 — Verdict: Approve

All three fixes are valid, minimal, backward-compatible core-SDK correctness improvements. The one blocking issue (Greptile P1 temp-name collision) is now resolved and matches SDK convention. Tests green (73 passed, 3 skipped).

Note: I can't formally approve via GitHub reviews (bot restriction), but from an architecture standpoint this is ready to merge.

Files modified this pass: src/praisonai-agents/praisonaiagents/memory/file_memory.py

Job run · Branch

Replace the fixed .tmp.<pid> temp filename with tempfile.mkstemp so
concurrent writers in the same process (multiple threads or FileMemory
instances writing the same file) cannot clobber each other's temp file,
where the first os.replace would remove the temp the sibling still needs.
Matches the atomic-write convention used elsewhere in the SDK
(storage/base.py, session/store.py). Addresses Greptile P1.

Co-authored-by: Mervin Praison <MervinPraison@users.noreply.github.com>
@MervinPraison MervinPraison added pipeline/blocked:stale-final Blocked: FINAL stale after new commits and removed pipeline/blocked:stale-final Blocked: FINAL stale after new commits labels Aug 2, 2026

@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: 2

🤖 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/praisonai-agents/praisonaiagents/agents/agents.py`:
- Around line 1359-1362: Update _gather_with_isolation to preserve and raise the
first exception observed at runtime, rather than selecting the first exception
returned in asyncio.gather input order. Capture failures as each coroutine
completes, while retaining successful results and existing isolation behavior.

In `@src/praisonai-agents/praisonaiagents/memory/file_memory.py`:
- Around line 259-264: Update _write_json so temporary-file cleanup runs in a
finally block covering both successful writes and failures, including TypeError
from json.dump. In that cleanup, call tmp_path.unlink and ignore
FileNotFoundError to handle files already removed by os.replace; retain the
existing error logging and False return behavior.
🪄 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: edd11601-94c2-4e9a-ba0a-666ad0046809

📥 Commits

Reviewing files that changed from the base of the PR and between 119b551 and 5ed306c.

📒 Files selected for processing (3)
  • src/praisonai-agents/praisonaiagents/agent/handoff.py
  • src/praisonai-agents/praisonaiagents/agents/agents.py
  • src/praisonai-agents/praisonaiagents/memory/file_memory.py

Comment on lines +1359 to +1362
results = await asyncio.gather(*coros, return_exceptions=True)
for result in results:
if isinstance(result, BaseException):
raise result

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

python - <<'PY'
import asyncio

class SlowError(Exception):
    pass

class FastError(Exception):
    pass

async def slow_failure():
    await asyncio.sleep(0.01)
    raise SlowError

async def fast_failure():
    raise FastError

async def current_helper(coros):
    results = await asyncio.gather(*coros, return_exceptions=True)
    for result in results:
        if isinstance(result, BaseException):
            raise result

async def main():
    try:
        await current_helper([slow_failure(), fast_failure()])
    except FastError:
        return
    except SlowError as exc:
        raise SystemExit("FAIL: input-order exception masked FastError") from exc
    raise SystemExit("FAIL: no exception propagated")

asyncio.run(main())
PY

Repository: MervinPraison/PraisonAI

Length of output: 206


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

# Locate and inspect the helper plus nearby call sites without running repository code.
sed -n '1330,1415p' src/praisonai-agents/praisonaiagents/agents/agents.py
printf '\n--- references to _gather_with_isolation ---\n'
rg -n "_gather_with_isolation|asyncio\\.gather\\(" src/praisonai-agents/praisonaiagents/agents/agents.py

Repository: MervinPraison/PraisonAI

Length of output: 4732


Preserve the first observed exception.

_gather_with_isolation selects the first exception in input order, not the first coroutine exception that raises. If a later coroutine raises before an earlier slower coroutine settles, the earlier exception is raised instead. Store and raise the first exception when a coroutine fails, or document input-order selection with a regression test that covers a fast-failing later coroutine.

🤖 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/praisonai-agents/praisonaiagents/agents/agents.py` around lines 1359 -
1362, Update _gather_with_isolation to preserve and raise the first exception
observed at runtime, rather than selecting the first exception returned in
asyncio.gather input order. Capture failures as each coroutine completes, while
retaining successful results and existing isolation behavior.

Source: Coding guidelines

Comment on lines +259 to +264
except (IOError, OSError) as e:
self._log(f"Error writing {filepath}: {e}", logging.ERROR)
try:
tmp_path.unlink()
except OSError:
pass

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the relevant file and method without running repository code.
wc -l src/praisonai-agents/praisonaiagents/memory/file_memory.py
sed -n '220,285p' src/praisonai-agents/praisonaiagents/memory/file_memory.py

printf '\nRelevant imports/open/os calls:\n'
sed -n '1,80p' src/praisonai-agents/praisonaiagents/memory/file_memory.py

printf '\nStructural context for write_to_file/save functions:\n'
ast-grep outline src/praisonai-agents/praisonaiagents/memory/file_memory.py || true

printf '\nSearch for temporary-file writes/unlinks in file_memory:\n'
rg -n "Tmp|temp|mktemp|tmp_path|unlink|json\.dump|replace|FileNotFoundError" src/praisonai-agents/praisonaiagents/memory/file_memory.py

Repository: MervinPraison/PraisonAI

Length of output: 7771


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Behavioral probe: show what exceptions json.dump can raise from non-json-serializable objects and how the current try/except control flow would handle them.
python3 - <<'PY'
import json, io
buf = io.StringIO()
for data in [[lambda: 1], (lambda: 1,), {lambda: 1: 'x'}]:
    try:
        json.dump(data, buf, indent=2, ensure_ascii=False)
    except Exception as e:
        print(type(e).__name__, str(e), 'is instance of (IOError,OSError):', isinstance(e, (IOError, OSError)))
PY

Repository: MervinPraison/PraisonAI

Length of output: 466


Clean up the temporary file outside the IOError/OSError handler.

json.dump can fail with TypeError, so _write_json() can return False while the temp file remains on disk. Move tmp_path.unlink() to finally, and catch FileNotFoundError there to avoid leaking after a successful os.replace.

Clean up in finally
     os.replace(tmp_path, filepath)
     return True
 except (IOError, OSError) as e:
     self._log(f"Error writing {filepath}: {e}", logging.ERROR)
-    try:
-        tmp_path.unlink()
-    except OSError:
-        pass
     return False
+finally:
+    try:
+        tmp_path.unlink()
+    except (FileNotFoundError, OSError):
+        pass
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
except (IOError, OSError) as e:
self._log(f"Error writing {filepath}: {e}", logging.ERROR)
try:
tmp_path.unlink()
except OSError:
pass
os.replace(tmp_path, filepath)
return True
except (IOError, OSError) as e:
self._log(f"Error writing {filepath}: {e}", logging.ERROR)
return False
finally:
try:
tmp_path.unlink()
except (FileNotFoundError, OSError):
pass
🤖 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/praisonai-agents/praisonaiagents/memory/file_memory.py` around lines 259
- 264, Update _write_json so temporary-file cleanup runs in a finally block
covering both successful writes and failures, including TypeError from
json.dump. In that cleanup, call tmp_path.unlink and ignore FileNotFoundError to
handle files already removed by os.replace; retain the existing error logging
and False return behavior.

Comment on lines 256 to 257
if _HAS_FCNTL:
fcntl.flock(f.fileno(), fcntl.LOCK_EX)

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.

P1 Temporary locks do not serialize writers

If two agents or FileMemory instances using the same user ID add episodic entries concurrently, each writer locks a different temporary inode after reading the same destination state. Both replacements then succeed, and the later replacement silently discards the other entry.

Knowledge Base Used: praisonai-agents Core Library

@praisonai-triage-agent praisonai-triage-agent Bot removed pipeline/blocked:ci Blocked: CI not green on HEAD pipeline/blocked:cooldown Blocked: post-push or @claude cooldown labels Aug 2, 2026
@MervinPraison
MervinPraison merged commit 1fe4aa4 into main Aug 3, 2026
58 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pipeline/awaiting-merge-gate FINAL done; waiting for merge gate / CI pipeline/blocked:manual-review Blocked: requires manual review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

praisonaiagents: gather() exception isolation, handoff safety-chain corruption, and non-atomic memory writes

1 participant