Skip to content

fix(machine): route Machine DELETE through canonical page services + scrub agent refs#2043

Merged
2witstudios merged 4 commits into
masterfrom
pu/fix-machine-delete-canonical
Jul 13, 2026
Merged

fix(machine): route Machine DELETE through canonical page services + scrub agent refs#2043
2witstudios merged 4 commits into
masterfrom
pu/fix-machine-delete-canonical

Conversation

@2witstudios

@2witstudios 2witstudios commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Summary

GA-blocker remediation for deferred items 4 + 5 of the Machine/Terminal arc (page wm76dkca6cd2jwcldumys5c5, task cj9bdw5wj4yfgbje2uuflzfu): Machine DELETE previously soft-deleted its page via raw pageRepository.trash, bypassing the canonical page services. That meant no descendant cascade-trash, no revision bump / page version, no page-trash workflow triggers, and the deleted machineId was left dangling in AI_CHAT agents' machines jsonb arrays.

What changed

1. Canonical page-trashcreateDbMachineSettingsStore().trashPage now routes through pageService.trashPage(machineId, actorUserId, { trashChildren: true }), exactly what the page DELETE route (/api/pages/[pageId]) does: descendant cascade, applyPageMutation revision bump + page version, and deferred page-trash workflow triggers. The trashed broadcast keeps the canonical payload shape (title + parentId). A trash failure still throws (the non-recoverable first step), so deleteMachine never proceeds to teardown on a failed trash.

2. Delete-time ref scrub — new MachineRefScrub seam in deleteMachine, wired by createDbMachineRefScrub(actorUserId):

  • Every AI_CHAT agent whose machines array contains {kind:'existing', machineId} gets that entry removed via the canonical applyPageMutation (operation: 'agent_config_update', changeGroupType: 'system', per-agent expectedRevision) — same treatment as pageAgentRepository.updateAgentConfig. Only the matching element is removed; all other entries (including malformed ones) are preserved byte-for-byte. Per-agent failures don't stop the sweep; the failure is thrown at the end so the result reports it.
  • global_assistant_config.machines is rewritten too — the same MachineRef pair migration 0195 covered in the rename backfill; this is the delete-time counterpart.
  • Scrubbed refs are deliberately NOT restored when the Machine page is restored — a MachineRef is the referencing agent's setting; its owner re-links explicitly.

3. PR #1967 ordering/reversibility preserveddeleteMachine order is now trash (reversible, FIRST) → ref scrub → Sprite teardown (remote, LAST, so host errors land in the recoverable path). Both post-trash steps are independently best-effort with their own result flags (agentRefsScrubbed joins spriteTornDown) — a scrub failure never skips the Sprite kill (no leaked microVMs) and vice versa. Branch-Sprites-then-machine-Sprite teardown order and the "dependent machine_* metadata rows stay for restore" invariant are untouched.

Review fixes (a2f19c2)

  • Subtree Sprite teardown — nothing prevents nesting a MACHINE page under another, and the new cascade-trash hides nested Machines whose Sprites were never killed. Teardown now BFS-walks the trashed subtree (no isTrashed filter — it runs post-trash) and tears down every Machine in it, descendants first, root last; one failure never strands the rest, and any own-Sprite kill failure still reports spriteTornDown: false.
  • Disable machineAccess when the scrub empties the refsresolveConfiguredMachines / resolveGlobalConfiguredMachines treat machineAccess: true + machines: [] as a fallback to {kind:'own'} (the global path even auto-provisions a personal Machine), so removing an agent's last ref would have silently repointed it at a different machine. The agent write carries machineAccess: false in the same canonical mutation; the global-config UPDATE flips it via a CASE in the same statement.
  • Cycle-guard hardening (a33f3ad, self-identified, not reviewer-flagged) — collectDescendantMachineIds's subtree walk now tracks visited ids. Page moves reject cycles today, but a corrupt tree must degrade to a bounded walk, not an infinite teardown loop.
  • 439958a merges latest master (two unrelated sibling fixes — cron scheduling, Canvas CSP nonce — no file overlap with this PR's changes); no conflicts.

Out of scope (unchanged, documented in the module doc)

  • PATCH (updateSettings) still writes settings fields via raw db.update — canonicalizing the rename/toggle path is a separate follow-up.
  • machine_agent_terminals / machine_projects / machine_branches rows are deliberately left in place (FK-cascade on hard purge; kept for restore — per PR feat(machines): Machine Settings CRUD route (GET/PATCH/DELETE) #1967's reverted-hard-delete lesson).

Tests

  • Pure module (packages/lib): trash→scrub→teardown ordering, scrub failure isolation (delete still succeeds, teardown still runs), teardown failure isolation, trash failure propagation, not_found short-circuit — 9 pass.
  • Runtime (new, apps/web/src/lib/machines/__tests__/machine-settings-runtime.test.ts): canonical pageService.trashPage routing with trashChildren: true (the cascade + revision + trigger behavior is that service's own tested contract), trashed broadcast parity, failure propagation without broadcast, no hard deletes (restore-compatible), ref filtering incl. malformed-sibling preservation, last-ref scrub disabling machineAccess (and leaving it alone when refs remain), sweep-on-partial-failure then throw, global-config rewrite incl. the access CASE, nested-machine subtree teardown order, descendant-failure isolation, parentId-cycle termination, teardown no-op — 16 pass.
  • Route contract: store/scrub constructed as the deleting user, refs dep wired, agentRefsScrubbed in response + audit — 21 pass.
  • tsc --noEmit green in packages/lib and apps/web.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HMmatEURu4tTTrdjP8yYQR

…scrub agent refs

Machine DELETE previously trashed its page via raw pageRepository.trash,
bypassing the canonical page-trash path: no descendant cascade-trash, no
revision bump / page version, no page-trash workflow triggers, and the
deleted machineId lingered as a dangling ref in AI_CHAT agents' machines
jsonb arrays (GA blocker, deferred items 4+5 of the Machine/Terminal arc).

- MachineSettingsStore.trashPage now routes through pageService.trashPage
  (trashChildren: true) — cascade, revision/version, and trash triggers all
  happen exactly as on the page DELETE route; the trashed broadcast keeps
  the same payload shape.
- New MachineRefScrub seam in deleteMachine: after the trash and before the
  Sprite teardown, scrub the machineId from every referencing AI_CHAT
  agent's machines array (via canonical applyPageMutation, operation
  agent_config_update, changeGroupType system) and from
  global_assistant_config.machines (the same pair migration 0195 rewrote).
  Best-effort like the teardown — reported as agentRefsScrubbed, never
  fails the delete, never skips the Sprite kill.
- PR #1967's ordering/reversibility invariants preserved: trash FIRST
  (reversible), remote teardown LAST (host errors land in the recoverable
  path), dependent machine_* metadata rows still left in place for restore.

Tests: pure-module ordering + failure isolation; runtime canonical-trash
routing, broadcast, ref filtering (incl. malformed-sibling preservation),
sweep-on-partial-failure, global-config rewrite; route contract updates.
typecheck green (packages/lib + apps/web).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMmatEURu4tTTrdjP8yYQR
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@2witstudios, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 45 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3906ea82-2408-48cf-804c-6893edebac5d

📥 Commits

Reviewing files that changed from the base of the PR and between d6a4cde and 439958a.

📒 Files selected for processing (6)
  • apps/web/src/app/api/machines/settings/__tests__/route.test.ts
  • apps/web/src/app/api/machines/settings/route.ts
  • apps/web/src/lib/machines/__tests__/machine-settings-runtime.test.ts
  • apps/web/src/lib/machines/machine-settings-runtime.ts
  • packages/lib/src/services/machines/__tests__/machine-settings.test.ts
  • packages/lib/src/services/machines/machine-settings.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/fix-machine-delete-canonical

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fb6538ccf2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// route's canDeleteMachine gate is the same check, so this never flips
// an authorized delete to a failure.
const result = await pageService.trashPage(machineId, actorUserId, {
trashChildren: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Tear down descendant machines before cascading trash

With trashChildren: true, deleting a Machine now trashes every descendant page, but the later Sprite teardown still runs only for the top-level machineId. I checked pageService.createPage and it only verifies that a parent page exists, not that the parent is a folder, so a descendant can also be a MACHINE; in that case the child Machine page is hidden by this cascade while its own session and branch Sprites are never killed. Please either discover/teardown descendant Machine pages as part of this delete or avoid cascading Machine subtrees.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a2f19c2. createMachineSpriteTeardown().teardown now tears down the whole trashed subtree: collectDescendantMachineIds BFS-walks parentId from the deleted root (post-trash, so deliberately no isTrashed filter) and collects every MACHINE-typed descendant; each is torn down via the extracted teardownOneMachine (branch Sprites best-effort, then own Sprite), descendants first, root last. One machine failing never strands the rest — failures are counted and rethrown at the end so deleteMachine still reports spriteTornDown: false. Covered by new tests: nested-machine teardown order, descendant-failure isolation, and the no-op case.

await applyPageMutation({
pageId: agent.id,
operation: 'agent_config_update',
updates: { machines: filtered },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid defaulting agents to own machines after scrub

When the deleted Machine is the only configured ref for an agent, filtered becomes [] while machineAccess remains enabled. I checked resolveConfiguredMachines in apps/web/src/lib/ai/tools/sandbox-tools-runtime.ts, and page agents with machineAccess on and an empty machines array fall back to { kind: 'own' }, so deleting a shared Machine silently switches that agent to a different Machine instead of leaving it without the removed configuration. Please disable machine access or preserve an explicit empty selection when the last ref is scrubbed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a2f19c2. Confirmed against resolveConfiguredMachinesmachineAccess: true + machines: [] does fall back to {kind:'own'}. The scrub now selects machineAccess alongside the refs and, when filtering empties the list for an agent that had access on, the same applyPageMutation write sets machineAccess: false (updates + updatedFields both carry it, single canonical mutation). The agent's owner re-enables and re-links explicitly. Tests: last-ref scrub disables access; remaining-refs scrub leaves machineAccess untouched.

.update(globalAssistantConfig)
.set({
machines: sql`(
SELECT coalesce(jsonb_agg(elem), '[]'::jsonb)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid defaulting global assistants to own machines

For a global assistant config whose only entry is the deleted Machine, this rewrite stores [] but leaves machineAccess true. I checked resolveGlobalConfiguredMachines in apps/web/src/lib/ai/tools/sandbox-tools-runtime.ts, and that exact state falls back to { kind: 'own' } and can provision/use the user's personal Machine, so removing an existing Machine unexpectedly changes the assistant's active machine rather than clearing access. Please update machineAccess (or otherwise distinguish explicit empty config) when the scrub removes the final ref.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a2f19c2. The global_assistant_config UPDATE now also sets machineAccess via a CASE that flips it to false when no elements survive the filter (NOT EXISTS (SELECT 1 FROM jsonb_array_elements(machines) elem WHERE NOT (elem @> ref))). Both SET expressions read the OLD row in the same statement, so the rewrite and the emptiness check see the same pre-update list — no window where access stays on over an emptied config, and no fallback to auto-provisioning the user's personal Machine via resolveGlobalConfiguredMachines.

2witstudios and others added 2 commits July 12, 2026 23:30
…rub empties refs

Review fixes (PR #2043, 3 threads):

- Teardown now covers the whole trashed subtree: nothing prevents nesting a
  MACHINE page under another, and the new cascade-trash hides nested Machines
  whose Sprites were never killed. collectDescendantMachineIds walks the tree
  (post-trash, so no isTrashed filter) and every Machine is torn down
  descendants-first; one failure never strands the rest, and any own-Sprite
  kill failure still surfaces as spriteTornDown=false.

- Scrubbing the LAST ref now disables machineAccess (agent pages and
  global_assistant_config alike): resolveConfiguredMachines /
  resolveGlobalConfiguredMachines treat access=true + machines=[] as a
  fallback to {kind:'own'}, so an emptied list would have silently repointed
  the agent at a different machine (and auto-provisioned a personal Machine
  on the global path). The owner re-enables and re-links explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMmatEURu4tTTrdjP8yYQR
Page moves reject cycles, but a corrupt tree must degrade to a bounded walk,
not an infinite teardown loop — visited-set guard + test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMmatEURu4tTTrdjP8yYQR
@2witstudios 2witstudios merged commit f442fe6 into master Jul 13, 2026
10 checks passed
@2witstudios 2witstudios deleted the pu/fix-machine-delete-canonical branch July 13, 2026 13:58
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