Fix parallel worker chat log orphans

Skip persisted tool-result files for BACKGROUND contexts, remove ephemeral direct parallel worker chat folders during cleanup, and ignore chat directories without chat.json during startup loading.

Add focused regressions for background output persistence, parallel worker cleanup, and orphan directory loading.
This commit is contained in:
Alessandro 2026-06-19 08:11:28 +02:00
parent 77552b9394
commit 78a50e1431
9 changed files with 84 additions and 1 deletions

View file

@ -12,6 +12,7 @@
- Preserve tool result traceability without leaking secrets.
- Keep file artifacts inside expected runtime/user-owned paths.
- Skip BACKGROUND contexts; background workers must remain ephemeral and must not create chat message files.
## Work Guidance

View file

@ -1,4 +1,5 @@
from typing import Any
from agent import AgentContextType
from helpers.extension import Extension
from helpers import files, persist_chat
import os, re
@ -9,6 +10,9 @@ class SaveToolCallFile(Extension):
def execute(self, data: dict[str, Any] | None = None, **kwargs):
if not self.agent:
return
if self.agent.context.type == AgentContextType.BACKGROUND:
return
if not data:
return

View file

@ -570,6 +570,7 @@ async def _remove_context(context_id: str | None) -> None:
if not context_id:
return
from agent import AgentContext
from helpers import persist_chat
context = AgentContext.get(context_id)
if context:
@ -578,6 +579,7 @@ async def _remove_context(context_id: str | None) -> None:
except Exception:
pass
AgentContext.remove(context_id)
persist_chat.remove_chat(context_id)
def _log_parallel_child_started(agent: "Agent", job: ParallelJob) -> None:

View file

@ -27,6 +27,7 @@
- Normalization rejects `document_query` inside `parallel` because document parsing and Q&A fan out into heavier worker/model paths that must run sequentially.
- `call_subordinate` jobs run in isolated child chat contexts tagged with parent-chat metadata; they must not be added to the scheduler task list and may use normal child-chat tools, including `parallel`.
- Direct tool jobs run in isolated background contexts and are blocked from recursively invoking `parallel`.
- Direct tool background context cleanup removes both the in-memory context and any transient chat folder left on disk.
- Parent-visible child log items are created for each wrapped call so the WebUI can inspect concurrent children separately while the wrapper result remains model-history-only.
- Child tool logs mirror normal tool-call visible args; job ids remain available through wrapper results and prompt extras rather than visible process-step args.
- Job IDs are stable handles for later await, collect, or cancel operations.

View file

@ -71,7 +71,9 @@ def load_tmp_chats():
folders = files.list_files(CHATS_FOLDER, "*")
json_files = []
for folder_name in folders:
json_files.append(_get_chat_file_path(folder_name))
chat_file = _get_chat_file_path(folder_name)
if files.exists(chat_file):
json_files.append(chat_file)
ctxids = []
for file in json_files:

View file

@ -42,6 +42,7 @@
- Imported dependency areas include: `agent`, `collections`, `datetime`, `helpers`, `helpers.localization`, `helpers.log`, `initialize`, `json`, `typing`, `uuid`.
- Serialized chats store `agent_profile` both at the context level for the main chat and on each serialized agent so subordinate profiles survive server restart.
- Deserialization must rebuild each agent with its serialized profile when present, falling back to the context profile for older chat files.
- Chat loading skips directories that do not contain `chat.json`; malformed existing chat files still report load errors.
## Key Concepts

View file

@ -262,6 +262,19 @@ async def test_parallel_cancel_still_stops_and_removes_running_jobs() -> None:
assert job.id not in agent.context.get_data(parallel_tools.PARALLEL_JOBS_KEY)
@pytest.mark.asyncio
async def test_parallel_remove_context_deletes_persisted_worker_chat(monkeypatch) -> None:
removed = []
from helpers import persist_chat
monkeypatch.setattr(persist_chat, "remove_chat", removed.append)
await parallel_tools._remove_context("missing-worker")
assert removed == ["missing-worker"]
@pytest.mark.asyncio
async def test_parallel_recursion_guard_allows_subordinate_children_but_blocks_tool_workers() -> None:
from extensions.python.tool_execute_before._20_block_parallel_recursion import (

View file

@ -1,5 +1,8 @@
from __future__ import annotations
import json
from types import SimpleNamespace
def test_deserialize_log_preserves_item_id() -> None:
from helpers.log import Log
@ -16,3 +19,37 @@ def test_deserialize_log_preserves_item_id() -> None:
assert restored.logs[0].id == "msg-123"
assert restored.logs[1].type == "assistant"
assert restored.logs[1].id is None
def test_load_tmp_chats_skips_directories_without_chat_json(monkeypatch, capsys) -> None:
from helpers import persist_chat
monkeypatch.setattr(persist_chat, "_convert_v080_chats", lambda: None)
monkeypatch.setattr(
persist_chat.files,
"get_abs_path",
lambda *parts: "/" + "/".join(str(part).strip("/") for part in parts if part),
)
monkeypatch.setattr(
persist_chat.files,
"list_files",
lambda folder, pattern="*": ["orphan", "valid"],
)
monkeypatch.setattr(
persist_chat.files,
"exists",
lambda path: str(path).endswith("/valid/chat.json"),
)
monkeypatch.setattr(
persist_chat.files,
"read_file",
lambda path: json.dumps({"id": "valid"}),
)
monkeypatch.setattr(
persist_chat,
"_deserialize_context",
lambda data: SimpleNamespace(id=data["id"]),
)
assert persist_chat.load_tmp_chats() == ["valid"]
assert "Error loading chat" not in capsys.readouterr().out

View file

@ -0,0 +1,22 @@
from types import SimpleNamespace
from agent import AgentContextType
from extensions.python.hist_add_tool_result._90_save_tool_call_file import SaveToolCallFile
def test_tool_result_file_persistence_skips_background_context(tmp_path, monkeypatch) -> None:
target = tmp_path / "messages"
agent = SimpleNamespace(
context=SimpleNamespace(id="background-worker", type=AgentContextType.BACKGROUND)
)
data = {"tool_result": "x" * 501}
monkeypatch.setattr(
"extensions.python.hist_add_tool_result._90_save_tool_call_file.persist_chat.get_chat_msg_files_folder",
lambda _ctxid: str(target),
)
SaveToolCallFile(agent=agent).execute(data=data)
assert "file" not in data
assert not target.exists()