Skip to content

SPE-5054 [S3]: swarm memory through the substrate's governed memory.write (stacked on #5) - #6

Merged
SomeRandmGuyy merged 13 commits into
mainfrom
feat/s3-swarm-memory
Oct 7, 2026
Merged

SomeRandmGuyy merged 13 commits into
mainfrom
feat/s3-swarm-memory

Conversation

@SomeRandmGuyy

@SomeRandmGuyy SomeRandmGuyy commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

What

ADR 0001 phase S3, swarm side (SPE-5054). Stacked on #5, so its base is that branch; GitHub retargets it to main when #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 governed memory_write, not a migration:

  • swarm/memory.py: memory_write maps swarm decision / retro / pattern to substrate decision / fact / fact, and scopes graph / repo / agent / global to graph:<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.
  • Outcomes: denied raises E-POLICY (fail-closed, never retried); quarantined and review.required are returned as-is and not retried; version.* conflicts re-read the version and re-submit with expected_version; an accepted-but-unverified write retries with the same key; ok is true only for accepted and verified_persisted; an unreachable substrate is unavailable, never a pretend write.
  • memory_query (memory_search) and run_start_context (/brief) are fail-open reads. scripts/swarm_run.py prepends a ## Substrate memory section to the assignment prompt when the substrate answers, and is byte-for-byte unchanged otherwise.
  • swarm/substrate_client.py gains mcp_call_json because memory_search answers a JSON array.
  • Criterion 7: an AST scan over swarm/, scripts/ and hooks/ proves no swarm-side store feeds an agent's context. The substrate's store-lock.test.ts cannot prove that.

A swarm decision can only be written at graph or repo scope (the gate never grants it at agent: scope), and a global write is a proposal until a reviewer token releases it. Both are documented in docs/substrate-memory.md.

Verification

  • pytest: whole suite passes (this branch adds tests/test_swarm_memory.py); build_agents.py --check, build_trae_agents.py --check and bun test tests/ts pass.
  • ruff: nothing new; the two E401 findings in tests/test_uplift_harness.py are on origin/main.

Not in this PR: agents calling this through tooling (S4), gate verdicts as memory (S5).

Refs SPE-5054
Refs SPE-7629

RetriggerConfidence Score: 5/5 Tier: apex

The reviewed changes add no new issue that would block merging.

What we checked:

  • Refused writes never look saved: A policy denied reply raises E-POLICY. WriteOutcome.ok also requires both accepted and verified_persisted.
  • Existing callers keep object replies: mcp_call_json accepts lists, but mcp_call still returns only objects or None.
  • Failed reads leave dispatch working: run_start_context returns 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.

  • Writes require accepted and verified_persisted; reads fail open.
  • Adds array replies while preserving object-only mcp_call callers.
  • Adds memory tests and checks for a second memory store.
  • No new actionable findings.

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]
Loading

Reviews (4) · Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." · Reviewed by Greptile

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
Comment thread swarm/memory.py Outdated
Comment thread swarm/memory.py Outdated
Comment thread tests/test_swarm_memory.py Outdated
Comment thread tests/test_swarm_memory.py Outdated
Comment thread docs/substrate-memory.md Outdated
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
@greptile-apps

greptile-apps Bot commented Oct 7, 2026

Copy link
Copy Markdown

Comments Outside Diff

These findings could not be posted inline.

  • P1 Brief skips configured token swarm/memory.py:285 ▶

    With SUBSTRATE_TOKEN_SWARM_A01_ORCH set and SUBSTRATE_TOKEN unset, run_start_context sends /brief without an Authorization header. lookup_graph_id uses the configured A01 token, but this call does not. An auth refusal becomes '' and is cached for the whole run, so no task gets memory context. Pass surface=ORCH_SURFACE to rest_post.

        got = substrate_client.rest_post("/brief", {"repo": repo, "graph_id": graph_id, "surface": ORCH_SURFACE}, env,
                                         surface=ORCH_SURFACE)
    
  • P2 Dry runs contact Substrate scripts/swarm_run.py:118 ▶

    With SUBSTRATE_URL enabled, swarm_run --dry-run now contacts live Substrate. execute_one builds assignment_prompt before checking args.dry_run, so this prefix triggers graph lookup and /brief during a preview. That breaks the documented promise that dry runs make no Substrate requests and makes preview prompts depend on live replies. Pass the dry-run flag into prompt construction and skip this fetch.

1 similar comment
@greptile-apps

greptile-apps Bot commented Oct 7, 2026

Copy link
Copy Markdown

Comments Outside Diff

These findings could not be posted inline.

  • P1 Brief skips configured token swarm/memory.py:285 ▶

    With SUBSTRATE_TOKEN_SWARM_A01_ORCH set and SUBSTRATE_TOKEN unset, run_start_context sends /brief without an Authorization header. lookup_graph_id uses the configured A01 token, but this call does not. An auth refusal becomes '' and is cached for the whole run, so no task gets memory context. Pass surface=ORCH_SURFACE to rest_post.

        got = substrate_client.rest_post("/brief", {"repo": repo, "graph_id": graph_id, "surface": ORCH_SURFACE}, env,
                                         surface=ORCH_SURFACE)
    
  • P2 Dry runs contact Substrate scripts/swarm_run.py:118 ▶

    With SUBSTRATE_URL enabled, swarm_run --dry-run now contacts live Substrate. execute_one builds assignment_prompt before checking args.dry_run, so this prefix triggers graph lookup and /brief during a preview. That breaks the documented promise that dry runs make no Substrate requests and makes preview prompts depend on live replies. Pass the dry-run flag into prompt construction and skip this fetch.

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-apps[bot]
greptile-apps Bot previously approved these changes Oct 7, 2026
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
@SomeRandmGuyy
SomeRandmGuyy deleted the branch main October 7, 2026 15:56
@SomeRandmGuyy SomeRandmGuyy reopened this Oct 7, 2026
@SomeRandmGuyy
SomeRandmGuyy deleted the branch main October 7, 2026 15:57
@SomeRandmGuyy SomeRandmGuyy reopened this Oct 7, 2026
@SomeRandmGuyy
SomeRandmGuyy changed the base branch from feat/s1-substrate-tee to main October 7, 2026 15:57
@greptile-apps
greptile-apps Bot dismissed their stale review October 7, 2026 16:00

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

@SomeRandmGuyy
SomeRandmGuyy merged commit a25c02a into main Oct 7, 2026
3 checks passed
@SomeRandmGuyy
SomeRandmGuyy deleted the feat/s3-swarm-memory branch October 7, 2026 16:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant