Two A2A isolation defects, and one refusal that looks like success
Two A2A isolation defects, and one refusal that looks like success
Owner: Echo (research & security lane). Measured on LAB, 2026-10-21 ~17:42–19:00 AEDT.
Lane: plugins/platforms/a2a/ (vendored Hermes A2A plugin) — context
isolation and terminal-state honesty, not the payload path.
Trigger: a peer sent a stream of bare ok messages to the Brain doors. Four of
my own turns were cut off mid-work and the audit log recorded three of them as
[agent did not reply in time]. Chasing that led to two isolation defects and
one state-honesty defect.
Scope note — what this note does NOT claim. An earlier draft of this note claimed the five agents were stealing each other's replies. That is false, and it was disproved before it was committed (§2). The real defects are narrower and better-proven: a shared transcript file, and a refusal that reports success.
1. Summary
| Finding | Severity | State |
|---|---|---|
A. A2A context transcripts are written to the shared root home — all five agents' traffic for one contextId lands in one file | MEDIUM | Open — diagnosed, not fixed |
B. A gateway drain/restart refusal is returned as TASK_STATE_COMPLETED (HTTP 200) | HIGH | Open — diagnosed, not fixed; reproduced live |
| C. The 300 s reply window discards a finished answer and records the timeout string instead | HIGH | Open — diagnosed in a2a-reply-timeout-drop-2026-10-21; this note adds 3 more instances |
D. The audit log caps summary at 500 chars | MEDIUM | By design — deliberate, documented |
E. Echo <echo@solos.lab> — a truncated email in 3 vault commits | LOW | Not a system defect — hand-typed, §6 |
Fleet verdict: routing is correct. The two defects are about isolation (shared log) and honesty of the terminal state (a refusal dressed as success).
2. A — One contextId is one file, shared by every agent (MEDIUM, OPEN)
The measurement
The peer used contextId: "c" for every send. The transcript for that context
— ~/.hermes/a2a_conversations/c.jsonl — contains replies from five
different agents:
$ python3 -c "
import json, re
v = set()
for l in open('/home/lab/.hermes/a2a_conversations/c.jsonl'):
d = json.loads(l)
if d['role'] != 'agent': continue
for n in ['Min','Mira','Sol','Nova','Echo']:
if re.search(r'\b'+n+r'\b', d['text'][:70]): v.add(n); break
print(sorted(v))
"
['Echo', 'Min', 'Mira', 'Nova', 'Sol']
Min, Mira, Sol, Nova and Echo are all in one transcript. That is not a
cosmetic problem: a2a_history (plugins/platforms/a2a/tools.py:223) is a
tool every agent can call, and it reads this file. One agent can recall
another agent's conversation.
Why it happens
The transcript path is derived from the context id alone, resolved against the
process home (protocol.py:433):
def _conv_path(context_id: str) -> Path:
safe = "".join(c for c in (context_id or "default") if c.isalnum() or c in "-_") or "default"
return get_hermes_home() / "a2a_conversations" / f"{safe}.jsonl"
get_hermes_home() resolves to the launch home (/home/lab/.hermes), not
the served profile's home. So c → ~/.hermes/a2a_conversations/c.jsonl for
every agent, and the file is a merged transcript. Each agent's own session
in its state.db is correctly private; only this derived log is shared.
What is NOT happening — reply theft (disproved)
An earlier reading of this was: five agents answering one context must mean their reply waiters were racing, so agent A's answer could resolve agent B's task. Measured, that is false:
$ python3 -c "
import json, glob, collections
loc = collections.defaultdict(set)
for f in ['/home/lab/.hermes/a2a_audit.jsonl'] + glob.glob('/home/lab/.hermes/profiles/*/a2a_audit.jsonl'):
for l in open(f):
try: d = json.loads(l)
except: continue
loc[d['task_id']].add(f)
print('task_ids spanning >1 audit file:', len([k for k, v in loc.items() if len(v) > 1]), '| total task_ids:', len(loc))
"
task_ids spanning >1 audit file: 0 | total task_ids: 642
Zero of 642 tasks appear in more than one audit file. And the direct test — one context id, two ports:
SAME context id 'ctx-echo-sharedctx-test-20261021' sent to min:8311 and echo:8315
:8311 min 18.3s TASK_STATE_COMPLETED 'SHAREDCTX-min-OK'
:8315 echo 22.7s TASK_STATE_COMPLETED 'SHAREDCTX-echo-OK'
Both answered correctly, each with its own marker. The waiters are
per-adapter (adapter.py:296, self._pending is an instance attribute), so
each port resolves its own tasks. Routing is sound; only the log is shared.
⇒ This is a transcript-isolation defect, not a reply-routing defect. Report it as such.
3. B — A drain refusal is returned as TASK_STATE_COMPLETED (HIGH, OPEN)
The measurement
During the gateway restart at ~18:54, all five Brain ports answered a normal
message/send with HTTP 200, TASK_STATE_COMPLETED, and an artifact
containing a refusal:
$ # five ports, unique context each
:8311 min 0.0s TASK_STATE_COMPLETED "⏳ Gateway is restarting and is not accepting new work right now."
:8312 mira 0.0s TASK_STATE_COMPLETED "⏳ Gateway is restarting and is not accepting new work right now."
:8313 sol 0.0s TASK_STATE_COMPLETED "⏳ Gateway is restarting and is not accepting new work right now."
:8314 nova 0.0s TASK_STATE_COMPLETED "⏳ Gateway is restarting and is not accepting new work right now."
:8315 echo 0.0s TASK_STATE_COMPLETED "⏳ Gateway is restarting and is not accepting new work right now."
Five for five. The refusal is a terminal success in the A2A sense: the
task is marked completed and the body is delivered as an artifact. A caller
that trusts TASK_STATE_COMPLETED records "the agent did the work" when
in fact nothing ran.
The audit log holds 31 rows of this:
$ python3 -c "
import json
print(sum(1 for l in open('/home/lab/.hermes/a2a_audit.jsonl') if 'restarting' in l))
"
31
The mechanism
The string is a user-facing locale message, locales/en.yaml:715:
drain_rejected_new_work: "⏳ Gateway is {action} and is not accepting new work right now."
It is emitted by the gateway's busy/drain path (gateway/run_busy.py:838,
if self._draining: await self._send_busy_drain_notice(...)) as the reply
text, and the A2A adapter then wraps whatever it receives in a completed task
(protocol.py:185, build_task(..., STATE_COMPLETED, agent_text) puts the
text in both status.message and artifacts).
⇒ A refusal is being encoded as a success. This is the same class of defect the fleet already names as "hollow success" in fleet-probe-2026-10-21 §"the same shape as Pulse's '200 that did not run'".
Why it matters
- A peer cannot distinguish "the agent answered" from "the gateway refused".
- The audit row records the refusal text, so the caller sees a normal reply and the log sees a refusal — they disagree, and neither is a clean signal.
- During a restart window this is exactly when a caller is most likely to retry or give up, and the answer it gets is a lie.
The fix
Return a non-success terminal state (TASK_STATE_FAILED, or
TASK_STATE_REJECTED) for a drain refusal, and do not attach it as an
artifact. Not applied — it changes the A2A wire contract for every peer
in the fleet, and a gateway restart is Young's decision.
Recovery check (proven): once the gateway returned to running, the same
probe answered correctly:
$ # after the restart cleared
:8315 echo 44.5s TASK_STATE_COMPLETED 'RECOVER-PROBE ctx-echo-recover-echo-20261021'
:8311 min 9.1s TASK_STATE_COMPLETED 'RECOVER-PROBE ctx-echo-recover-min-20261021'
4. C — The 300 s window is still the dominant cause of lost work (HIGH, OPEN)
Already diagnosed in a2a-reply-timeout-drop-2026-10-21: _await_reply
(adapter.py:663) returns (STATE_FAILED, "[agent did not reply in time]")
at the deadline and _finalize_task pops the pending entry
(adapter.py:643). This note adds three more instances and the session-level
context.
The three dropped tasks
| Task | Inbound | Door gave up (+300 s) | When the work actually finished |
|---|---|---|---|
task-cccffd4eb15444b1 | 18:07:52 | 18:12:52 | next turn, ~18:12 |
task-4cee783a1b77474f | 18:09:52 | 18:14:52 | next turn, ~18:12 |
task-af9f223e13904f05 | 18:11:53 | 18:16:53 | next turn, ~18:12 |
All three fired at exactly 300.0 s after their inbound. The work they needed was still running.
The session-level context
The three inbounds all used contextId: "c", which maps to one session:
agent:echo:a2a:dm:c. On that session:
inbounds on chat=c (gateway.log): 4
finals on chat=c (gateway.log): 2
Four inbounds, two finals. A2A sessions are serialised — one turn at a
time — so a peer that reuses one contextId for a rapid burst does not get
four parallel turns. It gets a queue. Combined with the 300 s window, a burst
that arrives faster than the agent can finish will have its tail dropped.
The peer's own contract is part of the fix: a distinct contextId per
conversation is what the fleet expects, and reusing one id across a burst is
what produced this. But the door must still not destroy a finished answer
— that is the adapter's defect, and it stands on its own.
Live confirmation that turns exceed the window
A five-port sweep, one unique context per port:
:8311 min 72.4s TASK_STATE_COMPLETED match=True
:8312 mira 6.0s TASK_STATE_COMPLETED match=True
:8313 sol 25.1s TASK_STATE_COMPLETED match=True
:8314 nova 120.0s ERR match=False 'timed out'
:8315 echo 47.0s TASK_STATE_COMPLETED match=True
Nova did not answer inside 120 s. The measured fleet distribution in a2a-reply-timeout-drop-2026-10-21 puts 20 % of real turns above 300 s and the max at 17 963 s. The window is not a tail allowance; it is inside the normal distribution.
5. D — The 500-char audit cap is DELIBERATE, not a defect (by design)
plugins/platforms/a2a/security.py:214:
rec = {..., "summary": (summary or "")[:500]}
total rows: 1243 | rows at exactly 500 chars: 220 | rows longer than 500: 0
of those 220, ending mid-sentence (no terminal punctuation): 213
The cap is a log-summary cap, not a transport cap — the full body always
survives in a2a_conversations/*.jsonl and the peer's state.db. Recorded in
a2a-capability-ping-t_echo_cap_A-2026-10-20 §"Did not touch the vault's
own [:500] audit cap — it is a deliberate log-budget choice".
⇒ Recorded here so the next reader does not re-report it as a new defect.
The full bodies are in a2a_conversations/*.jsonl and the peer's state.db;
the audit summary is a pointer, never the evidence.
6. E — echo@solos.lab is a hand-typed typo, NOT a system defect (LOW)
Three vault commits carry a truncated email:
| Commit | Author line | Agent |
|---|---|---|
4cf7bd0 | Echo <echo@solos.lab> | Echo (a probe) |
21b3c1f | Sol <sol@solos.lab> | Sol |
d703db7 | Sol <sol@solos.lab> | Sol |
Every other commit in the vault uses the correct @solos.local domain, and
both the registry (/home/lab/.solos/cortex/registry/agents.yaml) and every
profile's git_identity block are correct:
$ grep -A3 '^git_identity' ~/.hermes/profiles/echo/config.yaml
name: Echo
email: echo@solos.local
Controlled test — git does not truncate the address:
$ git init -q . && git config user.email "echo@solos.local"
$ GIT_AUTHOR_EMAIL=echo@solos.local git commit -m test
$ git log -1 --format='%an <%ae>'
Echo <echo@solos.local> # 16 chars, intact
echo@solos.local is 16 characters, echo@solos.lab is 14. There is no
filter, no .gitattributes, no ident hook, and no [:14] anywhere. The
truncation is a typing error at commit time, not a platform defect — it must
not be reported as one.
The open card t_55adde38 ("Per-agent git identities + log the session id on
A2A-originated writes") is blocked on exactly this question. Its
acceptance criterion 1 ("Each agent commits under its own identity") is what
these three commits violate, and the stalled investigation was hunting a
truncation bug that does not exist. The answer is: hand-typed, git is
innocent. The process fix is that an agent must never hand-type an identity —
tools/git_identity.py exports GIT_AUTHOR_*/GIT_COMMITTER_* per profile,
and an explicit git -c user.email=… overrides it, so the guard is
bypassed precisely by typing.
7. What I did NOT do
- No code changed. Every fix above touches the vendored Hermes A2A plugin or the gateway restart — Young's call, not a research lane's.
- No credentials or private content disclosed to the peer.
- The earlier wrong claim was killed, not committed. §2 records the disproof so the next reader does not repeat the mistake.
8. Recommendations, in dependency order
- Do not encode a drain refusal as
TASK_STATE_COMPLETED(§3) — the highest-value fix. ReturnTASK_STATE_FAILED/REJECTED, no artifact. - Namespace the transcript by agent (§2) — derive
_conv_pathfrom the served profile's home, not the process home. Makes the log private and stops one agent reading another's conversation. - Raise
A2A_REPLY_TIMEOUTabove the real turn distribution and order it above the peer-sidea2a_agents.timeout(60 s vs the door's 300 s — the outer timer is five times too short). - Do not pop the pending waiter on timeout — leave it for a
GetTaskpoll, and log the drop at INFO so it is visible. - Peer contract: a distinct
contextIdper conversation; never reuse one id for a burst. The counterpart of the agent-identity naming rule. - Never hand-type a git identity (§6) — use the exported
GIT_AUTHOR_*env, and amend the three affected commits.
9. Reproduction
# A. the shared transcript — five agents, one context id
python3 -c "
import json, re
v = set()
for l in open('/home/lab/.hermes/a2a_conversations/c.jsonl'):
d = json.loads(l)
if d['role'] != 'agent': continue
for n in ['Min','Mira','Sol','Nova','Echo']:
if re.search(r'\b'+n+r'\b', d['text'][:70]): v.add(n); break
print(sorted(v))
"
# A-disproof: no task is ever resolved by the wrong adapter
python3 -c "
import json, glob, collections
loc = collections.defaultdict(set)
for f in ['/home/lab/.hermes/a2a_audit.jsonl'] + glob.glob('/home/lab/.hermes/profiles/*/a2a_audit.jsonl'):
for l in open(f):
try: d = json.loads(l)
except: continue
loc[d['task_id']].add(f)
print(len([k for k, v in loc.items() if len(v) > 1]), 'of', len(loc))
"
# B. the drain refusal (while the gateway is draining)
curl -s -X POST -H 'Content-Type: application/json' -H 'A2A-Version: 1.0' \
-d '{"jsonrpc":"2.0","id":1,"method":"message/send","params":{"message":{"role":"user","parts":[{"kind":"text","text":"probe"}],"messageId":"x","contextId":"ctx-drain-probe"}}}' \
http://127.0.0.1:8315/ | python3 -m json.tool
# C. the 300 s window + session serialisation
grep -c 'inbound message: platform=a2a.*chat=c ' ~/.hermes/profiles/echo/logs/gateway.log
grep -c 'response ready: platform=a2a chat=c ' ~/.hermes/profiles/echo/logs/gateway.log
# D. the deliberate audit cap
grep -n 'summary' ~/.hermes/hermes-agent/plugins/platforms/a2a/security.py
# E. the typo'd identities
git -C ~/SolOS/vault log --format='%h %an <%ae>' | grep 'solos.lab'
A2A connectivity — what is actually broken (live probes, 2026-10-20)
Young asked three questions:
A2A replies are DROPPED, not lost — the 300 s reply timeout discards a finished answer
Owner: Echo (research & security lane). Measured on LAB, 2026-10-21 ~17:42–18:10 AEDT. Lane: plugins/platforms/a2a/ (vendored Hermes A2A plugin) + the Brain fleet's a2a_agents peer timeouts.

