Repository navigation
SPE-5054 [S3]: swarm memory through the substrate's governed memory.write (stacked on #5) - #6
Conversation
ADR 0001 phase S1, swarm side (criteria 3 to 6 and 8). - swarm/runlog.py: emit() mints an additive msg_id into each record and tees it to substrate after the local write. No SUBSTRATE_URL, no network. - swarm/substrate_tee.py: explicit swarm-type to EVENT_KINDS table, A01..A15 to swarm-aNN-xxx surfaces, session_id <AGENT>@<replica>:<graph_id>, dedupe on (surface, msg_id) over 24 h, a durable correlation to Graph ID cache, owner/name repo slug from the origin remote, Graph ID minting. - swarm/substrate_client.py: stdlib REST and stateless MCP client, 1.5 s timeout, back-off after an outage, never raises. - scripts/orch_plan.py: a new plan resolves or mints a Graph ID, binds it with graph_bind (adopting the server's winner) and registers the node set; --graph-id added. Re-plans still reuse and nothing time-varying enters the hashed brief. The tee fails open: with the substrate unreachable every swarm flow completes as before. Refs SPE-5052 Refs SPE-7629
…ory.write ADR 0001 phase S3, swarm side. The swarm had no memory module (its vector memory is design only), so this is a thin client, not a migration. - swarm/memory.py: memory_write maps decision/retro/pattern to decision/fact/fact and graph/repo/agent/global to graph:<id>, repo:<o/n>, agent:<surface>, global; derives an idempotency key from correlation, task, attempt, kind, scope and text and reuses it on every retry. Outcomes: denied raises E-POLICY, quarantined and review.required are returned and never retried, version conflicts re-read and re-submit with expected_version, an unverified write retries with the same key, and ok is true only for accepted and verified_persisted. An unreachable substrate is "unavailable", never a pretend write. memory_query and run_start_context read through memory_search and /brief, fail-open. - scripts/swarm_run.py: the assignment prompt gains a Substrate memory section when the substrate answers; unchanged byte for byte otherwise. - swarm/substrate_client.py: mcp_call_json for tools that answer a list. - tests: every handler, the key, and an AST scan proving no swarm-side store feeds an agent's context (the substrate's store-lock cannot). Refs SPE-5054 Refs SPE-7629
Greptile on #5, all seven findings: - the bearer token is an unredirected header, so a redirect never carries it - a per-surface token override (SUBSTRATE_TOKEN_<SURFACE>) lets one orchestrating process that holds several tokens speak as each agent; S4 still provisions one SUBSTRATE_TOKEN per agent process - the whole request (connect and read) is bounded by the timeout plus a small slack and the body is capped at 1 MiB; a hit starts the back-off - --dry-run runs write the local record but never tee - the correlation to Graph ID cache is keyed by the substrate base URL - dedupe distinguishes pending from sent, so a killed send can be retried after 60 s while a sent record stays deduped for 24 h - the 24 h prune has an index and runs at most once a minute per process Each fix has a test. No change to the event contract. Refs SPE-5052
# Conflicts: # swarm/substrate_client.py
…re scan Greptile on #6, all five findings: - the idempotency key hashes a JSON array of correlation, task, attempt, kind, scope, normalized subject and text, so subject-only differences and '|' inside a field no longer collide; expected_version stays out of it - a version conflict re-reads the standing decision with a targeted search by subject, falls back to the scope-wide read, and stops with conflict instead of dropping expected_version when it cannot find it - the criterion-7 scan walks nested Python modules and catches side-effect, dynamic and re-export TypeScript imports - unavailable is documented as an unknown result: recover by retrying the same logical write, which reuses its key Each change is tested. Refs SPE-5054
Greptile on #5: the deadline ran urlopen on a worker thread and armed the close timer only after urlopen returned, so a server trickling response headers kept the thread and its connection alive past the deadline, and later calls could pile up more. Every request now goes through one seam, _open, built on http.client in the calling thread. A timer armed before the request shuts the socket down at the deadline, which ends a blocked connect, header read or body read at once; no worker thread exists. Redirects are not followed at all, so the bearer is never re-sent, and the body stays capped at 1 MiB. Real-socket tests: a trickling-headers server is cut at the deadline with no lingering thread (stable over repeated runs), a 302 is not followed and the second server sees nothing, and an oversized body fails. Refs SPE-5052
The client no longer calls urllib.request.urlopen (S1 moved every request to http.client behind _open), so the memory tests patch _open with the same fakes and keep every assertion. Refs SPE-5054
Comments Outside DiffThese findings could not be posted inline.
|
1 similar comment
Comments Outside DiffThese findings could not be posted inline.
|
Greptile on #5: connection setup (DNS, connect) was not bounded, and the http.client transport had dropped HTTP_PROXY / HTTPS_PROXY / NO_PROXY. Each request now goes through a per-call urllib opener (ProxyHandler, a handler that never follows redirects, and connection classes that register their socket the moment TCP is up and again after TLS setup) on a daemon worker. The caller waits at most TIMEOUT_S + DEADLINE_SLACK_S over DNS, connect, headers and body; at the deadline it shuts the socket down, and a connect that completes later is killed on arrival. Only a worker stuck in DNS can outlive its deadline, and at most four workers exist at once; with none free a call fails at once. Real-socket tests: stuck DNS returns within the bound and caps lingering workers, a late connect is killed, HTTP_PROXY is used and NO_PROXY bypasses it, plus the existing trickling-headers, redirect and body-cap tests. Refs SPE-5052
Greptile on #5: a non-2xx reply whose body timed out, failed or exceeded MAX_BODY_BYTES became an empty answer, so the client never backed off and each later write paid the timeout again. Such a body now raises in the worker and in _request's error branch, so it takes the same back-off path as a network failure. An ordinary non-2xx (normal body or none) is still returned as (status, text) without back-off. Tests cover an oversized 500 body, a trickling 500 body and the fake-level error branch. Refs SPE-5052
# Conflicts: # swarm/substrate_client.py
Dismissed because a newer commit was pushed; Greptile will re-review the current head.
What
ADR 0001 phase S3, swarm side (SPE-5054). Stacked on #5, so its base is that branch; GitHub retargets it to
mainwhen #5 merges. Pairs with the substrate PR agent-substrate#12.The swarm has no memory module today (its vector memory is design only in
04-integration-plan.md), so this is a thin client onto the substrate's governedmemory_write, not a migration:swarm/memory.py:memory_writemaps swarmdecision/retro/patternto substratedecision/fact/fact, and scopesgraph/repo/agent/globaltograph:<id>/repo:<owner/name>/agent:<surface>/global. The idempotency key is derived from correlation, task, attempt, kind, scope and text and is reused on every retry of one write.deniedraisesE-POLICY(fail-closed, never retried);quarantinedandreview.requiredare returned as-is and not retried;version.*conflicts re-read the version and re-submit withexpected_version; an accepted-but-unverified write retries with the same key;okis true only foracceptedandverified_persisted; an unreachable substrate isunavailable, never a pretend write.memory_query(memory_search) andrun_start_context(/brief) are fail-open reads.scripts/swarm_run.pyprepends a## Substrate memorysection to the assignment prompt when the substrate answers, and is byte-for-byte unchanged otherwise.swarm/substrate_client.pygainsmcp_call_jsonbecausememory_searchanswers a JSON array.swarm/,scripts/andhooks/proves no swarm-side store feeds an agent's context. The substrate'sstore-lock.test.tscannot prove that.A swarm
decisioncan only be written atgraphorreposcope (the gate never grants it atagent:scope), and aglobalwrite is a proposal until a reviewer token releases it. Both are documented indocs/substrate-memory.md.Verification
pytest: whole suite passes (this branch addstests/test_swarm_memory.py);build_agents.py --check,build_trae_agents.py --checkandbun test tests/tspass.ruff: nothing new; the two E401 findings intests/test_uplift_harness.pyare onorigin/main.Not in this PR: agents calling this through tooling (S4), gate verdicts as memory (S5).
Refs SPE-5054
Refs SPE-7629
The reviewed changes add no new issue that would block merging.
What we checked:
deniedreply raisesE-POLICY.WriteOutcome.okalso requires bothacceptedandverified_persisted.mcp_call_jsonaccepts lists, butmcp_callstill returns only objects orNone.run_start_contextreturns an empty string for failed replies. Prompt construction also catches exceptions and keeps an empty memory section.Summary
Adds a thin Substrate memory client and a run-start brief in task prompts.
acceptedandverified_persisted; reads fail open.mcp_callcallers.Greptile automatically discovered a related ticket that helped explain the purpose of this PR: route swarm memory through Substrate’s governed write API.
Diagram
%%{init: {'theme': 'neutral'}}%% flowchart TD Caller[Caller] --> Adapter[memory_write] Adapter --> Write[POST /memory] Write --> Result{Write result} Result -->|accepted and verified| Done[ok = true] Result -->|policy denied| Refused[E-POLICY] Result -->|version conflict| Read[memory_search] Read -->|version found| Adapter Result -->|other result| Pending[ok = false] Prompt[assignment_prompt] --> Brief[Cached run-start /brief] Brief -->|text returned| Prefix[Prepend Substrate memory] Brief -->|off or failed| Original[Keep original prompt]Reviews (4) · Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." · Reviewed by Greptile