feat(auto-agents): archive entire task-tool subagent tree before DELETE root

Before this change the dispatcher archived only the top-level
wrapper session. The entire ``task``-tool subagent chain
(tier-dispatcher → estimator-implementation / tier-qwen-med →
task-implementor → git-isolator-util) was opaque the moment
the dispatcher's DELETE /session/{id} fired, so post-mortem
analysis of an implementer run was limited to whatever
live-API polling we'd done DURING the run. That's how the
recent optimization round had to work from two cherry-picked
live snapshots of task-implementor and git-isolator-util —
unreliable, only what happened to be active when polled.

Three changes:

1. tools/_opencode_worker.py
   - New helpers: _walk_subagent_descendants (BFS over
     GET /session keyed on parentID), _extract_subagent_agent_name
     (parses OpenCode's "(@<agent> subagent)" title convention),
     _ms_to_iso (epoch-ms to ISO-8601), and _archive_subagent_tree
     (best-effort walk + fetch + write driver; never raises).
   - _archive_session / _build_archive_payload gain optional
     parent_session_id / subagent_title / subagent_depth kwargs.
     When set, the filename includes a ``sub<depth>`` infix
     (e.g. 2026-...__sub01__AUTO-IMP-PR-30__tier-dispatcher__ses_*.json)
     so a directory listing groups every session from one
     dispatcher cycle and reads top-down in BFS order.
   - Archive schema bumped from v1 → v2. New fields are nullable;
     v1 readers (the existing telemetry-console endpoints) treat
     them as missing and remain forward-compatible.
   - run_session_blocking's finally block calls
     _archive_subagent_tree after the root archive write and
     before the root DELETE. Both calls are wrapped in
     try/except so a subagent-walk failure can never mask the
     worker outcome or stop the dispatcher from cleaning up.
   - The dispatcher's existing redact_values list (the Forgejo
     PAT) propagates into every subagent archive too, so a
     ``git clone https://${PAT}@...`` in git-isolator-util's
     bash history is masked the same way the wrapper's prompt is.

2. .opencode/telemetry/server.py
   - _api_archived_sessions listing endpoint now surfaces the
     three v2 fields (schema_version, parent_session_id,
     subagent_title, subagent_depth) in each row payload so a
     future UI render can nest subagents under their wrapper.
     Additive — existing row keys are preserved.

3. tests/auto_agents/test_opencode_worker_observability.py
   - 17 new tests across four classes:
     - TestSubagentTitleExtraction (5): title parser edge cases
     - TestWalkSubagentDescendants (6): BFS order, depth
       annotation, transport-error / malformed-payload paths,
       cycle safety
     - TestArchiveSubagentTree (5): end-to-end orchestration
       including a redaction-propagation test that asserts a
       PAT inside a subagent's bash tool input is replaced
       with <REDACTED>
     - TestEndToEndSubagentArchive (1): drives the full
       run_session_blocking lifecycle with a wired subagent
       descendant and asserts BOTH archives land on disk
   - Existing schema-version assertion updated to v2 + three
     new ``None``-on-top-level field assertions.
   - Two manually-wired archive tests (transport-error,
     timeout) now wire GET /session so the walker doesn't emit
     a spurious warning.

Total auto_agents suite: 1061 passed, 3 skipped (up from 1044).

