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.

Alessandro committed Jun 19, 2026 at 08:11 UTC 78a50e1431734cdc287c9920e034018e287b0052
9 files changed +84 -1
extensions/python/hist_add_tool_result/AGENTS.md
+1
@@ -12,6 +12,7 @@
12
13 - Preserve tool result traceability without leaking secrets.
14 - Keep file artifacts inside expected runtime/user-owned paths.
15 +- Skip BACKGROUND contexts; background workers must remain ephemeral and must not create chat message files.
16
17 ## Work Guidance
18
extensions/python/hist_add_tool_result/_90_save_tool_call_file.py
+4
@@ -1,4 +1,5 @@
1 from typing import Any
2 +from agent import AgentContextType
3 from helpers.extension import Extension
4 from helpers import files, persist_chat
5 import os, re
@@ -9,6 +10,9 @@ class SaveToolCallFile(Extension):
10 def execute(self, data: dict[str, Any] | None = None, **kwargs):
11 if not self.agent:
12 return
13 +
14 + if self.agent.context.type == AgentContextType.BACKGROUND:
15 + return
16
17 if not data:
18 return
helpers/parallel_tools.py
+2
@@ -570,6 +570,7 @@ async def _remove_context(context_id: str | None) -> None:
570 if not context_id:
571 return
572 from agent import AgentContext
573 + from helpers import persist_chat
574
575 context = AgentContext.get(context_id)
576 if context:
@@ -578,6 +579,7 @@ async def _remove_context(context_id: str | None) -> None:
579 except Exception:
580 pass
581 AgentContext.remove(context_id)
582 + persist_chat.remove_chat(context_id)
583
584
585 def _log_parallel_child_started(agent: "Agent", job: ParallelJob) -> None:
helpers/parallel_tools.py.dox.md
+1
@@ -27,6 +27,7 @@
27 - Normalization rejects `document_query` inside `parallel` because document parsing and Q&A fan out into heavier worker/model paths that must run sequentially.
28 - `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`.
29 - Direct tool jobs run in isolated background contexts and are blocked from recursively invoking `parallel`.
30 +- Direct tool background context cleanup removes both the in-memory context and any transient chat folder left on disk.
31 - 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.
32 - 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.
33 - Job IDs are stable handles for later await, collect, or cancel operations.
helpers/persist_chat.py
+3 -1
@@ -71,7 +71,9 @@ def load_tmp_chats():
71 folders = files.list_files(CHATS_FOLDER, "*")
72 json_files = []
73 for folder_name in folders:
74 - json_files.append(_get_chat_file_path(folder_name))
74 + chat_file = _get_chat_file_path(folder_name)
75 + if files.exists(chat_file):
76 + json_files.append(chat_file)
77
78 ctxids = []
79 for file in json_files:
helpers/persist_chat.py.dox.md
+1
@@ -42,6 +42,7 @@
42 - Imported dependency areas include: `agent`, `collections`, `datetime`, `helpers`, `helpers.localization`, `helpers.log`, `initialize`, `json`, `typing`, `uuid`.
43 - 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.
44 - Deserialization must rebuild each agent with its serialized profile when present, falling back to the context profile for older chat files.
45 +- Chat loading skips directories that do not contain `chat.json`; malformed existing chat files still report load errors.
46
47 ## Key Concepts
48
tests/test_parallel_tool.py
+13
@@ -262,6 +262,19 @@ async def test_parallel_cancel_still_stops_and_removes_running_jobs() -> None:
262 assert job.id not in agent.context.get_data(parallel_tools.PARALLEL_JOBS_KEY)
263
264
265 +@pytest.mark.asyncio
266 +async def test_parallel_remove_context_deletes_persisted_worker_chat(monkeypatch) -> None:
267 + removed = []
268 +
269 + from helpers import persist_chat
270 +
271 + monkeypatch.setattr(persist_chat, "remove_chat", removed.append)
272 +
273 + await parallel_tools._remove_context("missing-worker")
274 +
275 + assert removed == ["missing-worker"]
276 +
277 +
278 @pytest.mark.asyncio
279 async def test_parallel_recursion_guard_allows_subordinate_children_but_blocks_tool_workers() -> None:
280 from extensions.python.tool_execute_before._20_block_parallel_recursion import (
tests/test_persist_chat_log_ids.py
+37
@@ -1,5 +1,8 @@
1 from __future__ import annotations
2
3 +import json
4 +from types import SimpleNamespace
5 +
6
7 def test_deserialize_log_preserves_item_id() -> None:
8 from helpers.log import Log
@@ -16,3 +19,37 @@ def test_deserialize_log_preserves_item_id() -> None:
19 assert restored.logs[0].id == "msg-123"
20 assert restored.logs[1].type == "assistant"
21 assert restored.logs[1].id is None
22 +
23 +
24 +def test_load_tmp_chats_skips_directories_without_chat_json(monkeypatch, capsys) -> None:
25 + from helpers import persist_chat
26 +
27 + monkeypatch.setattr(persist_chat, "_convert_v080_chats", lambda: None)
28 + monkeypatch.setattr(
29 + persist_chat.files,
30 + "get_abs_path",
31 + lambda *parts: "/" + "/".join(str(part).strip("/") for part in parts if part),
32 + )
33 + monkeypatch.setattr(
34 + persist_chat.files,
35 + "list_files",
36 + lambda folder, pattern="*": ["orphan", "valid"],
37 + )
38 + monkeypatch.setattr(
39 + persist_chat.files,
40 + "exists",
41 + lambda path: str(path).endswith("/valid/chat.json"),
42 + )
43 + monkeypatch.setattr(
44 + persist_chat.files,
45 + "read_file",
46 + lambda path: json.dumps({"id": "valid"}),
47 + )
48 + monkeypatch.setattr(
49 + persist_chat,
50 + "_deserialize_context",
51 + lambda data: SimpleNamespace(id=data["id"]),
52 + )
53 +
54 + assert persist_chat.load_tmp_chats() == ["valid"]
55 + assert "Error loading chat" not in capsys.readouterr().out
tests/test_tool_result_file_persistence.py new
+22
@@ -0,0 +1,22 @@
1 +from types import SimpleNamespace
2 +
3 +from agent import AgentContextType
4 +from extensions.python.hist_add_tool_result._90_save_tool_call_file import SaveToolCallFile
5 +
6 +
7 +def test_tool_result_file_persistence_skips_background_context(tmp_path, monkeypatch) -> None:
8 + target = tmp_path / "messages"
9 + agent = SimpleNamespace(
10 + context=SimpleNamespace(id="background-worker", type=AgentContextType.BACKGROUND)
11 + )
12 + data = {"tool_result": "x" * 501}
13 +
14 + monkeypatch.setattr(
15 + "extensions.python.hist_add_tool_result._90_save_tool_call_file.persist_chat.get_chat_msg_files_folder",
16 + lambda _ctxid: str(target),
17 + )
18 +
19 + SaveToolCallFile(agent=agent).execute(data=data)
20 +
21 + assert "file" not in data
22 + assert not target.exists()