Skip to content

fix(workspace): deletion must take the agents' data with it - #187

Open
WhichPaths wants to merge 1 commit into
yetone:mainfrom
WhichPaths:fix/workspace-deletion-orphans
Open

fix(workspace): deletion must take the agents' data with it#187
WhichPaths wants to merge 1 commit into
yetone:mainfrom
WhichPaths:fix/workspace-deletion-orphans

Conversation

@WhichPaths

Copy link
Copy Markdown
Collaborator

Deleting a workspace leaves the agents' memory and their written notes about people behind.

The purge sweeps its soft-scoped tables with

for (const table of softScopedTables) {
  await client.query(`DELETE FROM ${table} WHERE company_id = $1`, [companyId])
}

which is exactly as good as the company_id the writers put there. Two of them do not put one.

agent_workspace — the agent's own filesystem endpoint writes no tenant:

// server/src/agents/runtime/fs-endpoints.ts:130
`INSERT INTO agent_workspace (agent_id, path, body, meta, updated_at)
   VALUES ($1, $2, $3, $4::jsonb, NOW())
 ON CONFLICT (agent_id, path) DO UPDATE
   SET body = EXCLUDED.body, meta = …, updated_at = NOW()`

Every other writer of that table does — skills.ts:262, cli.ts:2713/4116/4122/4363, router.ts:2962, and the baseline backfill at migrate.ts:647 — and they refresh it on conflict too. This is the one path that doesn't, and it is the path an agent uses to write its own memory.

agent_climate — the column is TEXT NOT NULL DEFAULT 'personal' (migrate.ts:822) and neither of its two INSERT sites (climate.ts:57, cli.ts:4250) names it. So every climate row in every workspace is labelled 'personal', and DELETE FROM agent_climate WHERE company_id = 'co-acme' can never match one.

Demonstrated

Postgres 16, one agent in co-tenant, one row written the FUSE way and one written the way a peer writer does:

     path       | company_id
----------------+------------
 memory/note.md | <NULL>
 skills/x.md    | co-tenant

 deleted_by_company_scope | 1
 survives_deletion        | 1

Same agent, same table, same deletion — one row goes, one stays. The one that stays is the memory file.

The fix, in two halves

Stop producing them. fs-endpoints fills the tenant from the agent's participants row with a scalar subquery, so it stays a single round trip and still inserts when the lookup finds nothing — behaviour is unchanged where it was already correct. The ON CONFLICT arm uses COALESCE(EXCLUDED.company_id, agent_workspace.company_id), so a row already written without one heals on the agent's next write and a known tenant is never overwritten with NULL.

Reach the ones already written. The purge also sweeps the agent-owned subset by agent_id:

if (agentIds.length > 0) {
  for (const table of agentOwnedTables) {
    await client.query(`DELETE FROM ${table} WHERE agent_id = ANY($1::text[])`, [agentIds])
  }
}

agentIds is already built earlier in the same transaction for board_mention_reads. Deleting by owner needs no backfill migration to reach rows written before this, which matters because those rows exist in every deployment today. computers is deliberately not in the subset — it is keyed by the machine, not an agent. I confirmed against the live schema that all eight tables in the subset really do have an agent_id column.

Why the existing test didn't catch it

workspace-management.test.ts's purge test seeds every row with a correct company_id:

`INSERT INTO agent_runs (id, agent_id, company_id) VALUES ('run-managed', 'agent-managed', 'co-managed')`

That is the one shape the broken writers never produce, so the test passes on the broken code. The new tests seed the way the real writers do.

Verification

  • Five integration tests, and each half goes red on its own:
    • reverting only the purge change → tests 1 and 2 fail (memory and climate outlive the workspace)
    • reverting only the fs-endpoints change → test 5 fails (the row is written without a workspace)
  • Test 5 drives the real PUT /runtime/fs/write with a minted agent token rather than issuing the SQL itself, so it pins the writer, not my restatement of it.
  • Tests 3 and 4 pin the other direction: a row the company_id sweep already reached is still removed, and another workspace's agent keeps its data — deleting by owner must not reach past the workspace being deleted.
  • The existing workspace-management.test.ts stays green, 12/12.
  • Full unit suite against a live Postgres: 1095 pass, 0 fail. tsc --noEmit, biome lint ., all three source guards clean.

Related, not fixed here

agent_climate's writers should probably name company_id rather than relying on a 'personal' default that is wrong for every non-personal workspace — the column and its index (idx_agent_climate_company) exist as if it were meaningful, and AGENT_ID_CASCADE_TABLES scopes on it. But climate reads are per-agent and global by design (ADR 0004 says so explicitly), so changing what the column holds is a separate decision with its own blast radius. This PR makes deletion correct without taking that on.

The purge sweeps its soft-scoped tables with
`DELETE FROM <t> WHERE company_id = $1`, which is only as good as the
company_id the writers put there. Two of them do not put one.

agent_workspace: the agent's own filesystem endpoint wrote
(agent_id, path, body, meta) and no tenant, so every file an agent wrote
through it — its memory index included — landed with company_id NULL.
Every other writer of that table (skills.ts, cli.ts, router.ts, the
baseline backfill) supplies it, and also refreshes it on conflict.

agent_climate: the column carries DEFAULT 'personal' and NEITHER of its
two INSERT sites names it, so every climate row in every workspace is
labelled 'personal'. The delete can never match one.

Both survive the sweep. What is left behind is an agent's memory and the
notes it wrote about the people it worked with, for a workspace whose
owner asked for it to be deleted.

Demonstrated against Postgres 16: the FUSE-shaped insert and a peer
writer's insert for the same agent, then the sweep — one row deleted,
one row surviving.

Two halves:

- fs-endpoints fills the tenant from the agent's participants row via a
  scalar subquery, so it stays a single round trip and still inserts when
  the lookup finds nothing, exactly as before. The ON CONFLICT arm heals
  rows already written without it.

- The purge also sweeps agent-owned tables by agent_id. Those rows belong
  to agents that are about to cease existing, and deleting by owner
  reaches the rows already written tenant-less without needing a backfill
  migration.

The existing purge test cannot catch this: it seeds every row with a
correct company_id, which is the one shape the broken writers never
produce. The new tests seed the way the real writers do — and one drives
the actual PUT /runtime/fs/write endpoint, so the writer half is pinned
too, not just the sweep. Both halves go red when reverted separately.
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