Repository navigation
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
📝 Walkthrough
|
Model: Claude Opus 5.5. Rebased onto current main with Claude Opus 5.5 on 2026-10-12.
Stacked on #16047 (and #15010). Review only the top 6 commits: 3fa503e.
Problem
A plugin cannot react to the one thing people most often want it for: "a turn finished". There is no durable OV2 record that a run and its follow-up work are done.
thread.settledis the sidebar's parking state, and the terminalrun.updatedlands before the checkpoint capture and workspace refresh. A plugin that keyed off either would fire early, twice, or not at all after a restart. There is also no path from the event log to a plugin process, so the plugin host from the previous stacked PR has nothing that actually launches a plugin.This PR adds both halves of one concern: plugins receive run lifecycle events at least once, in order, from an acknowledged cursor that survives restarts. Nothing user-visible changes in the web, desktop or mobile UI.
Why this qualifies
This is the proposal route in CONTRIBUTING. It needs two approvals, and no maintainer has agreed to either yet:
run.finalized/run.finalization-failedthread events in the orchestration contract (on #6837 or a new Ideas post). Its only consumer today is plugin events, which is why it ships in this PR instead of on its own.It stacks on the plugin host PR (supervised child processes + catalogue + consent). If the answer is no, we close this and the plugin PRs above it. Previous PR in this stack: feat(server): run consented local plugins in supervised child processes (#16047).
Fix
Run finalization (OV2).
run.finalized { runId, outcome: completed | failed | interrupted | cancelled, checkpointId | null }andrun.finalization-failed { runId, operation: capture-checkpoint | refresh-workspace | record-finalized }as thread events (and their JSON arms). Clients without this PR skip them as unknown events.run.finalizedfromRunFinalizationServiceafter capture and workspace refresh succeed. When the effect worker gives up on its last attempt, the failure record commits in the same transaction that fails the outbox row; a cancelled capture records the failure in the cancelling commit; an interrupted capture is replayed after restart, never recorded as a failure.EventSink, so a new terminal path needs no extra wiring. Thread activity,thread.settledand the relay are unchanged.Plugin events.
"capabilities": ["events"]with"proposedApi": trueand registerscontext.proposed.onEvent(handler). A server without this PR refuses the plugin instead of running it without delivery.pluginEvents.ts): only those two event types, each withdeliveryId(stable dedupe key),sequence,occurredAt,environmentId,threadId,runId, the thread'sprojectIdand title (≤200 chars,nullif the thread was deleted), andoutcomeoroperation. No message text, no provider ids.PluginEventFeed.ts, migration 058plugin_event_cursors): one cursor per installation, started at the end of the log when the installation is enabled (no history backfill). The feed reads bounded pages from the durable event log and invokes the host-reservedt3.eventshandler through the plugin catalogue. The cursor moves only after the plugin acknowledges the page.EventSinkcommits only wake the feed through a sliding subscription, so orchestration never waits on a plugin.plugins.resumeclears quarantine; re-enable and restart retry from the cursor. A stored event the server cannot read (bad payload or bad identifiers) stops delivery in front of it, quarantined with 0 failures, until it is repaired and resumed; it is never skipped. Storage errors are never counted against the plugin. Disable stops delivery, enable continues from the cursor, remove forgets it.PluginInstallation.eventDelivery(forward-compatible optional) reportsactive | retrying | quarantinedso a client can show it; the management UI PR renders it.t3.are host-reserved:handle("t3.…")throws in the plugin process. Later stacked PRs open exactly three names, each with its capability:t3.tool.<name>(tools),t3.transform.enrich(context transforms) andt3.approval.decide(approvals). Every othert3.*stays refused.No new RPC:
plugins.resumealready exists and keeps itsaccess:writescope.Size: 34 files, +3405 / −82. About 1.7k of the added lines are tests.
Evidence
Environment: macOS arm64; this PR on top of the plugin host PR.
How to exercise it (isolated
vp run dev, administrative session): add, consent and enable a plugin whose entry callscontext.proposed.onEvent(e => appendFile(log, JSON.stringify(e))), then run a turn with any provider. Onerun.finalizedline appears after the turn's checkpoint lands, and the stored cursor equals that event'ssequence.Live trace (run on an earlier revision of this PR with an identical patch, before the stack was restacked; isolated server on a fresh home, macOS 26 arm64, Node 24; real Claude turns (
claudeAgent,claude-opus-5-5) through the isolated server's default provider configuration; the fixture plugin appends each event to a file outside its directory, deduplicated byenvironmentId+deliveryId):"capabilities": ["events"]this server does not support eventscompleted, 0run.finalizedrecords, no cursor table, 0 plugin processesrun.finalizedat sequence 38, right after the checkpoint; plugin process launched; handler wroteTurn finished in "…turn 1": completed; cursor = 38What the trace shows, in order:
checkpoint.captured(33, 34),run.updated(36), thenrun.finalized(38). The host launches the plugin, the handler runs, and the cursor is acknowledged at 38.plugins.listshows hostrunning,eventDelivery: active.plugins.subscribeshowsretryingwith failures 1–4 (retries 4 s, 8 s, 16 s apart), thenquarantinedwith 5 failures. The server logsQuarantined a plugin's event delivery; the cursor stays at 38 while the log head is 75. Once the handler would succeed, delivery stays quarantined untilplugins.resume, which delivers the page once (7th attempt) and moves the cursor to 75.Plugin failed; backing off (was killed by SIGKILL, 1000 ms), starts a new process (new pid), and the same page is delivered again and acknowledged (cursor 112). The plugin's dedupe kept one notification.deactivatenever settling, the firstplugins.disableis interrupted by its client after 300 ms while the process is still alive. The retrieddisablereturns 1.7 s later (2.0 s after the first call, the stop grace) and the process is gone.run.finalized(149), but nothing is delivered and the cursor stays at 112. The server is stopped and started on the same home; the installation is still disabled and nothing is delivered.enabledelivers sequence 149 from the cursor, and no acknowledged event is delivered again.--sharerun below) left the cursor at 153 and the attempt count unchanged.Remote pass (
vp run dev --share, same isolated home, clients reached the server only through the tailnet HTTPS origin): a standard client readeventDelivery: activethroughplugins.list, then followedplugins.subscribewhile a remote administrator ran a Claude turn with the handler failing. It sawidle → starting → running,retrying1–4, thenquarantined(5). Its ownplugins.resumewas refused (requiredScope=access:write); the administrator's resume delivered the page, and the standard client'splugins.listshowedactiveagain.Trace excerpt (delivery, quarantine, crash)
Checks at this head (
55906e7fbb), re-run 2026-10-05 (CI=true, all exit 0):vp test run(apps/server) onRunFinalized.test.ts,RunFinalizationService.test.ts,EffectWorker.test.ts,PluginEventFeed.test.ts, the two migration-manifest tests, plus the plugin host's catalogue, supervisor and RPC-scope tests: 9 files, 88 tests pass. ContractsorchestrationV2.test.ts+pluginCatalog.test.ts: 2 files, 36 tests pass.RunFinalized.test.tsandPluginEventFeed.test.tsfail 29 of 30 tests (measured before the unreadable-identifier test was added); with all production files reverted, 5 server and 2 contracts tests fail and the two new files cannot load. The unreadable-identifier test fails against the previous revision of this PR (the worker died without quarantine, so Resume could not recover it) and passes now.RunFinalized.test.tsruns the real effect worker, outbox, SQLite event sink and projection, finalization service and restart recovery; only capture and refresh are stubbed.PluginEventFeed.test.tsruns the real catalogue and supervisor with real plugin child processes. Both wait on persisted events, worker drains and the feed's receipts; retry backoff is crossed withTestClock. None sleep.vp run --filtertypecheck for@t3tools/contracts,t3and@t3tools/client-runtime;vp lint --report-unused-disable-directivesandvp fmt --checkon the touched files (one lint warning, unchanged from the parent: an unuseddaemonLayerinEffectWorker.ts);vp run knip:check; web build;vp run build:desktop;node scripts/release-smoke.ts. All pass.Surfaces
context.proposed.onEvent; administrators use the existingplugins.resume. No settings, command palette or keybinding.eventDeliveryis added to installation snapshots; the management UI PR displays it. Clients that do not know it ignore it.run.finalizedthrough the same rule. Completed, interrupted and cancelled runs finalize after their checkpoint; failed runs finalize in their terminal commit; rolled-back runs never do. The plugin feed neither reads nor filters by provider. Live delivery at this head was exercised with Claude.eventDeliveryinstallation field. New client + old server: no events, field absent (treated as unknown). Old client + new server: events are skipped as unknown; field ignored.plugins.resume; disable ↔ enable (continues from the cursor); remove forgets the cursor. Arun.finalization-failedrun never later recordsrun.finalized.docs/internals/overview.md(exactly-one finalization record, committed with its work; EventSink owns the no-capture paths). No user doc yet: users cannot install plugins until the management UI PR, which adds the guide.Not verified
run.finalized(by design); app-owned subagents finalize in their own threads.environmentId+deliveryId.eventDeliveryused the tailnet (--share) only, with a scripted RPC client rather than a browser (no client renders the field yet). No relay or T3 Connect tunnel run.Claude Opus 5.5 (build) and GPT-6.1 Sol (review) via T3 Code
🤖 Generated with Claude Code