refactor(compass): SEA-1516 collapse the container hop and record agent placement - #33
Conversation
|
Cross-branch integration verified locally. This repo has no CI yet, so this is the only integration evidence there is — and it is the check that caught a real defect in the sealed copies of these same diffs (a deleted symbol still cited by a sibling branch, invisible to any single-diff review). Merged all five of this lane's branches together onto All five merge clean, no conflicts. On the combined tree, against a live Worth stating plainly: A per-package throwaway Postgres container also starves under this suite's parallelism (the store package hit its 600s timeout four times over); one shared database runs the same suite in ~30s. Relevant when CI is set up here. |
Greptile SummaryThis PR replaces container-mediated session ownership with direct agent ownership and introduces durable agent-to-Runner placement records.
Confidence Score: 3/5The PR is not safe to merge until the migration preserves every usable legacy container mapping. The pre-upgrade schema permits multiple containers for one agent, but the placement backfill retains only one before deleting the source table; starting any discarded container then fails ownership resolution and rolls back the live session. The previously reported unbounded rollback wait is fixed by the new independent timeout. Files Needing Attention: go/internal/store/migrations/0004_agent_placement.sql
|
| Filename | Overview |
|---|---|
| go/internal/store/migrations/0004_agent_placement.sql | Migrates session ownership and creates placement state, but the legacy backfill still loses additional containers belonging to the same agent. |
| go/internal/store/agent_placements.go | Adds validated placement upserts and lookup methods for container ownership and Runner recovery. |
| go/internal/store/agent_sessions.go | Replaces container-based session ownership with direct agent-account ownership and preserves channel-membership authorization. |
| go/internal/runnerhub/commands.go | Returns the serving Runner identity from provision relays so placement records identify the actual Runner. |
| go/internal/runnerhub/hub.go | Extends router lookup to atomically return the attached Runner identifier with its router. |
| go/server/service.go | Persists placements, resolves session ownership from them, and bounds best-effort rollback Stops with an independent timeout. |
| go/server/service_placement_pgtest_test.go | Adds integration coverage for placement recording, legacy sentinel behavior, rollback, and rollback timeout handling. |
Reviews (3): Last reviewed commit: "fix(server): bound the rollback Stop so ..." | Re-trigger Greptile
Review of record — BLOCKING on 1 finding (medium), parked as an Open Question for MattRan the mandatory review ( OQ (medium, gating) — the rollback
|
This stack of pull requests is managed by Graphite. Learn more about stacking. |
…nt placement agent_containers sat inside the SubscribeAgentSession authz chain carrying a fact that is not authorization: its only read was that JOIN, where the hop was provably 1:1 (container_name PK, NOT NULL FK to the account). Separately, nothing recorded which Runner an agent runs on -- the fact reattach recovery needs after a Runner restart orphans its surviving containers. agent_sessions now points at agent_account_id directly, agent_containers is dropped, and agent_placements records placement as operational state kept out of the authz path. This also closes a durability window: the container-to- account mapping lived only in memory, so a Server restart between Provision and Start left Start unable to record whose session it was. Placements are backfilled with an empty runner_id sentinel so pre-existing containers stay Startable; without it every container provisioned before the upgrade became permanently un-Startable, failing after the Runner had already started a live agent. StartAgentSession now best-effort stops a session it cannot record rather than stranding it unreachable. Co-Authored-By: seal <noreply@sealedsecurity.com>
abandonStartedSession rolled back a started-but-unrecorded session with context.WithoutCancel(ctx), which strips the caller's deadline; the runnerhub dispatch path has no deadline of its own, so a wedged-but-connected Runner that accepts Stop but never answers hung StartAgentSession (and stalled graceful shutdown) forever. Derive a bounded context from the cancellation-free base (rollbackStopTimeout = 30s) and correct the false comment that claimed the relay's own per-call bounds still apply. Co-Authored-By: seal <noreply@sealedsecurity.com>
1837439 to
6088148
Compare

Collapses a redundant hop out of the
SubscribeAgentSessionauthorization chain, and records the fact reattach recovery needs and nothing currently holds: which Runner each agent is on.Two problems, one migration
1.
agent_containerssat inside a security boundary carrying a fact that is not authorization.The table (
0003:24-27) wascontainer_namePK +agent_account_id, and its only read anywhere was the authz JOIN atagent_sessions.go:99. But the hop could never do more than pass the account through —container_nameis the PK and the FK to the account isNOT NULL, so it is strictly 1:1. It could neither drop nor multiply a row.Worse, it stored a derivable string:
spec.go:73builds the name asNamePrefix + accountID, a pure function of the agent account.2. Nothing recorded which Runner an agent runs on. No
runner_idcolumn exists anywhere. That matters because a Runner link drop kills the process while its containers survive (Launchsets no--rm), so after a restart the Server must re-driveProvisionfor exactly the agents that Runner held — a set it could not name.What changed
agent_sessionspoints atagent_account_iddirectly,agent_containersis dropped, andagent_placements(agent_account_idPK,runner_id,container_name, timestamps) records placement as operational state, deliberately outside the authz path.The chain shortens from
session_id → container_name → agent_account_id → home_channel_idtosession_id → agent_account_id → home_channel_id.Why
container_namelives onagent_placementsrather than being recomputed. My first instinct was to derive the name, butNamePrefixis Runner-side config (cmd/compass-runner/main.go:112) that the Server never sees — it can neither compute nor invert it. The Server does, however, learn the name atProvision, where it holds every fact at once:agent_account_idis its own request field,container_nameis the Runner's response,runner_idis the Runner it relayed to. Previously it dropped that triple into an in-memory binding and nowhere else.So this closes a real durability window rather than just reorganizing tables: before it, a Server restart or Runner re-enroll between
ProvisionandStartleftStartAgentSessionunable to say whose session it was recording.Startnow resolves the owner from a durable read.The authz query is the risk, so it got the most scrutiny
The removed hop was provably 1:1, so the set of
(session, caller)pairs authorized is identical. (The in-code comment originally justified this by the derived container name — a Runner-side convention the Server never enforces. It now rests on the schema fact, which holds regardless of how any Runner names things.) Still a single constant-shapeEXISTS, so 0003's timing-parity argument survives.agent_placementsis not in this query — placement is where an agent runs, not who may watch it, and keeping them apart is what stops the hop from growing back into the security boundary.Every existing authz test passes unchanged, including the not-found/forbidden merge and the end-to-end parity test through the real interceptor chain. Added
TestRequireAgentSessionSubscriberCollapseIsUniformAcrossRefusalCauses, which pins four structurally distinct refusals against each other — unknown session; a foreign session where the caller owns a different agent so the chain resolves fully and only membership fails; a caller belonging to nothing; an owner probing a rival's session — asserting the same sentinel and the same message, plus that the legitimate owner still authorizes, so uniform refusal is not universal refusal.Mutation-proven, not assumed. Weakening the membership join to
ON TRUEfails both the pre-existing collapse test and the new one. I re-ran that mutation myself rather than taking it on report.Backfill proven separately, because a fresh-schema migration test never exercises it: a 0003-era schema seeded with 2 agents / 2 containers / 3 sessions, then the migration's
agent_sessionshalf run verbatim — all 3 rows survived carrying the account the old JOIN resolved.Deliberate choices worth flagging
runner_idis not a FK. Runners enroll in memory under a token subject with norunnerstable, and a placement must outlive the Runner's attachment — that is the entire point of recording it.PK on the agent, not a surrogate. An agent is on at most one Runner under one name, so a re-provision replaces the row. A stale
container_namebeside a freshrunner_idwould be worse than no row.container_nameis UNIQUE, so a second agent can never claim a live container name — which would letStartrecord a session under the wrong owner and hand the authz JOIN the wrong home channel.Placements ARE backfilled, with an explicit sentinel. My first revision declined to backfill, reasoning that a pre-existing container's placement was "unknowable" since
agent_containersrecorded no Runner. Review caught that this was an upgrade-path break, and it was right: the same migration makesagent_placementsa hard dependency ofStartAgentSession, so every container provisioned before the upgrade became permanently un-Startable — and it failed after the Runner had already started a live agent, stranding a session the Server had no record of. It was only "unknowable" because the migration dropped the source table before anything could read it.Two of the three columns were sitting right there in scope, so the backfill inserts them with
runner_id = ''as a documented sentinel. That value is exactly right in both directions:AgentForContainerresolves it soStartkeeps working, whileListAgentPlacementsForRunnerrejects an empty runner id and so never surfaces a backfilled row to reattach — correct, because we genuinely do not know which Runner held it. The next provision overwrites it with the real Runner through the existing upsert.runner_idstaysNOT NULL; the empty string satisfies it, which is stronger than making the column nullable.Proven empirically: a 0003-era database seeded with a provisioned-but-never-started container — the exact broken case — now resolves through the literal query
Startissues.StartAgentSessionno longer strands a session it cannot record.hub.Startis an irreversible side effect. If the placement read or the ownership write then failed, the handler returned an error and discarded the session id, leaving a running agent that could not be stopped, reloaded, or subscribed to through any API. It now best-effort Stops the session oncontext.WithoutCancel(matching the failed-Launchcleanup pattern ininternal/runtime/agent.go), logs at ERROR with the session and container, and names the session id in the returned error so a failed rollback still leaves an operator able to reap it.This is not symmetric with
Provision, and the comments no longer imply it is:Provision's write is an idempotent upsert whose client retry dedups at the router viaclient_request_id, so it self-heals.StartAgentSessionRequestcarries noclient_request_id, so a retry issues a genuinely new Start — its partial state was unrecoverable.RecordAgentPlacement's docstring no longer oversells. It presented refusing a container name held by another agent as a live safety property, but nothing releases a name and names are derived from the account (spec.go:73), so the cross-agent conflict is unreachable in production. It now says placements are permanent for now, names what the unique index actually leans on, and states that changing name derivation makes the conflict reachable and needs a release path added alongside. No delete path was added.Scope
This lands the record reattach recovery reads; it does not implement reattach (re-driving
Provisiononreattached), which is separate work in this lane. The placement accessors are real and tested, not stubs, andProvisiongenuinely writes the row.The handler seam had zero coverage — that is where every defect lived
The store primitives were each individually correct and all their tests stayed green under both bugs; it was their composition in the handlers that was wrong, and no test drove either changed handler. Three pgtests now do, against a fake Runner dialing the real mounted RunnerService door, so a rollback
Stopis observed as an actual wire command rather than a mock call:StartAgentSessionsucceeds for a backfilled placement (runner_id = ''), records the ownership row, and stays invisible to reattach.StartAgentSessionon an unplaced container returnsCodeInternal, names the session id, the Runner receives aStopfor that exact session, andagent_sessionsis left empty.ProvisionAgentWorkspacerecords a placement naming the Runner that actually served the relay.I mutation-tested the rollback myself: removing the
Stopturns the second test red with "the started session is stranded".Verification
go build,go vet -tags "pgtest unix",gofmt -l(empty),-raceunit suite,golangci-lintzero findings in hand-written code. Real-Postgres pgtest run with-vagainst a live DSN showing genuine PASS lines — 6 placement tests, 5 authz tests, and the 3 new handler tests, no skips. (Thepgtestsuite silently skips when no container runtime is available, so a bareokproves nothing; these were confirmed executing.)Spec-impact: none
Internal persistence and one unchanged authz predicate; no externally-visible behavior changes.
Targets
mainfor review; lands insealedsecurity/compassafter the port.Ported from
sealedsecurity/sealed#998, unchanged in content — Compass code now lives here, so this is where it ships. The sealed PR is closed in favour of this one.Verification (this repo has no CI yet, so a local run is the only evidence there is):
go build ./...,go vet -tags "pgtest unix" ./...,gofmt -lclean,go test -race ./internal/... ./server/..., and thepgtestsuites for./internal/store/,./internal/comms/,./server/against a livepostgres:17— all green on this branch.