This is the prerequisite for trustworthy quantification of the
upcoming default-flip of IMPLEMENTER_DISPATCHER_PREFETCH=1 and
IMPLEMENTER_DISPATCHER_PRECLONE=1. With the walker in place,
every cycle now leaves a complete trace on disk that a human
can read bottom-up months later.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-10 21:21:01 -04:00
parent 8af5924db2
commit dc96848174
4 changed files with 907 additions and 8 deletions
+11
View File
@@ -869,6 +869,17 @@ def _api_archived_sessions() -> dict[str, Any]:
"wallclock_seconds": data.get("wallclock_seconds"),
"message_count": len(data.get("messages") or []),
"turn_count": len(per_turn) if isinstance(per_turn, list) else 0,
# v2 (2026-05-10) fields: present for subagent archives,
# ``None`` for top-level wrapper archives. The UI can use
# ``parent_session_id`` to group every session from one
# dispatcher cycle into a single tree row, and
# ``subagent_depth`` to nest the table view. Old (v1)
# archives lack these keys; ``.get`` returns None so the
# row schema stays uniform.
"schema_version": data.get("schema_version"),
"parent_session_id": data.get("parent_session_id"),
"subagent_title": data.get("subagent_title"),
"subagent_depth": data.get("subagent_depth"),
}
)
# Newest archive first. Use ``modified_at`` since it is ISO-formatted
+90
View File
@@ -5,6 +5,96 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added
- **Subagent session archive walker (2026-05-10).** Before this
change the dispatcher archived only the top-level wrapper
session — the entire ``task``-tool subagent chain
(``tier-dispatcher`` → ``estimator-implementation`` /
``tier-qwen-med`` → ``task-implementor`` → ``git-isolator-util``)
was opaque the moment the dispatcher's ``DELETE /session/{id}``
fired. The post-mortem analysis of an implementer run was
therefore limited to whatever live-API polling we'd done DURING
the run, which is unreliable and only catches the agents that
happened to be active when we polled.
The wrapper's ``run_session_blocking`` ``finally`` block now
walks ``GET /session`` BFS-from-the-root after the root archive
is written and BEFORE the root ``DELETE``. Every descendant
session — at any depth — has its full ``/session/{id}/message``
stream fetched and written to a per-descendant archive file.
The schema is the same as the wrapper archive (so the existing
telemetry-console endpoints render them) with three new
optional fields:
- ``parent_session_id`` — wrapper's ``session_id`` for direct
children; the immediate parent's id for deeper nodes.
- ``subagent_title`` — OpenCode's full session title (e.g.
``"Dispatch implementation to tier-dispatcher (@tier-dispatcher subagent)"``).
- ``subagent_depth`` — 1-based distance from the wrapper.
Filenames embed a ``sub<depth>`` infix
(``<started_at>__sub01__<tag>__<agent>__<session_id>.json``) so a
``ls -1 .dispatcher-logs/sessions`` listing groups every session
from one dispatcher cycle and reads top-down in BFS order. The
schema version is bumped to **v2**; v1 readers see ``None`` for
the three new fields and remain forward-compatible (no breaking
change for the telemetry UI). The redaction list passed by the
dispatcher (the Forgejo PAT) propagates into every subagent
archive too, so a ``git clone https://${PAT}@...`` command in
``git-isolator-util``'s bash history is masked the same way the
wrapper's prompt is.
Concretely:
- ``tools/_opencode_worker.py`` gains
``_walk_subagent_descendants(server_url, root_session_id)``
(BFS over ``GET /session`` keyed on ``parentID``, returns one
dict per descendant with an added ``_subagent_depth`` field),
``_extract_subagent_agent_name(title)`` (regex on the
OpenCode title convention with a ``unknown-subagent``
fallback), ``_ms_to_iso(ms)`` (epoch-ms → ISO-8601 helper),
and ``_archive_subagent_tree(...)`` (best-effort
walk + fetch + write driver; never raises, logs and
continues on per-descendant transport failure).
- ``_archive_session`` and ``_build_archive_payload`` gain
optional ``parent_session_id`` / ``subagent_title`` /
``subagent_depth`` kwargs. When set, the filename includes
the ``sub<depth>`` infix; when unset, the legacy filename
pattern is preserved byte-for-byte.
- ``run_session_blocking``'s ``finally`` block invokes
``_archive_subagent_tree`` after the root archive write and
before the root ``DELETE``. Both calls are wrapped in
``try/except BaseException`` so a subagent-walk failure can
never mask the worker outcome or stop the
dispatcher from cleaning up.
- ``.opencode/telemetry/server.py``'s ``_api_archived_sessions``
listing endpoint now surfaces the three v2 fields in the row
payload so a future UI render can nest subagents under their
wrapper. The existing row shape is preserved (additive only).
- **17 new tests** in ``test_opencode_worker_observability.py``:
``TestSubagentTitleExtraction`` (5) covers the title parser,
``TestWalkSubagentDescendants`` (6) covers BFS order, depth
annotation, transport-error / malformed-payload paths, and
cycle safety, ``TestArchiveSubagentTree`` (5) covers
end-to-end orchestration including a redaction-propagation
test that asserts a PAT inside a subagent's
``bash`` tool input is replaced with ``<REDACTED>``, and
``TestEndToEndSubagentArchive`` (1) drives the full
``run_session_blocking`` lifecycle with a wired subagent
descendant and asserts both archives land on disk.
Total auto_agents suite: **1061 passed, 3 skipped** (up from
1044).
Why this matters: the prior post-mortem analysis recommendations
(default-flip ``IMPLEMENTER_DISPATCHER_PREFETCH=1`` etc.) all
rested on live-poll snapshots of two specific subagents I
happened to capture mid-run. With the walker in place, every
cycle leaves a complete trace on disk that a human can read
bottom-up months later, and the telemetry console can render
the wrapper → tier-dispatcher → task-implementor →
git-isolator-util tree as one navigation context.
### Changed
- **Tighten `implementation-worker.md` skill allowlist and Step 0
@@ -115,6 +115,15 @@ def _wire_lifecycle_with_statuses(
httpserver.expect_request(
f"/session/{session_id}/message", method="GET"
).respond_with_json(msg_payload)
# Subagent descendant walk (added 2026-05-10): _walk_subagent_descendants
# issues GET /session in the run_session_blocking finally block to find
# any subagent sessions that need archiving. By default the test wires
# this to return ONLY the root session (no descendants), so existing
# tests are subagent-archive-clean. Tests that exercise the subagent
# path should ``httpserver.clear()`` and re-wire with descendants.
httpserver.expect_request("/session", method="GET").respond_with_json(
[_session_obj(session_id)]
)
httpserver.expect_request(
f"/session/{session_id}", method="DELETE"
).respond_with_data("", status=200)
@@ -566,7 +575,7 @@ def test_archive_completed_session_writes_payload_with_schema(
assert len(files) == 1, f"expected one archive file, got {files}"
payload = json.loads(files[0].read_text())
assert payload["schema_version"] == 1
assert payload["schema_version"] == 2
assert payload["session_id"] == "s-obs-1"
assert payload["agent"] == "test-agent"
assert payload["tag"] == "AUTO-OBS"
@@ -581,6 +590,10 @@ def test_archive_completed_session_writes_payload_with_schema(
assert payload["state_history"][0]["state"] in {"busy", "idle", "unknown"}
assert isinstance(payload["messages"], list)
assert payload["messages"][-1]["info"]["role"] == "assistant"
# v2 subagent-marker fields default to None on a top-level archive.
assert payload["parent_session_id"] is None
assert payload["subagent_title"] is None
assert payload["subagent_depth"] is None
def test_archive_transport_error_path_still_writes_payload(
@@ -614,6 +627,13 @@ def test_archive_transport_error_path_still_writes_payload(
}
]
)
# Subagent walker fires in finally{} on every terminal path including
# transport-error. Wire it to return ONLY the root (no descendants)
# so the warning-on-transport-error path isn't muddied with subagent
# walk noise.
httpserver.expect_request("/session", method="GET").respond_with_json(
[_session_obj()]
)
httpserver.expect_request(
"/session/s-obs-1", method="DELETE"
).respond_with_data("")
@@ -675,6 +695,10 @@ def test_archive_timeout_path_still_writes_payload(
}
]
)
# Subagent walker fires on timeout too — return only the root.
httpserver.expect_request("/session", method="GET").respond_with_json(
[_session_obj()]
)
httpserver.expect_request(
"/session/s-obs-1", method="DELETE"
).respond_with_data("")
@@ -1072,3 +1096,499 @@ class TestRunSessionRedactsArchive:
for rec in caplog.records
)
assert short in body
# ─── Subagent archive walker (2026-05-10) ───────────────────────────────────
class TestSubagentTitleExtraction:
"""``_extract_subagent_agent_name`` parses the ``(@<agent> subagent)``
suffix OpenCode appends to subagent session titles. The archive
walker relies on this to populate the ``agent`` field of the
subagent archive filename, so the parsing has to be tolerant of
the OpenCode title conventions we've actually seen on live runs.
"""
def test_extracts_basic_agent_name(self, mod):
title = "Dispatch implementation to tier-dispatcher (@tier-dispatcher subagent)"
assert mod._extract_subagent_agent_name(title) == "tier-dispatcher"
def test_handles_dotted_agent_name(self, mod):
title = "Foo (@some.dotted.agent subagent)"
assert mod._extract_subagent_agent_name(title) == "some.dotted.agent"
def test_handles_trailing_whitespace(self, mod):
title = "Foo (@bar subagent) "
assert mod._extract_subagent_agent_name(title) == "bar"
def test_returns_sentinel_for_non_subagent_title(self, mod):
# Top-level sessions don't carry the ``(@... subagent)`` suffix;
# the walker is only called with descendants, but defensive
# handling means a misclassified record still archives.
assert mod._extract_subagent_agent_name("[AUTO-IMP] foo") == "unknown-subagent"
def test_returns_sentinel_for_empty(self, mod):
assert mod._extract_subagent_agent_name("") == "unknown-subagent"
assert mod._extract_subagent_agent_name(None) == "unknown-subagent"
class TestWalkSubagentDescendants:
"""``_walk_subagent_descendants`` walks the ``/session`` payload
BFS from a root session id and returns every descendant. Coverage:
(a) BFS order, (b) depth annotation, (c) transport error path,
(d) malformed payload path, (e) cycle-safety (just in case).
"""
def _wire_sessions(self, httpserver, sessions: list[dict]):
httpserver.expect_request("/session", method="GET").respond_with_json(
sessions
)
def test_returns_empty_for_no_descendants(self, mod, httpserver):
self._wire_sessions(
httpserver,
[{"id": "root", "parentID": "", "title": "[AUTO] root"}],
)
out = mod._walk_subagent_descendants(_server_url(httpserver), "root")
assert out == []
def test_collects_two_level_chain_in_bfs_order(self, mod, httpserver):
# root -> child1 -> grandchild; root -> child2.
# BFS order: child1, child2, grandchild.
self._wire_sessions(
httpserver,
[
{"id": "root", "parentID": "", "title": "root", "time": {"created": 1}},
{
"id": "child1",
"parentID": "root",
"title": "Call A (@a subagent)",
"time": {"created": 10},
},
{
"id": "child2",
"parentID": "root",
"title": "Call B (@b subagent)",
"time": {"created": 20},
},
{
"id": "grandchild",
"parentID": "child1",
"title": "Inner (@inner subagent)",
"time": {"created": 30},
},
],
)
out = mod._walk_subagent_descendants(_server_url(httpserver), "root")
ids = [s["id"] for s in out]
assert ids == ["child1", "child2", "grandchild"]
depths = [s["_subagent_depth"] for s in out]
assert depths == [1, 1, 2]
def test_orders_siblings_by_creation_time(self, mod, httpserver):
# Two siblings created out-of-order in the /session response;
# BFS within a depth level should still be creation-time
# ordered so the trace reads left-to-right by clock.
self._wire_sessions(
httpserver,
[
{"id": "root", "parentID": "", "title": "root"},
{
"id": "later",
"parentID": "root",
"title": "Later (@x subagent)",
"time": {"created": 999},
},
{
"id": "earlier",
"parentID": "root",
"title": "Earlier (@x subagent)",
"time": {"created": 1},
},
],
)
out = mod._walk_subagent_descendants(_server_url(httpserver), "root")
assert [s["id"] for s in out] == ["earlier", "later"]
def test_returns_empty_on_transport_error(self, mod, httpserver):
# No /session route wired; httpserver returns 500 for the
# unmatched request and _request raises HTTPError which the
# walker catches.
out = mod._walk_subagent_descendants(_server_url(httpserver), "root")
assert out == []
def test_returns_empty_on_non_list_payload(self, mod, httpserver):
httpserver.expect_request("/session", method="GET").respond_with_json(
{"unexpected": "shape"}
)
out = mod._walk_subagent_descendants(_server_url(httpserver), "root")
assert out == []
def test_handles_cycle_without_infinite_loop(self, mod, httpserver):
# Defensive: OpenCode should never report a cycle, but the
# walker should terminate even if it ever does. Each id is
# only visited once thanks to the ``seen`` set.
self._wire_sessions(
httpserver,
[
{"id": "root", "parentID": "loop-end", "title": "root"},
{
"id": "loop-mid",
"parentID": "root",
"title": "Mid (@x subagent)",
"time": {"created": 1},
},
{
"id": "loop-end",
"parentID": "loop-mid",
"title": "End (@x subagent)",
"time": {"created": 2},
},
],
)
out = mod._walk_subagent_descendants(_server_url(httpserver), "root")
# Only ``loop-mid`` and ``loop-end`` get visited; the back-edge
# to ``root`` is blocked by the seen-set so we don't recurse.
ids = [s["id"] for s in out]
assert ids == ["loop-mid", "loop-end"]
class TestArchiveSubagentTree:
"""``_archive_subagent_tree`` orchestrates: walk → fetch each
descendant's messages → write archive per descendant. Coverage:
(a) writes one file per descendant, (b) filename infix carries
``sub<depth>``, (c) payload v2 fields populated, (d) parent's tag
+ started_at inherited, (e) per-descendant fetch failure logs and
continues.
These tests call ``_archive_subagent_tree`` directly (i.e. they
bypass ``run_session_blocking`` and therefore bypass
``_resolve_archive_dir``). Because of that we have to create the
archive directory ourselves the autouse fixture only sets the
env var; ``_resolve_archive_dir`` is what calls ``mkdir`` on it.
"""
@pytest.fixture(autouse=True)
def _ensure_archive_dir_exists(self, archive_dir):
archive_dir.mkdir(parents=True, exist_ok=True)
def _wire(self, httpserver, descendants: list[dict], messages_for: dict[str, list]):
# Root session is always returned by /session GET — the walker
# uses it as a no-op (excludes root from descendants).
all_sessions = [{"id": "root", "parentID": "", "title": "root"}] + descendants
httpserver.expect_request("/session", method="GET").respond_with_json(
all_sessions
)
for sid, msgs in messages_for.items():
httpserver.expect_request(
f"/session/{sid}/message", method="GET"
).respond_with_json(msgs)
def test_writes_one_archive_per_descendant(
self, mod, httpserver, archive_dir
):
descendants = [
{
"id": "child1",
"parentID": "root",
"title": "Dispatch (@tier-dispatcher subagent)",
"time": {"created": 1_000_000_000_000, "updated": 1_000_000_005_000},
},
{
"id": "child2",
"parentID": "root",
"title": "Estimate (@estimator-implementation subagent)",
"time": {"created": 1_000_000_001_000, "updated": 1_000_000_003_500},
},
]
messages_for = {
"child1": [
{
"info": {
"id": "m1",
"role": "assistant",
"time": {"created": 1, "completed": 2},
"tokens": {"input": 10, "output": 5},
},
"parts": [{"type": "text", "text": "child1 said hi"}],
}
],
"child2": [
{
"info": {
"id": "m2",
"role": "assistant",
"time": {"created": 1, "completed": 2},
"tokens": {"input": 20, "output": 10},
},
"parts": [{"type": "text", "text": "child2 said hi"}],
}
],
}
self._wire(httpserver, descendants, messages_for)
paths = mod._archive_subagent_tree(
archive_dir=archive_dir,
server_url=_server_url(httpserver),
root_session_id="root",
root_tag="AUTO-IMP-PR-99",
root_started_at_iso="2026-05-10T00:00:00+00:00",
)
assert len(paths) == 2
# Files exist on disk and parse.
for p in paths:
assert p.exists()
payload = json.loads(p.read_text())
assert payload["schema_version"] == 2
assert payload["parent_session_id"] == "root"
assert payload["tag"] == "AUTO-IMP-PR-99"
assert payload["subagent_depth"] == 1
assert payload["status"] == "subagent"
assert payload["subagent_title"].startswith(("Dispatch", "Estimate"))
def test_filename_contains_sub_depth_infix(
self, mod, httpserver, archive_dir
):
descendants = [
{
"id": "child1",
"parentID": "root",
"title": "X (@agent-a subagent)",
"time": {"created": 1_000_000_000_000, "updated": 1_000_000_001_000},
}
]
messages_for = {"child1": []}
self._wire(httpserver, descendants, messages_for)
paths = mod._archive_subagent_tree(
archive_dir=archive_dir,
server_url=_server_url(httpserver),
root_session_id="root",
root_tag="AUTO-IMP-PR-99",
root_started_at_iso="2026-05-10T00:00:00+00:00",
)
assert len(paths) == 1
# Filename layout: <started_at>__sub01__<tag>__<agent>__<sid>.json
name = paths[0].name
assert "__sub01__" in name
assert "__AUTO-IMP-PR-99__" in name
assert "__agent-a__" in name
assert name.endswith("__child1.json")
def test_continues_after_per_descendant_fetch_failure(
self, mod, httpserver, archive_dir, caplog
):
# Wire two descendants but only respond to one's /message GET
# — the other returns 500 and the walker must log + continue.
descendants = [
{
"id": "ok",
"parentID": "root",
"title": "OK (@ok-agent subagent)",
"time": {"created": 1_000_000_000_000, "updated": 1_000_000_001_000},
},
{
"id": "broken",
"parentID": "root",
"title": "Broken (@broken-agent subagent)",
"time": {"created": 1_000_000_002_000, "updated": 1_000_000_003_000},
},
]
messages_for = {"ok": []} # ``broken`` deliberately omitted
self._wire(httpserver, descendants, messages_for)
import logging
caplog.set_level(logging.WARNING, logger="opencode_worker")
paths = mod._archive_subagent_tree(
archive_dir=archive_dir,
server_url=_server_url(httpserver),
root_session_id="root",
root_tag="AUTO-IMP-PR-99",
root_started_at_iso="2026-05-10T00:00:00+00:00",
)
# Only ``ok`` archives; ``broken`` is skipped with a warning.
assert len(paths) == 1
assert any(
"subagent archive: skipping broken" in rec.getMessage()
for rec in caplog.records
)
def test_redact_values_propagate_to_subagent_archive(
self, mod, httpserver, archive_dir
):
# The PAT in a subagent's tool input must also be redacted —
# the dispatcher passes redact_values=[cfg.token] through.
secret = "FORGEJO-PAT-LONG-SECRET-VALUE-NEVER-COMMIT-3"
descendants = [
{
"id": "child1",
"parentID": "root",
"title": "Clone (@git-isolator-util subagent)",
"time": {"created": 1_000_000_000_000, "updated": 1_000_000_001_000},
}
]
messages_for = {
"child1": [
{
"info": {
"id": "m1",
"role": "assistant",
"time": {"created": 1, "completed": 2},
"tokens": {"input": 10, "output": 0},
},
"parts": [
{
"type": "tool",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"command": (
f"git clone https://{secret}@host/x.git /tmp/x"
)
},
},
}
],
}
]
}
self._wire(httpserver, descendants, messages_for)
paths = mod._archive_subagent_tree(
archive_dir=archive_dir,
server_url=_server_url(httpserver),
root_session_id="root",
root_tag="AUTO-IMP-PR-99",
root_started_at_iso="2026-05-10T00:00:00+00:00",
redact_values=[secret],
)
assert len(paths) == 1
body = paths[0].read_text()
assert secret not in body, "secret leaked into subagent archive"
assert "<REDACTED>" in body
def test_returns_empty_when_no_descendants_to_walk(
self, mod, httpserver, archive_dir
):
# Only the root in /session response → nothing to archive.
httpserver.expect_request("/session", method="GET").respond_with_json(
[{"id": "root", "parentID": "", "title": "root"}]
)
paths = mod._archive_subagent_tree(
archive_dir=archive_dir,
server_url=_server_url(httpserver),
root_session_id="root",
root_tag="AUTO-IMP-PR-99",
root_started_at_iso="2026-05-10T00:00:00+00:00",
)
assert paths == []
assert list(archive_dir.glob("*.json")) == []
class TestEndToEndSubagentArchive:
"""End-to-end: a real ``run_session_blocking`` call that emits a
subagent descendant in /session both archives land on disk."""
def test_run_session_archives_root_and_subagents(
self, mod, httpserver, archive_dir
):
# We deliberately do NOT use ``_wire_lifecycle_with_statuses``
# here because that helper wires its own ``/session`` GET
# (returning only the root) and pytest-httpserver matches
# ``expect_request`` registrations in FIFO order — the helper's
# registration would win over our descendant-bearing override.
# Wire the full lifecycle by hand instead so the walker sees
# the subagent we care about.
httpserver.expect_request("/session", method="POST").respond_with_json(
_session_obj("s-obs-1")
)
httpserver.expect_request(
"/session/s-obs-1/prompt_async", method="POST"
).respond_with_json({})
# status sequence: busy then idle
httpserver.expect_oneshot_request(
"/session/status", method="GET"
).respond_with_json({"s-obs-1": {"type": "busy"}})
httpserver.expect_oneshot_request(
"/session/status", method="GET"
).respond_with_json({"s-obs-1": {"type": "idle"}})
httpserver.expect_request(
"/session/s-obs-1/message", method="GET"
).respond_with_json(
[
{
"info": {
"id": "m-final",
"role": "assistant",
"time": {"created": 1000, "completed": 5000},
"tokens": {"input": 100, "output": 20, "reasoning": 5},
},
"parts": [
{
"type": "text",
"text": '{"outcome":"resolved","files_touched":[]}',
}
],
}
]
)
# ``/session`` GET returns BOTH the root and one subagent
# descendant. The walker excludes the root and archives the
# subagent.
httpserver.expect_request("/session", method="GET").respond_with_json(
[
_session_obj("s-obs-1"),
{
"id": "ses-sub-1",
"parentID": "s-obs-1",
"title": "Dispatch (@tier-dispatcher subagent)",
"time": {
"created": 1_000_000_000_000,
"updated": 1_000_000_001_000,
},
},
]
)
# The walker fetches the descendant's /message — wire it.
httpserver.expect_request(
"/session/ses-sub-1/message", method="GET"
).respond_with_json(
[
{
"info": {
"id": "sm1",
"role": "assistant",
"time": {"created": 0, "completed": 1},
"tokens": {"input": 5, "output": 1},
},
"parts": [{"type": "text", "text": "sub said done"}],
}
]
)
httpserver.expect_request(
"/session/s-obs-1", method="DELETE"
).respond_with_data("")
result = mod.run_session_blocking(
server_url=_server_url(httpserver),
agent="test-agent",
tag="AUTO-OBS",
prompt="x",
timeout_seconds=30,
poll_interval_seconds=0.01,
)
assert result.status == "completed"
files = sorted(archive_dir.glob("*.json"))
# One root + one subagent.
assert len(files) == 2
names = [p.name for p in files]
# Sorting puts ``2026-...`` (root, started_at first) before
# ``<iso of sub started_at>``. The depth-1 infix appears in
# the subagent's filename.
assert any("__sub01__" in n for n in names)
# Validate the subagent payload.
sub_file = next(p for p in files if "__sub01__" in p.name)
sub_payload = json.loads(sub_file.read_text())
assert sub_payload["schema_version"] == 2
assert sub_payload["parent_session_id"] == "s-obs-1"
assert sub_payload["agent"] == "tier-dispatcher"
assert sub_payload["tag"] == "AUTO-OBS"
assert sub_payload["subagent_depth"] == 1
+285 -7
View File
@@ -535,7 +535,29 @@ _ARCHIVE_DISABLED_ENV = "OPENCODE_WORKER_ARCHIVE_DISABLED"
# Schema version for the archive payload. Bump when the on-disk JSON
# shape changes; the telemetry console reads this to decide which
# fields to render.
_ARCHIVE_SCHEMA_VERSION = 1
#
# v1 (initial) — top-level wrapper sessions only; carries
# session_id, agent, tag, status, started_at,
# archived_at, wallclock_seconds, per_turn,
# state_history, messages.
# v2 (2026-05-10) — adds optional ``parent_session_id``,
# ``subagent_title``, and ``subagent_depth`` fields so
# the dispatcher can archive the entire ``task`` tool
# subagent tree before deleting the root session. The
# first three fields are absent / null on top-level
# (wrapper) archives so a v1 reader treating them as
# missing is forward-compatible.
_ARCHIVE_SCHEMA_VERSION = 2
# Pattern that matches the trailing ``(@<agent> subagent)`` suffix
# OpenCode appends to a subagent session's title (e.g.
# ``"Implement PR fix #30 (@task-implementor subagent)"``). The capture
# group is the agent name and is what we use as the ``agent`` field in
# the subagent archive payload. If the title does not match — which
# would mean OpenCode changed its title convention or this is a
# manually-titled top-level session — we fall back to a sentinel string
# so the archive still lands on disk.
_SUBAGENT_TITLE_RE = re.compile(r"\(@([\w.-]+)\s+subagent\)\s*$")
def _resolve_archive_dir() -> Path | None:
@@ -590,16 +612,28 @@ def _build_archive_payload(
wallclock_seconds: float,
messages: list[dict[str, Any]],
state_history: list[dict[str, Any]] | None = None,
parent_session_id: str | None = None,
subagent_title: str | None = None,
subagent_depth: int | None = None,
) -> dict[str, Any]:
"""Build the JSON-serialisable archive payload for a session.
Pulled out into a helper so :func:`_archive_session` and the
test suite share the same shape; the on-disk schema is therefore
derivable from this function alone.
``parent_session_id`` / ``subagent_title`` / ``subagent_depth`` are
populated only for subagent archives (sessions spawned by a parent's
``task`` tool call). For the top-level wrapper session they remain
``None`` and serialise as ``null`` so a downstream reader can tell
the two apart without checking the schema version.
"""
return {
"schema_version": _ARCHIVE_SCHEMA_VERSION,
"session_id": session_id,
"parent_session_id": parent_session_id,
"subagent_title": subagent_title,
"subagent_depth": subagent_depth,
"agent": agent,
"tag": tag,
"status": status,
@@ -667,6 +701,9 @@ def _archive_session(
messages: list[dict[str, Any]],
state_history: list[dict[str, Any]] | None = None,
redact_values: Iterable[str] | None = None,
parent_session_id: str | None = None,
subagent_title: str | None = None,
subagent_depth: int | None = None,
) -> Path | None:
"""Write the session archive JSON file. Returns the resolved path
on success, or ``None`` on any I/O / serialisation failure.
@@ -677,13 +714,25 @@ def _archive_session(
disk. The redaction runs on the serialised JSON so it catches the
value in any nested location (prompt text, tool input, error
string). See :func:`_redact_secret_values` for the safety floor.
``parent_session_id`` / ``subagent_title`` / ``subagent_depth`` are
forwarded into the payload to support subagent archiving (see
:func:`_archive_subagent_tree`). For top-level (wrapper) archives
they should remain ``None``.
"""
fname = (
f"{_safe_filename_component(started_at_iso)}__"
f"{_safe_filename_component(tag)}__"
f"{_safe_filename_component(agent)}__"
f"{_safe_filename_component(session_id)}.json"
)
fname_parts = [
_safe_filename_component(started_at_iso),
_safe_filename_component(tag),
_safe_filename_component(agent),
_safe_filename_component(session_id),
]
# Subagent archives get a ``sub<depth>`` infix so a directory listing
# sorts the wrapper first, then its subagents in BFS order
# (sub1 before sub2 before sub3...). This makes ``ls -1`` a usable
# post-mortem trace tool without needing the telemetry console.
if subagent_depth is not None:
fname_parts.insert(1, f"sub{subagent_depth:02d}")
fname = "__".join(fname_parts) + ".json"
path = archive_dir / fname
payload = _build_archive_payload(
session_id=session_id,
@@ -694,6 +743,9 @@ def _archive_session(
wallclock_seconds=wallclock_seconds,
messages=messages,
state_history=state_history,
parent_session_id=parent_session_id,
subagent_title=subagent_title,
subagent_depth=subagent_depth,
)
try:
# Render with a default=str so anything urllib hands back that
@@ -708,6 +760,212 @@ def _archive_session(
return path
def _walk_subagent_descendants(
server_url: str,
root_session_id: str,
) -> list[dict[str, Any]]:
"""Walk OpenCode's ``GET /session`` response BFS starting from
``root_session_id`` and return every descendant session record.
The returned list excludes ``root_session_id`` itself and is sorted
by depth-then-creation-time, which matches the natural order a
human reads a trace ("the wrapper called tier-dispatcher first,
which called the estimator, which called"). Each record is the
raw session dict from OpenCode with one added field:
``_subagent_depth`` (1-based distance from the root).
Returns ``[]`` on any transport error or unexpected response
shape the caller's archive flow must continue regardless, so we
never raise here. A short warning is logged so a missed walk is
visible in the dispatcher log.
"""
try:
all_sessions = _request("GET", f"{server_url}/session")
except _TRANSPORT_EXC as e:
logger.warning(
"subagent walk failed (transport): %s; archiving root only", e
)
return []
if not isinstance(all_sessions, list):
logger.warning(
"subagent walk: /session returned unexpected shape "
"(%s); archiving root only",
type(all_sessions).__name__,
)
return []
# parent_id -> [child_session_dict, ...]
children_by_parent: dict[str, list[dict[str, Any]]] = {}
for s in all_sessions:
if not isinstance(s, dict):
continue
pid = s.get("parentID") or s.get("parent_id") or ""
if not pid:
continue
children_by_parent.setdefault(pid, []).append(s)
# Sort children by creation time so the trace order is stable.
for kids in children_by_parent.values():
kids.sort(
key=lambda k: (k.get("time") or {}).get("created", 0) or 0
)
descendants: list[dict[str, Any]] = []
seen: set[str] = {root_session_id}
# BFS frontier: list of (session_id, depth).
frontier: list[tuple[str, int]] = [(root_session_id, 0)]
while frontier:
parent_id, parent_depth = frontier.pop(0)
for child in children_by_parent.get(parent_id, []):
cid = child.get("id")
if not isinstance(cid, str) or cid in seen:
continue
seen.add(cid)
depth = parent_depth + 1
child_with_depth = dict(child)
child_with_depth["_subagent_depth"] = depth
descendants.append(child_with_depth)
frontier.append((cid, depth))
return descendants
def _extract_subagent_agent_name(title: str | None) -> str:
"""Return the ``agent`` name from a subagent session title.
Subagent sessions in OpenCode are titled
``"<some description> (@<agent-name> subagent)"`` see
:data:`_SUBAGENT_TITLE_RE`. Returns ``"unknown-subagent"`` if the
title is missing or does not match the convention, so the archive
still lands on disk with a useful (if generic) filename component.
"""
if not isinstance(title, str) or not title:
return "unknown-subagent"
m = _SUBAGENT_TITLE_RE.search(title)
if not m:
return "unknown-subagent"
return m.group(1)
def _ms_to_iso(ms: int | float | None) -> str | None:
"""Convert a Unix-epoch millisecond timestamp to an ISO-8601
UTC string, or return ``None`` if ``ms`` is not a usable number.
OpenCode's ``/session`` payload reports ``time.created`` /
``time.updated`` in milliseconds since the epoch. We carry that
forward into the archive as an ISO string so the on-disk format
matches the wrapper archive's ``started_at`` field.
"""
if ms is None:
return None
try:
seconds = float(ms) / 1000.0
except (TypeError, ValueError):
return None
return _dt.datetime.fromtimestamp(
seconds, tz=_dt.timezone.utc
).isoformat()
def _archive_subagent_tree(
*,
archive_dir: Path,
server_url: str,
root_session_id: str,
root_tag: str,
root_started_at_iso: str,
redact_values: Iterable[str] | None = None,
) -> list[Path]:
"""Walk every subagent descendant of ``root_session_id``, fetch
its message stream, and write an archive file per session.
Returns the list of archive paths that were written (in BFS order).
Always runs best-effort: a transport error on any single
subagent's message fetch logs a warning and continues to the next
descendant. The caller MUST invoke this BEFORE deleting the root
session OpenCode may garbage-collect subagent sessions when the
root is removed.
``root_tag`` and ``root_started_at_iso`` are inherited so a
directory listing groups every session from one dispatcher cycle
together. The subagent's own per-session ``time.created`` is
captured under the new ``subagent_started_at`` slot for the
timeline view in the telemetry console.
"""
descendants = _walk_subagent_descendants(server_url, root_session_id)
if not descendants:
return []
paths: list[Path] = []
for desc in descendants:
sid = desc.get("id")
if not isinstance(sid, str) or not sid:
continue
title = desc.get("title") or ""
agent_name = _extract_subagent_agent_name(title)
depth = int(desc.get("_subagent_depth") or 1)
time_info = desc.get("time") or {}
created_ms = time_info.get("created")
updated_ms = time_info.get("updated")
sub_started_iso = _ms_to_iso(created_ms) or root_started_at_iso
sub_wallclock = 0.0
if isinstance(created_ms, (int, float)) and isinstance(
updated_ms, (int, float)
):
sub_wallclock = max(
0.0, (float(updated_ms) - float(created_ms)) / 1000.0
)
try:
messages = _request(
"GET", f"{server_url}/session/{sid}/message"
)
except _TRANSPORT_EXC as e:
logger.warning(
"subagent archive: skipping %s (%s) — transport: %s",
sid, agent_name, e,
)
continue
if not isinstance(messages, list):
logger.warning(
"subagent archive: skipping %s (%s) — unexpected "
"messages shape %s",
sid, agent_name, type(messages).__name__,
)
continue
# Inherit the parent's tag so a ``ls -1 .dispatcher-logs/sessions``
# listing groups every session from this dispatcher cycle
# together. The subagent's own creation time goes into
# ``subagent_started_at`` inside the payload.
try:
written = _archive_session(
archive_dir=archive_dir,
session_id=sid,
agent=agent_name,
tag=root_tag,
status="subagent",
started_at_iso=sub_started_iso,
wallclock_seconds=sub_wallclock,
messages=messages,
state_history=None,
redact_values=redact_values,
parent_session_id=desc.get("parentID") or root_session_id,
subagent_title=title,
subagent_depth=depth,
)
except Exception as e: # noqa: BLE001 — best-effort, never raise
logger.warning(
"subagent archive raised for %s (%s): %s",
sid, agent_name, e,
)
continue
if written is not None:
paths.append(written)
logger.info(
"subagent %s (depth=%d agent=%s) archived to %s",
sid, depth, agent_name, written,
)
return paths
def _summarize_per_turn(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Aggregate per-assistant-turn metrics out of a /session/{id}/message
payload.
@@ -1442,6 +1700,26 @@ def run_session_blocking(
"session archive raised for %s: %s",
session_id, e,
)
# Subagent tree archive — runs AFTER the root archive
# but BEFORE the DELETE below, so the dispatcher
# captures the complete ``task`` tool chain even if
# OpenCode garbage-collects subagent sessions on
# root deletion. Best-effort: any failure logs a
# warning and lets the DELETE proceed.
try:
_archive_subagent_tree(
archive_dir=archive_dir,
server_url=server_url,
root_session_id=session_id,
root_tag=tag,
root_started_at_iso=started_at_iso,
redact_values=redact_snapshot,
)
except Exception as e: # noqa: BLE001 — defensive belt-and-braces
logger.warning(
"subagent tree archive raised for root %s: %s",
session_id, e,
)
try:
_request("DELETE", f"{server_url}/session/{session_id}")
except _TRANSPORT_EXC as e: