Skip to content

feat: agent background edits on notebooks#586

Open
emrberk wants to merge 7 commits into
mainfrom
feat/agent-passive-notebook-edits
Open

feat: agent background edits on notebooks#586
emrberk wants to merge 7 commits into
mainfrom
feat/agent-passive-notebook-edits

Conversation

@emrberk

@emrberk emrberk commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Depends on #581

Tandem PR: questdb/mcp-bridge#1

What

Agents could previously only edit the notebook the user had open, and was stealing focus of the users while editing a notebook. This PR lets the MCP bridge and the built-in assistant work on any notebook in the background.

  • The agent edits a notebook in the background, without stealing the focus
  • The user gets notifications about the agent work, and can navigate to the last edit using the notification popper, or MCP pair popper
  • The agent has activate_notebook tool for navigating the user if requested

How it works

  • Dexie controller (notebookDexieController.ts): every op on an unmounted buffer is a queued read → pure transition → write on the persisted buffer row. No mounting, no hidden React tree.
  • Per-buffer FIFO queue (notebookBufferQueue.ts): all writers (agent ops, mount seed read, unmount flush, debounced persists) are strictly ordered per buffer — no lost updates at the mount/unmount boundaries.
  • Mount-claim protocol: when the user opens a buffer mid-edit, the claim + seed read enqueue atomically; in-flight agent ops fail with a typed mounted_mid_edit error telling the agent to re-sync and retry.
  • Headless run_cell (notebookHeadlessRun.ts): runs cells on unmounted notebooks with a verification outcome machine (notebook/cell deleted, user mounted mid-run, cell changed mid-run, superseded) — results are persisted and reported as unverified with a note when the run can't be attributed cleanly.
  • Freshness gating: mutating tools are rejected with STATE_STALE if the user edited the notebook after the agent's last read (per-buffer action sequence, baseline captured before the read). apply_notebook_state is deliberately not up-front gated — it's a wholesale PUT; it keeps its internal mid-apply staleness re-check.
  • activate_notebook tool: brings a buffer to the foreground and reveals a cell (used on user request; intentionally ungated — accepted risk).
  • Footer notifications: agent edits to non-active buffers surface as an unread-style "(new changes)" state on the MCP pill (cyan accent + blink) with a transient popper and a persistent row in the pair popover; cleared when the popover is opened or the user visits the buffer.

Testing

  • New unit tests: queue ordering, mount-claim races, headless run outcomes, freshness baselines, storage cap (max 10 notebooks' results), NOTICE mapping. Suite: 1,418 passing.
  • New e2e spec agentBackgroundEdits.spec.js + shared mcpFakeWebSocket.js test util.
  • Typecheck, lint, build green.

Comment thread e2e/utils/mcpFakeWebSocket.js
Base automatically changed from feat/notebook-cell-toolbar-redesign to main July 14, 2026 08:46
@emrberk emrberk force-pushed the feat/agent-passive-notebook-edits branch from 5e89510 to 5012b6f Compare July 14, 2026 10:20
Comment thread e2e/utils/mcpFakeWebSocket.js
emrberk and others added 6 commits July 14, 2026 15:13
Address review findings on the agent-passive-edit path:

- #8 archived preview: seed a read-only preview from the persisted view
  instead of erroring; NotebookProvider gains a `preview` prop that skips
  live-controller registration and persistence; key <Notebook> on archived
  so restore remounts cleanly. Also fixes the restore-from-search stale
  archived flag this surfaced.
- #6 archive/commit race: commitView re-reads the row inside a Dexie
  transaction and returns committed|archived|deleted, rejecting archived
  and deleted rows atomically with the write.
- #3 reconnect freshness: per-generation guard so an in-flight read that
  spans a reconnect reset no longer seeds freshness for the new connection.
- #7 apply_notebook_state: report state_applied + post_apply_aborted on a
  post-commit abort instead of a generic failure.
- #9 hide-result: bump the freshness seq so a stale agent read is caught.
- #15 agent-change toast a11y: persistent live announcer, return focus to
  the pill, split hover/focus auto-hide pause.
- Surface the active tab's archived state to the agent (workspace + digest).

Tests: freshness generation guard, commitView archived/deleted, and the
apply_notebook_state post-commit abort path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Buffer name limit:
- Add MAX_BUFFER_NAME_LENGTH (100) and exceedsBufferNameLimit in the
  buffer store. Enforce on the SQL/Metrics tab rename (clamp on commit),
  the notebook rename input (maxLength), and the create_notebook agent
  tool (schema maxLength + dispatch validation). Existing longer names
  are never re-validated.

Headless notebook runs:
- Replace the generation-counter supersession with an AbortController so
  a newer run cancels the older one mid-flight, not just discards its
  result — this stops a superseded multi-statement script from executing
  later statements. Guarantee slot cleanup via finally.
- Distinguish a notebook archived mid-run from one deleted mid-run, with
  a dedicated agent note (NOTEBOOK_ARCHIVED_MID_RUN_NOTE).
- Wrap long agent-change labels in the footer popovers.
- Add coverage for the above.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@emrberk

emrberk commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author

Review: PR #586 — feat: agent background edits on notebooks

Reviewed at level 3 (the full mission-critical pass, all 13 agents + per-finding verification).

Local HEAD 58f540e8, base merge db1f97f2. Quality gates all green (typecheck, lint, build, 1506 unit tests).

This is a large, genuinely well-engineered PR. The core concurrency machinery — per-buffer FIFO queue, mount-claim/epoch model, commitView's in-transaction archived/deleted guard, freshness generation guard, headless-run supersession — holds up under adversarial tracing. The findings are edge-case follow-ups, not structural failures. Nothing here is a merge blocker on correctness of the happy path, but several are worth fixing before ship.

Issues

ID Issue Category Severity Location Description / reproduction Suggested fix
#1 Dismiss permanently kills the popper React correctness & hooks Moderate in-diff useAgentChanges.ts:29,48,100 dismissedRef is a latch set true on dismiss and never reset anywhere (grep-confirmed: 3 refs). The onAgentEdit subscriber gates the toast on if (!dismissedRef.current). The hook never unmounts (footer is permanent). Repro: agent edits background notebook B → popper shows → click X → agent edits C → no popper ever again this session. Pill blink + pair-popover row still update, so it's not silent, but the primary proactive toast is gone. Make dismiss per-notification: reset dismissedRef when a new edit arrives, or compare dismissal against latest's identity instead of a global latch.
#2 Stale result rows under agent-rewritten SQL Query execution & data integrity Moderate in-diff NotebookProvider.tsx:243-257, notebookTransitions.ts:97-112 Grid hydration matches snapshots by cell.id only (gated if (cell.result) return cell), with no SQL-match guard — unlike DrawCanvas which gates on resultMatchesQueries. updateCellTransition rewrites value but emits no cleanup, leaving the persisted snapshot. Repro: run cell C (SELECT * FROM trades), close notebook; agent update_cell(C, "SELECT * FROM orders") on the unmounted notebook; reopen → grid shows trades rows under orders SQL, no error. The base editor has a similar "edit-without-rerun" quirk, but this PR makes it happen invisibly (user never touched the cell). Persist a sqlHash on the snapshot and skip/flag hydration on mismatch, or have updateCellTransition emit cleanup.cellIds when value changes.
#3 Server NOTICE hidden when timings present Styling & theming / A11y Moderate in-diff StatusNotification.tsx:107-128 When a DQL result has both timings and notice (both independent optional fields), the visible body renders only <QueryResult> timings; the notice text lands only in title= (hover-only, no keyboard/touch) and a one-shot live region. The no-timings branch (line 133) correctly shows {notice ?? "OK"}. So a normal successful query carrying a server notice (implicit cast, truncation warning, etc.) drops the message from the visible UI. Render notice visibly alongside the timings block; don't rely on title/live-region alone.
#4 Loading fallback announces nothing Accessibility & UX Moderate in-diff Notebook/index.tsx:854, LoadingSpinner <SeedFallback role="status" aria-live="polite"> contains only an unlabeled Loader3 SVG (no aria-label, no text, not aria-hidden), and no aria-busy. An empty polite region announces nothing; SR users get zero feedback that a notebook is loading. The PR already ships a visually-hidden Announcer pattern to reuse. Add visually-hidden "Loading notebook…" text + aria-busy, mark the spinner aria-hidden.
#5 Headless run commits under stale globals Query execution & data integrity Moderate in-diff notebookHeadlessRun.ts:230-238,275-319 Variables are captured at prep; the commit gate re-checks claim/epoch/supersede/bufferSeq/cell SQL but not settings.variables. bufferSeq only advances on USER edits, so a concurrent agent changing @sym (via apply_notebook_state) between prep and commit passes all guards: commitView writes the new variables plus cell rows computed under the old ones. Narrow — needs two concurrent agent surfaces (MCP bridge + built-in assistant, or two MCP clients) since one flow runs tools sequentially. Capture a variables fingerprint at prep, compare in the commit gate; on mismatch return unverified with a note (mirror cell_changed).
#6 Redundant Dexie read on mounted snapshot path Performance & rendering at scale Moderate in-diff notebookSnapshot.ts:161-172 buildSnapshot always runs enqueueBufferTask(readNotebookBufferMeta) — a Dexie getById + two O(cells) migration passes — then for a mounted notebook discards meta.view and uses the synchronous live controller.readView(). meta is needed only for label + status. Wasted async round-trip and double clone on the hot agent read path (get_notebook_state / freshness re-sync). Split into a lightweight meta read (no view migration); only migrate/clone the view in the unmounted branch that consumes it.
#7 Per-cell script result bytes uncapped Performance & rendering at scale Moderate in-diff notebookHeadlessRun.ts:186 capResultBytes(..., NOTEBOOK_BYTE_CAP) caps each statement independently. A multi-statement script cell can hold q × 2 MB in the in-memory CellResult and in the persisted snapshot — the per-statement cap doesn't bound the per-cell aggregate. (The prior review's Θ(q²) clone claim is falseresults[i]= is in-place mutation, not cloning.) Budget a whole-cell aggregate byte cap across statements, or cap the persisted snapshot payload separately.
#8 Background runs evict the open notebook's results Persistence & migrations Moderate in-diff persistCellSnapshot.ts:22, notebookResults.ts:53-71 Every snapshot save unconditionally calls pruneToRecentNotebooks(), keeping only the 10 notebooks with newest savedAt, with no awareness of which notebook is mounted. Repro: user has A open (results saved a while ago); agent runs cells across ≥10 other background notebooks; each prunes; A drops to 11th-newest and its snapshots are deleted while open. On reload A shows empty result areas (only lastRunStatus survives). Re-runnable, so offline-restore loss, not permanent. New: pre-PR only the active notebook wrote snapshots. Exempt the active/mounted buffer(s) from pruning, or prune by total bytes rather than a flat notebook count.
#9 Missing tests on new UI/durable paths Test review & coverage Moderate in-diff (footer, hydration) useAgentChanges.ts (133 lines) has zero unit tests — none of #1/#14/#15 or the view() stale-guard/failed-activation branches are covered. Also untested: stale-snapshot hydration under changed SQL (#2), commitView debounced search publish, apply_notebook_state markdown-preserve auto-run guard, archived-search restore. The core controller/queue/freshness/headless layers ARE well tested. Extract the notification state to a testable reducer; add hook + unit tests for the listed paths.
#10 apply_notebook_state misreports after commit Async, timers & cancellation Minor in-diff applyNotebookState.ts:445-464 The catch handles committed && isAbortErrorstate_applied:true, but a non-abort NotebookToolError from the post-commit readBasics()/auto-run (e.g. user deletes/archives in the window after commit) falls to line 455 and returns a bare error with no state_applied. Agent may re-PUT (though the re-PUT then fails on the gone notebook, so real duplication risk is small). When committed is set, always include state_applied:true regardless of error kind.
#11 commitView failures mislabeled "storage_full" Async, timers & cancellation Minor in-diff notebookHeadlessRun.ts:314-322 catch { return { reason: "storage_full" } } wraps commitView, which can reject for non-quota reasons (transaction abort, version upgrade). All surface to the agent as STORAGE_FULL_RUN_NOTE, and the real error is swallowed without logging. Log the real error; only map genuine quota errors to storage_full, else a generic commit_failed note.
#12 activate_notebook drops its AbortSignal Async, timers & cancellation Minor in-diff dispatch.ts (activate_notebook), NotebookWorkspaceBridge.tsx:145 activateNotebook(buffer_id, cell_to_focus) takes/threads no signal. On MCP cancel/deadline the tab switch + focus write still complete while the result is suppressed; agent can't observe success. Benign (idempotent UI action). Thread signal; bail before setActiveBuffer when aborted.
#13 activate focus-write skips the commitView guard Persistence & migrations Minor in-diff NotebookWorkspaceBridge.tsx:197 The unclaimed activate path does getById→check→bare bufferStore.update({notebookViewState}) (not a transaction, no in-txn archived re-check) — the exact TOCTOU commitView was added to close. User archiving in the read→update window lets a stale focus write land on the archived row. Cosmetic (only focusedCellId/maximizedCellId; archived preserved). Route the focus write through commitView or a db.transaction("rw") that re-checks archived.
#14 Stale activeIdRef can drop a notification React correctness & hooks Minor in-diff useAgentChanges.ts:31-35,45 activeIdRef is synced in a passive effect; the listener reads it at event time. In React 17 there's a post-commit window where state advanced to buffer 7 but the ref still holds 3; an emitAgentEdit({bufferId:3}) in that window is suppressed as "active" though 3 is no longer active. The derived guard can't resurrect it. Narrow (~one frame). Drop the event-time ref gate; rely solely on the derived hasUnseen = latest.bufferId !== activeId.
#15 Auto-hide can strand "paused" React correctness & hooks Minor in-diff AgentChangesPopper.tsx:102-116 If the portaled Card unmounts while the pointer is over it (click View/Dismiss, or pair popover opens), onPointerLeave never fires, so hovered stays true and pushes autoHidePaused=true; the next popper then never auto-hides until the pointer moves over-and-off. Reset hovered/focused to false when open goes false.
#16 Same-buffer edit not re-announced Accessibility & UX Minor in-diff MCPBridgeStatus/index.tsx:211-215 Two consecutive edits to the same background buffer produce byte-identical live-region text, so React writes no DOM change and SR users aren't told of the second change. Vary the announced string with a changing discriminator (cell name / monotonic count).
#17 Agent deletion vanishes silently State & context architecture Minor in-diff NotebookWorkspaceBridge.tsx:138 deleteNotebook calls archiveBuffer(id, "agent") and never emitAgentEdit, while create/duplicate/edit all do. An agent deleting an inactive notebook removes the tab with no footer feedback. (Nuance: the popper's view() couldn't open an archived buffer anyway, so a plain edit-style notice wouldn't work — needs a delete-flavored one, or an explicit comment that deletes intentionally don't notify.) Emit a delete-flavored activity rendered as "deleted ", or document the intentional omission.
#18 Loading spinners ignore reduced-motion Accessibility & UX Minor in-diff/adjacent index.tsx SeedFallback, result-table/styles.ts:84-103 The new loading spinners animate unconditionally, while this PR did gate the pill pulse behind @media (prefers-reduced-motion: no-preference) — the standard is set but not applied to the spinners. spinAnimation is shared/pre-existing; the new SeedFallback usage is in-diff. Wrap the spin keyframes in the same reduced-motion guard.
#19 Popper z-index below overlays Styling & theming Minor in-diff AgentChangesPopper.tsx:109 OVERLAY_Z_INDEX - 1 = 99 sits below the app overlay (100), modals (101), tooltips/popovers (1000), menus (9999); the sibling PairPopover uses 200. Likely intentional (suppressed under the pair popover) but undocumented and inconsistent — a footer-anchored tooltip would occlude it. Use a deliberate token or add a one-line intent comment.
#20 Passive commits amplify full-table re-render Performance & rendering at scale Minor (pre-existing, amplified) out-of-diff EditorProvider/index.tsx:176,842 — unmemoized value + useLiveQuery(getAll) Pre-existing: the provider value is a fresh literal each render and every db.buffers write invalidates the whole-table live query, re-rendering all useEditor consumers. This PR turns background agent commits into a new, frequent write source, so what was idle-time cost is now per-agent-op. The 300ms search-publish debounce mitigates the search event but not the underlying per-commit bufferStore.update. Out of scope to fully fix; consider memoizing the value and splitting the frequently-changing buffers slice into its own context (as AIConversationProvider already does).

False-positives

Category Draft claim Why dismissed
Performance executeCellQueries clones the results list q times → Θ(q²) False. results[i]=/results[j]= are in-place mutations of a once-built array; the loop is O(q). (Real issue is the aggregate byte cap — issue #7.)
Styling Long notebook labels push View/close off the card False. Both AgentChangesPopper Message and PairPopover AgentChangesMessage already have flex:1 1 auto; min-width:0; overflow-wrap:anywhere, buttons flex-shrink:0, Card max-width. Overflow is bounded.
Accessibility / UX Edits to notebook B are lost when the agent then edits C (single latest) Intended. The PR scopes to "navigate to the last edit", not a notification inbox; B's durable changes remain. The pair-popover row (PairPopover.tsx:527) also keys off the single latest by design.
State & context An obsolete workspace read seeds freshness after MCP reconnect, letting the new connection mutate an unread notebook False. readGeneration is captured synchronously before any await and recordRead(...readAtGeneration) no-ops when the generation moved; reset() bumps it on connect/disconnect. Covered by notebookFreshness.test.ts:92.
Query integrity Notebook A's seed leaks into B on tab switch False. <Notebook key={activeBuffer.id...}> fully remounts NotebookProvider per buffer, cancelling A's seed lifecycle.
Browser compat & security Agent-supplied SQL / variable values enable injection or XSS False. validateVariableShape rejects any non-clean @name := value (tested); markdown goes through react-markdown without rehype-raw; labels render as escaped JSX text; sanitizeForPromptContext strips </> before prompt context. No sink found.
Persistence commitView can "pretend it committed" and surface a hidden edit after archive False. The rw transaction serializes against archive; archive-first ordering is rejected atomically before emitAgentEdit. Commit-first ordering is honest (the edit genuinely happened first).
Cross-context Moved modules leave dangling imports / broken tool-schema callsites False. tsc --noEmit = 0 errors; every importer of the old utils/aiAssistant/notebookAIBridge/executeAIFlow paths is migrated; all tool arg keys match shared-definitions.json; archived-search preview uses a deliberate dual mount path that never hits the archived-rejecting durable mount.

Summary

Verdict: approve with minor changes. No critical or data-loss-on-the-happy-path defects. The architecture is sound and the core is well tested. Fix the small cluster that's genuinely user-facing before ship: #1 (one dismiss kills the popper for the session), #2 (stale rows under agent-rewritten SQL — a data-trust issue), #3 (server notice hidden), and #4 (loading state announces nothing). The rest are edge-case/perf/polish follow-ups.

  • Draft findings verified vs dropped: ~28 raw agent findings → 20 confirmed, 8 dropped as false positives (listed above).
  • Severity split: 0 Critical, 9 Moderate, 11 Minor.
  • In-diff vs out-of-diff: 19 in-diff, 1 out-of-diff (web console/refactor jquery modules #20, EditorProvider — pre-existing arch amplified by the new write source). Agent 12's cross-context sweep confirmed no broken external contract: tsc is clean, all moved-module importers are migrated, and the archived-search interaction with the new durable mount is safe by design. A near-zero out-of-diff count here is expected — this PR adds a parallel subsystem behind a single door (withBoundNotebook) rather than changing the shape of a widely-consumed existing symbol.
  • Regressions/tradeoffs: two pre-existing issues that this PR meaningfully amplifies rather than introduces — the full-table live-query re-render (web console/refactor jquery modules #20) and the multi-tab last-write-wins on the shared buffer row (no compare-and-swap). Neither blocks, but both now fire on background agent activity, not just user actions; worth an explicit "single-tab / re-render cost accepted" decision.

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