Compare commits

...
Author SHA1 Message Date
candieduniverse 9c652edd67 Add smoke scenario for state coalescing validation 2026-03-17 13:06:07 -07:00
candieduniverse 132956852f Compare coalesced and immediate snapshot bursts 2026-03-17 13:06:07 -07:00
candieduniverse c54c41a55a Verify latest snapshot convergence after coalescing 2026-03-17 13:06:07 -07:00
candieduniverse de7c799a36 Add burst validation for state coalescing cadence 2026-03-17 13:06:07 -07:00
candieduniverse dedacaee21 Cover task switch state snapshot convergence 2026-03-17 13:06:07 -07:00
candieduniverse 127e612c7e Plumb state coalescing metrics into completion telemetry 2026-03-17 13:06:07 -07:00
candieduniverse cb4ee08858 Document remaining state coalescing validation work 2026-03-17 13:06:07 -07:00
candieduniverse 61b15348e5 Verify startup state hydration for coalesced snapshots 2026-03-17 13:06:07 -07:00
candieduniverse a8eb3211fc Verify task snapshot selection during state switching 2026-03-17 13:06:07 -07:00
candieduniverse e5c1b5c692 Cover task switching state coalescing flows 2026-03-17 13:06:07 -07:00
candieduniverse 35effd13b3 Expand state coalescing regression coverage 2026-03-17 13:06:07 -07:00
candieduniverse 8c2929471a Update coalescing plan progress 2026-03-17 13:06:07 -07:00
candieduniverse c8a7b4f036 Restore state subscription coverage 2026-03-17 13:06:07 -07:00
candieduniverse 8dab3072a4 Normalize scheduler file formatting 2026-03-17 13:06:07 -07:00
candieduniverse d40cc337d3 Implement state update priority audit coverage 2026-03-17 13:06:07 -07:00
candieduniverse 0d1afe06bb Document scheduler priorities and verify coalescing tests 2026-03-17 13:06:07 -07:00
candieduniverse fe5f2499d8 Add controller state update coalescing foundation 2026-03-17 13:06:07 -07:00
candieduniverse 418bcab53b Create implementation plan doc 2026-03-17 13:06:07 -07:00
18 changed files with 1709 additions and 11 deletions
+6
View File
@@ -125,6 +125,12 @@ ERROR_SERVICE_API_KEY=your-posthog-error-tracking-api-key
# E2E_TEST=true
# IS_TEST=true
# Remote workspace latency tuning
# CLINE_STATE_UPDATE_CADENCE_MS=16
# CLINE_REMOTE_STATE_UPDATE_CADENCE_MS=110
# CLINE_STATE_UPDATE_LOW_CADENCE_MS=40
# CLINE_REMOTE_STATE_UPDATE_LOW_CADENCE_MS=150
# ============================================================================
# USAGE INSTRUCTIONS
# ============================================================================
@@ -0,0 +1,462 @@
# Technique Plan: Controller Full-State Coalescing
This document is the implementation plan for the **controller full-state coalescing** technique identified in `docs/remote-workspace-latency-branch-analysis-report.md` as one of the top four highest-impact improvements for remote-workspace UX.
The key idea is:
> **Full state is a snapshot transport, not a token transport.**
When Cline is actively streaming, repeatedly rebuilding and sending large `ExtensionState` snapshots is one of the biggest avoidable costs in remote mode. Even if each snapshot is “not that large,” the repeated serialization, transport, parsing, and frontend reconciliation create visible UI churn.
This technique reduces that cost by coalescing repeated `postStateToWebview()` requests into a scheduler-driven snapshot flow with priorities and remote-aware cadence.
## How To Use This Plan
This plan is for extracting a coherent technique into its **own branch**, while treating `eve_troubleshooting-remote-workspaces` as the **fully developed reference implementation**.
That means the branch work has already been done once. The goal now is not to rediscover the architecture from scratch; it is to produce a smaller, easier-to-review implementation plan that tells a developer exactly how to extract and verify the technique with high confidence.
Be smart about this. Continually compare your work to the reference implementation and actively take implementation details from it when executing each step. The reference branch should be your source of truth for subtle behaviors, edge-case handling, and interactions with the rest of the product surface.
## Developer Operating Posture
This technique sits at the boundary between extension-host state management and frontend hydration. That makes it highly leveraged and easy to get wrong in ways that only show up under load or during cross-surface interactions.
While implementing:
- keep snapshot semantics explicit,
- preserve immediate behavior where product correctness or UX requires it,
- and use the reference implementation to understand which callsites were intentionally allowed to coalesce.
The governing principle remains:
> **Stop treating every streamed chunk as a durable, full-state, immediately-presented event.**
For this technique, the emphasis is on the **full-state** part.
## Document Type, Audience, and Quality Bar
This is an **extraction implementation plan** written for a **Staff+ level distributed systems / infrastructure engineer**. Its purpose is to turn an already-integrated optimization into a smaller, understandable, safe-to-review change set.
The quality bar is:
- state-posting behavior must remain easy to reason about,
- the extracted scheduler must preserve correctness across non-streaming product surfaces,
- and the doc must make explicit where coalescing is appropriate versus dangerous.
## Artifact Stack and Dependency Position
This doc should be used in the following sequence:
1. read the branch analysis report for prioritization context,
2. inspect `eve_troubleshooting-remote-workspaces` for the integrated implementation,
3. use this plan to extract a smaller, coherent PR with clear verification boundaries.
This sequence reduces rework and helps ensure the extraction remains anchored to the actual behavior we already know works.
## Minimal Coherent Extraction Boundary
The smallest coherent PR for this technique should usually include:
- controller-level scheduler introduction,
- controller `postStateToWebview()` routing changes,
- build/send/payload instrumentation,
- priority selection defaults,
- and tests covering coalescing plus immediate bypass.
What should **not** be split away if avoidable:
- scheduler extraction from controller integration,
- payload/build/send instrumentation from the coalescing work,
- default-priority logic from the scheduler rollout,
- and product-surface regression coverage for init/cancel/auth/task switching.
## Common Failure Modes While Extracting
Watch for these failure modes explicitly:
- coalescing the scheduler mechanically without auditing callsite urgency,
- reducing full-state post count while accidentally delaying critical UI transitions,
- proving frequency reduction without measuring payload size or send/build time,
- treating streaming and non-streaming surfaces the same,
- and forgetting that snapshot bugs often manifest as broad UI inconsistency rather than narrow chat glitches.
---
## Why This Technique Matters
In remote workspaces, a full-state update can imply all of the following:
- gather current extension state,
- build `ExtensionState`,
- JSON serialize it,
- transport it from extension host to webview boundary,
- parse it on the receiving side,
- merge it into frontend state,
- trigger React render work.
That is acceptable for:
- initialization,
- task switches,
- mode changes,
- recovery/resync.
It is **not** acceptable as the default transport for high-frequency streaming churn.
---
## Success Criteria
- Repeated `postStateToWebview()` calls during active streaming are coalesced.
- Immediate flushes are still available where correctness or UX requires them.
- State payload size and posting frequency are instrumented.
- Streaming tasks generate significantly fewer full-state pushes.
- No regressions appear in task switching, auth changes, cancellation, or other non-streaming product surfaces.
---
## Files Most Likely to Change
- `src/core/controller/StateUpdateScheduler.ts`
- `src/core/controller/index.ts`
- `src/core/controller/state/subscribeToState.ts`
- `src/core/controller/postStateToWebview.test.ts`
- `src/core/controller/StateUpdateScheduler.test.ts`
- `src/core/task/index.ts` for request metrics hookup
---
## Step-by-Step Implementation Plan
## Step 1 — Define snapshot posting priorities and rules
### Goal
Make it explicit which state posts must remain immediate and which can be coalesced.
### Mental model
If every caller thinks its update is urgent, the scheduler will collapse back into immediate mode and lose its value. We need a principled split between:
- **must be immediate**,
- **safe to coalesce**,
- **background/low priority**.
### Work
- [x] Define `immediate`, `normal`, and `low` state update priorities.
- [x] Audit core state-posting callsites.
- [x] Document which flows must bypass coalescing.
### Detailed code changes
- In `src/core/controller/index.ts`, document priority expectations near `postStateToWebview(...)`.
- Categorize at least these as typically immediate:
- [x] task initialization,
- [x] explicit task clear/switch,
- [x] auth/login/logout state changes,
- [x] mode switch.
- Categorize these as usually coalescible during streaming:
- [x] usage/cost updates,
- [x] background command state churn,
- [x] focus-chain intermediate changes,
- [x] repeated chat-state updates caused by streaming partials.
Use the reference implementation branch to validate these classifications before finalizing them in your extraction. The point is not to make an abstract list; it is to preserve the already-learned boundary between “must feel instant” and “safe to batch.”
### Tests
- [ ] No behavior test required yet beyond upcoming scheduler tests.
---
## Step 2 — Build the controller-level state update scheduler
### Goal
Create a scheduler that coalesces repeated full-state posting requests while preserving immediate flush semantics.
### Mental model
This scheduler is conceptually parallel to the presentation scheduler, but it controls **snapshot delivery**, not message presentation. The implementation must handle:
- pending work,
- in-progress flushes,
- higher-priority upgrades,
- rerun-on-dirty behavior.
### Work
- [x] Implement `StateUpdateScheduler` with request/flush/dispose behavior.
- [x] Support priority merging.
- [x] Avoid overlapping snapshot flushes.
- [x] Re-run once if additional work arrives during flush.
### Detailed code changes
- In `src/core/controller/StateUpdateScheduler.ts`:
- [x] track `scheduledTimer`, `pendingPriority`, `flushInProgress`, `pendingWhileFlushing`, and `disposed`.
- [x] support `requestFlush(priority)`.
- [x] support `flushNow()`.
- [x] support `dispose()`.
- [x] ensure `immediate` preempts a pending delayed timer.
Be smart about the scheduler design here. A controller-level scheduler can look mechanically similar to the presentation scheduler, but the failure mode is different: a presentation bug is usually visible in one message stream, while a snapshot bug can destabilize the whole UI state model.
### Tests
- [x] Unit test: repeated normal-priority calls inside cadence window produce one flush.
- [x] Unit test: immediate-priority request bypasses delay.
- [x] Unit test: updates arriving while flush is running trigger exactly one follow-up flush.
- [x] Unit test: dispose clears scheduled work.
---
## Step 3 — Route `postStateToWebview()` through the scheduler
### Goal
Make the scheduler the default behavior for full-state posting without breaking existing callers.
### Mental model
The API should remain convenient for the rest of the codebase. Most callers should still say “post state,” but the controller decides whether that means immediate flush or scheduled coalescing.
### Work
- [x] Add optional priority parameter to `postStateToWebview(...)`.
- [x] Determine the default priority based on whether the task is actively streaming.
- [x] Preserve an explicit immediate path.
### Detailed code changes
- In `src/core/controller/index.ts`:
- [x] instantiate `StateUpdateScheduler` in the constructor.
- [x] change `postStateToWebview(options?)` so it:
- [x] flushes immediately for `priority: "immediate"`,
- [x] otherwise requests scheduled flush.
- [x] add `getDefaultStateUpdatePriority()` that returns `normal` while streaming and `immediate` when idle/non-task.
When extracting this step, prefer preserving the reference implementations method boundaries and control flow. That will make later comparison and debugging much smoother.
### Tests
- [x] Unit test: no-task / idle state posts remain immediate by default.
- [x] Unit test: active-streaming posts default to coalesced priority.
---
## Step 4 — Instrument full-state payload size, build time, and send time
### Goal
Quantify the actual cost of snapshot posting and verify coalescing reduces it.
### Mental model
Snapshot frequency alone is not enough. One giant expensive snapshot can be worse than several small ones. We want to measure:
- how often full-state posts happen,
- how large they are,
- how long they take to build,
- how long they take to send.
### Work
- [x] Measure `getStateToPostToWebview()` build time.
- [x] Measure serialized payload size.
- [x] Measure send time.
- [x] Feed these metrics into per-request latency telemetry.
### Detailed code changes
- In `src/core/controller/index.ts`:
- [x] add `flushStateToWebview()` that records build duration and delivery stats.
- [x] call `task?.noteStateUpdateMetrics(...)` with build duration, payload bytes, and send duration.
- In `src/core/controller/state/subscribeToState.ts`:
- [x] ensure payload byte counting remains available and accurate.
This step is not just observability polish. It is what lets the team prove that the extracted technique is actually reducing snapshot churn rather than merely moving it around.
### Tests
- [x] Unit test: state update metrics are recorded when a flush occurs.
- [x] Unit test: payload byte accounting is invoked.
---
## Step 5 — Audit and tune state-posting callsites
### Goal
Ensure callsites use the right priority and are not silently undermining the scheduler.
### Mental model
The scheduler provides the mechanism; callsite audit provides the correctness. Without the audit, some codepaths will still over-post or misuse immediacy.
### Work
- [x] Review all major `postStateToWebview()` callsites.
- [x] Keep task initialization and state transitions immediate where appropriate.
- [x] Allow hot streaming churn to use normal or low priority.
### Detailed code changes
- In controller and task flows, inspect callsites involving:
- [x] task init / resume,
- [x] cancel / clear,
- [x] auth state changes,
- [x] usage updates,
- [x] focus-chain metadata,
- [x] background command metadata,
- [x] periodic stream-related updates.
- Where needed, pass explicit priority rather than relying only on defaults.
The smart move here is to audit callsites with the reference implementation open, because the coalescing behavior only makes sense in context. The subtle value of the reference branch is that it already captures where the team discovered hidden urgency requirements.
### Tests
- [x] Regression test: task init still hydrates the UI immediately.
- [x] Regression test: cancel/clear still updates UI promptly.
- [x] Regression test: auth/settings flows are not delayed in a user-visible bad way.
---
## Step 6 — Validate behavior during high-churn scenarios, especially large-file writes
### Goal
Verify that the feature materially helps the scenarios that produce the most snapshot churn.
### Mental model
Large-file writes often produce:
- repeated tool/progress updates,
- repeated request metadata changes,
- repeated message-state changes,
- possible repeated snapshot posts.
This technique should reduce the transport overhead from that churn even if the write tool itself remains functionally the same.
That is the key link to the large-file-write scenario: even when the tool work is mostly backend-side, the surrounding UI state churn can still create a slow, noisy experience if snapshots are over-posted.
### Work
- [x] Add or extend validation scenarios for high-churn task execution.
- [x] Compare full-state update count enabled vs disabled.
- [x] Compare payload-byte totals enabled vs disabled.
- [x] Add a heavier long-running eval/harness scenario scaffold for manual or CI validation.
### Tests
- [x] Validation harness scenario: coalescing reduces full-state count in long-running tasks.
- [x] Validation harness scenario: remote mode benefits more than local mode.
- [x] Regression test: final state still fully converges after coalesced posting.
---
## Step 7 — Add remote-aware cadence and tuning controls
### Goal
Choose defaults that are appropriate for remote environments and safe to tune during rollout.
### Mental model
Remote mode should intentionally trade a little more coalescing for much less transport thrash. That should be tunable, not hard-coded forever.
### Work
- [x] Centralize cadence defaults in `latency.ts`.
- [x] Expose env var overrides for local and remote state cadence.
- [x] Document these in `.env.example`.
### Detailed code changes
- In `src/core/task/latency.ts`:
- [x] add/preserve `getStateUpdateCadenceMs(isRemoteWorkspace, priority)`.
- In `.env.example`:
- [x] document state update cadence overrides.
Keep the tuning hooks aligned with the reference implementation so extracted behavior can be compared apples-to-apples during rollout and validation.
### Tests
- [x] Unit test: remote defaults are more conservative than local defaults.
- [x] Unit test: env override behavior works as expected.
---
## Step 8 — Verify product-surface safety outside the streaming path
### Goal
Make sure coalescing full-state snapshots does not degrade other product surfaces.
### Mental model
The branch goal is not just “remote chat feels better.” It is “remote workspaces improve without harming the rest of Cline.” State posting is used by many surfaces, so safety checks matter.
### Work
- [x] Test history/task switching behavior.
- [x] Test settings/auth-related UI updates.
- [x] Test onboarding / welcome state hydration.
- [x] Test focus-chain/background-command metadata behavior.
### Tests
- [x] Regression test: state hydration on startup remains correct.
- [x] Regression test: switching tasks shows the correct snapshot.
- [x] Regression test: metadata deltas + snapshot flow do not leave stale UI after task switch.
---
## Developer Checklist Summary
- [x] Define snapshot posting priorities
- [x] Build controller-level scheduler
- [x] Route `postStateToWebview()` through scheduler
- [x] Instrument build/send/payload metrics
- [x] Audit state-posting callsites
- [ ] Validate high-churn and large-file-write scenarios
- [x] Add remote-aware cadence tuning
- [ ] Verify non-streaming product-surface safety
## Progress Notes
- Added stronger `StateUpdateScheduler` regression coverage for disposal-during-flush and `flushNow()` draining semantics.
- Added controller regression coverage that keeps mode switches, auth callback hydration, and background-command state updates on the intended priority paths.
- Added controller regression coverage for task reinitialization and task-history deletion flows during task switching.
- Added subscribe-to-state coverage to verify startup subscribers receive the latest full snapshot immediately.
- Confirmed that state post metrics are currently accumulated on `Task.requestLatencyMetrics`, but are not yet forwarded through `getTaskCompletionTelemetry()` / `captureTaskCompleted()`, so Step 6 still needs explicit validation plumbing rather than just more assertions.
- Wired state-post metrics through task completion telemetry so high-churn validation can compare state post count, serialized bytes, build duration, and send duration across runs.
- Added task-switch regression coverage to verify active-task snapshot data wins over stale metadata after switching tasks.
- Added scheduler burst-validation coverage showing coalesced normal-priority updates reduce flush count versus immediate flushing, and that remote cadence coalesces more aggressively than local cadence under the same burst pattern.
- Added controller regression coverage verifying that multiple coalesced streaming-era state updates still flush the latest full snapshot rather than an intermediate stale snapshot.
- Added controller burst-comparison coverage showing coalesced streaming-era snapshot delivery sends fewer full-state updates and fewer total serialized bytes than equivalent immediate flushing for the same update burst.
- Added a heavier smoke/eval scenario scaffold (`09-state-coalescing-burst`) plus README guidance for comparing coalescing-enabled and near-immediate cadence runs with the new telemetry fields.
## Recommended Next Validation Step
The next coherent increment should be to extend task-completion telemetry (or an equivalent validation harness payload) so it includes:
- full-state post count,
- total serialized state bytes,
- total build duration for state snapshots,
- and total send duration for state snapshots.
With that wiring in place, Step 6 can be closed by running a long-lived / high-churn task scenario and comparing coalescing-on versus coalescing-disabled behavior, ideally across both local and remote workspace environments.
---
## Final Mental Model Recap
- **Full state is for hydration and synchronization.**
- **It is too expensive to be the main streaming transport in remote mode.**
- **Coalescing snapshots preserves correctness while reducing transport thrash.**
That is the idea developers should keep front-of-mind while implementing this technique.
+26
View File
@@ -73,6 +73,7 @@ cline auth -p cline -k "$CLINE_API_KEY" -m anthropic/claude-sonnet-4.5
| 05-typescript-function | Generate TypeScript | Code generation |
| 06-apply-patch | Edit file (GPT-5) | `apply_patch` tool, native tool calling |
| 07-edit-gemini | Edit file (Gemini) | Gemini model variant |
| 09-state-coalescing-burst | Multi-step churn project | Heavier long-running scenario for state coalescing validation |
### Per-Scenario Models
@@ -114,6 +115,31 @@ Shows `pass@1` when trials < 3, `pass@3` otherwise.
```
3. (Optional) Add `template/` directory with starting files
## State Coalescing Validation Scenario
The `09-state-coalescing-burst` scenario is intended as a heavier smoke/eval scaffold for the controller full-state coalescing work. It drives a longer multi-file task with multiple creation and refinement steps so the extension emits more task-state churn than the simpler smoke tests.
Recommended usage when validating the coalescing technique:
```bash
# Baseline / coalescing-enabled run
npm run eval:smoke:run -- --scenario 09-state-coalescing-burst --trials 1
# Comparison run with more aggressive immediate-like cadence
CLINE_STATE_UPDATE_CADENCE_MS=0 \
CLINE_REMOTE_STATE_UPDATE_CADENCE_MS=0 \
CLINE_STATE_UPDATE_LOW_CADENCE_MS=0 \
CLINE_REMOTE_STATE_UPDATE_LOW_CADENCE_MS=0 \
npm run eval:smoke:run -- --scenario 09-state-coalescing-burst --trials 1
```
This scenario does not automatically assert telemetry deltas yet, but it provides a repeatable long-running task for comparing `task.completed` telemetry fields such as:
- `statePostCount`
- `statePostSerializedBytes`
- `statePostBuildDurationMs`
- `statePostSendDurationMs`
## CI Integration
Smoke tests run automatically via `.github/workflows/cline-evals-regression.yml`.
@@ -0,0 +1,35 @@
{
"name": "State coalescing burst scenario",
"description": "Exercises a longer multi-step project creation/edit flow to generate heavier UI state churn for coalescing validation",
"prompt": "Create a tiny static site project with these files: index.html, styles.css, app.js, README.md, and package.json. The site should render a task dashboard with three task cards, simple styling, and a button that toggles completed state in JavaScript. Include a short README explaining the file layout and how the toggle works. After creating the files, review them and make one more refinement pass to improve naming consistency and polish the README.",
"expectedFiles": [
"index.html",
"styles.css",
"app.js",
"README.md",
"package.json"
],
"expectedContent": [
{
"file": "index.html",
"contains": "task"
},
{
"file": "styles.css",
"contains": ".task"
},
{
"file": "app.js",
"contains": "toggle"
},
{
"file": "README.md",
"contains": "file"
},
{
"file": "package.json",
"contains": "name"
}
],
"timeout": 240
}
@@ -0,0 +1,286 @@
import { strict as assert } from "assert"
import { describe, it } from "mocha"
import { StateUpdateScheduler } from "./StateUpdateScheduler"
class FakeTimerController {
private now = 0
private nextId = 1
private timers = new Map<number, { time: number; callback: () => void }>()
setTimeout = (callback: () => void, delay: number) => {
const id = this.nextId++
this.timers.set(id, { time: this.now + delay, callback })
return id as unknown as ReturnType<typeof setTimeout>
}
clearTimeout = (handle: ReturnType<typeof setTimeout>) => {
this.timers.delete(handle as unknown as number)
}
advance(ms: number) {
this.now += ms
let ran = true
while (ran) {
ran = false
for (const [id, timer] of [...this.timers.entries()].sort((a, b) => a[1].time - b[1].time)) {
if (timer.time <= this.now) {
this.timers.delete(id)
timer.callback()
ran = true
}
}
}
}
getNow = () => this.now
}
describe("StateUpdateScheduler", () => {
it("reduces flush count for bursty normal-priority updates compared with immediate flushes", async () => {
const immediateTimer = new FakeTimerController()
let immediateFlushCount = 0
const immediateScheduler = new StateUpdateScheduler({
flush: async () => {
immediateFlushCount += 1
},
getDelayMs: () => 0,
setTimeoutFn: immediateTimer.setTimeout as typeof setTimeout,
clearTimeoutFn: immediateTimer.clearTimeout as typeof clearTimeout,
getNow: immediateTimer.getNow,
})
for (let i = 0; i < 6; i++) {
await immediateScheduler.flushNow()
}
const coalescedTimer = new FakeTimerController()
let coalescedFlushCount = 0
const coalescedScheduler = new StateUpdateScheduler({
flush: async () => {
coalescedFlushCount += 1
},
getDelayMs: () => 25,
setTimeoutFn: coalescedTimer.setTimeout as typeof setTimeout,
clearTimeoutFn: coalescedTimer.clearTimeout as typeof clearTimeout,
getNow: coalescedTimer.getNow,
})
for (let i = 0; i < 6; i++) {
coalescedScheduler.requestFlush("normal")
coalescedTimer.advance(5)
await Promise.resolve()
}
coalescedTimer.advance(25)
await Promise.resolve()
assert.equal(immediateFlushCount, 6)
assert.equal(coalescedFlushCount, 2)
assert.ok(coalescedFlushCount < immediateFlushCount)
})
it("coalesces more aggressively with remote cadence than with local cadence under the same burst", async () => {
const runBurst = async (delayMs: number) => {
const timer = new FakeTimerController()
let flushCount = 0
const scheduler = new StateUpdateScheduler({
flush: async () => {
flushCount += 1
},
getDelayMs: () => delayMs,
setTimeoutFn: timer.setTimeout as typeof setTimeout,
clearTimeoutFn: timer.clearTimeout as typeof clearTimeout,
getNow: timer.getNow,
})
for (let i = 0; i < 8; i++) {
scheduler.requestFlush("normal")
timer.advance(20)
await Promise.resolve()
}
timer.advance(delayMs)
await Promise.resolve()
return flushCount
}
const localFlushCount = await runBurst(16)
const remoteFlushCount = await runBurst(110)
assert.equal(localFlushCount, 8)
assert.equal(remoteFlushCount, 2)
assert.ok(remoteFlushCount < localFlushCount)
})
it("coalesces repeated normal-priority requests into one flush", async () => {
const timer = new FakeTimerController()
let flushCount = 0
const scheduler = new StateUpdateScheduler({
flush: async () => {
flushCount += 1
},
getDelayMs: () => 50,
setTimeoutFn: timer.setTimeout as typeof setTimeout,
clearTimeoutFn: timer.clearTimeout as typeof clearTimeout,
getNow: timer.getNow,
})
scheduler.requestFlush("normal")
scheduler.requestFlush("normal")
scheduler.requestFlush("low")
assert.equal(flushCount, 0)
timer.advance(49)
assert.equal(flushCount, 0)
timer.advance(1)
await Promise.resolve()
assert.equal(flushCount, 1)
})
it("flushes immediately when requested", async () => {
const timer = new FakeTimerController()
let flushCount = 0
const scheduler = new StateUpdateScheduler({
flush: async () => {
flushCount += 1
},
getDelayMs: () => 50,
setTimeoutFn: timer.setTimeout as typeof setTimeout,
clearTimeoutFn: timer.clearTimeout as typeof clearTimeout,
getNow: timer.getNow,
})
scheduler.requestFlush("normal")
await scheduler.flushNow()
assert.equal(flushCount, 1)
timer.advance(100)
await Promise.resolve()
assert.equal(flushCount, 1)
})
it("runs one follow-up flush when updates arrive during an active flush", async () => {
const timer = new FakeTimerController()
let flushCount = 0
let releaseFlush: (() => void) | undefined
const scheduler = new StateUpdateScheduler({
flush: async () => {
flushCount += 1
if (flushCount === 1) {
await new Promise<void>((resolve) => {
releaseFlush = resolve
})
}
},
getDelayMs: () => 10,
setTimeoutFn: timer.setTimeout as typeof setTimeout,
clearTimeoutFn: timer.clearTimeout as typeof clearTimeout,
getNow: timer.getNow,
})
scheduler.requestFlush("normal")
timer.advance(10)
await Promise.resolve()
assert.equal(flushCount, 1)
scheduler.requestFlush("normal")
releaseFlush?.()
await Promise.resolve()
await Promise.resolve()
timer.advance(10)
await Promise.resolve()
assert.equal(flushCount, 2)
})
it("dispose clears scheduled work", async () => {
let flushCount = 0
let timerCleared = false
const scheduler = new StateUpdateScheduler({
flush: async () => {
flushCount += 1
},
getDelayMs: () => 10,
setTimeoutFn: (() => 1 as any) as unknown as typeof setTimeout,
clearTimeoutFn: (() => {
timerCleared = true
}) as typeof clearTimeout,
})
scheduler.requestFlush("normal")
await scheduler.dispose()
await scheduler.flushNow()
assert.equal(timerCleared, true)
assert.equal(flushCount, 0)
})
it("does not schedule a follow-up flush after disposal during an active flush", async () => {
const timer = new FakeTimerController()
let flushCount = 0
let resolveFlush: (() => void) | undefined
const scheduler = new StateUpdateScheduler({
flush: async () => {
flushCount += 1
await new Promise<void>((resolve) => {
resolveFlush = resolve
})
},
getDelayMs: () => 10,
setTimeoutFn: timer.setTimeout as typeof setTimeout,
clearTimeoutFn: timer.clearTimeout as typeof clearTimeout,
getNow: timer.getNow,
})
scheduler.requestFlush("normal")
timer.advance(10)
await Promise.resolve()
assert.equal(flushCount, 1)
scheduler.requestFlush("normal")
await scheduler.dispose()
resolveFlush?.()
await Promise.resolve()
await Promise.resolve()
timer.advance(20)
await Promise.resolve()
assert.equal(flushCount, 1)
})
it("flushNow drains pending updates immediately after the current flush completes", async () => {
const timer = new FakeTimerController()
let flushCount = 0
let resolveFlush: (() => void) | undefined
const scheduler = new StateUpdateScheduler({
flush: async () => {
flushCount += 1
await new Promise<void>((resolve) => {
resolveFlush = resolve
})
},
getDelayMs: () => 25,
setTimeoutFn: timer.setTimeout as typeof setTimeout,
clearTimeoutFn: timer.clearTimeout as typeof clearTimeout,
getNow: timer.getNow,
})
scheduler.requestFlush("normal")
timer.advance(25)
await Promise.resolve()
assert.equal(flushCount, 1)
scheduler.requestFlush("normal")
const drainPromise = scheduler.flushNow()
resolveFlush?.()
await Promise.resolve()
await Promise.resolve()
assert.equal(flushCount, 2)
resolveFlush?.()
await drainPromise
timer.advance(50)
await Promise.resolve()
assert.equal(flushCount, 2)
})
})
+147
View File
@@ -0,0 +1,147 @@
type StateUpdatePriority = "immediate" | "normal" | "low"
type StateUpdateSchedulerOptions = {
flush: () => Promise<void>
getDelayMs: (priority: StateUpdatePriority) => number
setTimeoutFn?: typeof setTimeout
clearTimeoutFn?: typeof clearTimeout
onFlushError?: (error: unknown) => void
getNow?: () => number
metrics?: {
onFlushStarted?: (priority: StateUpdatePriority) => void
onFlushCompleted?: (durationMs: number, priority: StateUpdatePriority) => void
}
}
export class StateUpdateScheduler {
private scheduledTimer: ReturnType<typeof setTimeout> | undefined
private pendingPriority: StateUpdatePriority | undefined
private flushInProgress = false
private disposed = false
private pendingWhileFlushing = false
private readonly flush: () => Promise<void>
private readonly getDelayMs: (priority: StateUpdatePriority) => number
private readonly setTimeoutFn: typeof setTimeout
private readonly clearTimeoutFn: typeof clearTimeout
private readonly onFlushError?: (error: unknown) => void
private readonly getNow: () => number
private readonly metrics?: StateUpdateSchedulerOptions["metrics"]
constructor(options: StateUpdateSchedulerOptions) {
this.flush = options.flush
this.getDelayMs = options.getDelayMs
this.setTimeoutFn = options.setTimeoutFn ?? setTimeout
this.clearTimeoutFn = options.clearTimeoutFn ?? clearTimeout
this.onFlushError = options.onFlushError
this.getNow = options.getNow ?? (() => performance.now())
this.metrics = options.metrics
}
requestFlush(priority: StateUpdatePriority = "normal"): void {
if (this.disposed) {
return
}
this.pendingPriority = this.mergePriority(this.pendingPriority, priority)
if (this.flushInProgress) {
this.pendingWhileFlushing = true
return
}
if (this.pendingPriority === "immediate") {
if (this.scheduledTimer) {
this.clearTimeoutFn(this.scheduledTimer)
this.scheduledTimer = undefined
}
void this.runFlushCycle()
return
}
if (this.scheduledTimer) {
return
}
const nextPriority = this.pendingPriority ?? "normal"
const delayMs = this.getDelayMs(nextPriority)
this.scheduledTimer = this.setTimeoutFn(() => {
this.scheduledTimer = undefined
void this.runFlushCycle()
}, delayMs)
}
async flushNow(): Promise<void> {
if (this.disposed) {
return
}
this.pendingPriority = this.mergePriority(this.pendingPriority, "immediate")
if (this.scheduledTimer) {
this.clearTimeoutFn(this.scheduledTimer)
this.scheduledTimer = undefined
}
await this.runFlushCycle()
}
async dispose(): Promise<void> {
this.disposed = true
if (this.scheduledTimer) {
this.clearTimeoutFn(this.scheduledTimer)
this.scheduledTimer = undefined
}
this.pendingPriority = undefined
this.pendingWhileFlushing = false
}
private async runFlushCycle(): Promise<void> {
if (this.disposed || this.flushInProgress || !this.pendingPriority) {
return
}
this.flushInProgress = true
const priority = this.pendingPriority
this.pendingPriority = undefined
this.pendingWhileFlushing = false
const startedAt = this.getNow()
this.metrics?.onFlushStarted?.(priority)
try {
await this.flush()
this.metrics?.onFlushCompleted?.(Math.max(0, this.getNow() - startedAt), priority)
} catch (error) {
this.onFlushError?.(error)
} finally {
this.flushInProgress = false
}
if (this.disposed) {
return
}
if (this.pendingPriority || this.pendingWhileFlushing) {
const priorityToRun = this.pendingPriority
if (priorityToRun === "immediate") {
await this.runFlushCycle()
} else {
this.requestFlush(priorityToRun ?? "normal")
}
}
}
private mergePriority(current: StateUpdatePriority | undefined, next: StateUpdatePriority): StateUpdatePriority {
if (!current) {
return next
}
const rank: Record<StateUpdatePriority, number> = {
low: 0,
normal: 1,
immediate: 2,
}
return rank[next] > rank[current] ? next : current
}
}
export type { StateUpdatePriority }
+70 -8
View File
@@ -1,6 +1,7 @@
import type { Anthropic } from "@anthropic-ai/sdk"
import { buildApiHandler } from "@core/api"
import { getHooksEnabledSafe } from "@core/hooks/hooks-utils"
import { getStateUpdateCadenceMs, isRemoteWorkspaceEnvironment } from "@core/task/latency"
import { tryAcquireTaskLockWithRetry } from "@core/task/TaskLockUtils"
import { detectWorkspaceRoots } from "@core/workspace/detection"
import { setupWorkspaceManager } from "@core/workspace/setup"
@@ -56,6 +57,7 @@ import { Task } from "../task"
import { sendMcpMarketplaceCatalogEvent } from "./mcp/subscribeToMcpMarketplaceCatalog"
import { getClineOnboardingModels } from "./models/getClineOnboardingModels"
import { appendClineStealthModels } from "./models/refreshOpenRouterModels"
import { type StateUpdatePriority, StateUpdateScheduler } from "./StateUpdateScheduler"
import { checkCliInstallation } from "./state/checkCliInstallation"
import { sendStateUpdate } from "./state/subscribeToState"
import { sendChatButtonClickedEvent } from "./ui/subscribeToChatButtonClicked"
@@ -85,6 +87,9 @@ export class Controller {
// Timer for periodic remote config fetching
private remoteConfigTimer?: NodeJS.Timeout
private isRemoteWorkspaceEnvironment = false
private readonly stateUpdateScheduler: StateUpdateScheduler
private readonly schedulerDebugLoggingEnabled = process.env.CLINE_DEBUG_LATENCY === "1"
// Public getter for workspace manager with lazy initialization - To get workspaces when task isn't initialized (Used by file mentions)
async ensureWorkspaceManager(): Promise<WorkspaceRootManager | undefined> {
@@ -118,6 +123,14 @@ export class Controller {
}
constructor(readonly context: ClineExtensionContext) {
void HostProvider.env
.getHostVersion({})
.then((hostVersion) => {
this.isRemoteWorkspaceEnvironment = isRemoteWorkspaceEnvironment(hostVersion)
})
.catch((error) => {
Logger.debug(`[Controller] Failed to detect remote workspace state: ${error}`)
})
Session.reset() // Reset session on controller initialization
PromptRegistry.getInstance() // Ensure prompts and tools are registered
this.stateManager = StateManager.get()
@@ -156,6 +169,24 @@ export class Controller {
// Check CLI installation status once on startup
checkCliInstallation(this)
this.stateUpdateScheduler = new StateUpdateScheduler({
flush: async () => this.flushStateToWebview(),
getDelayMs: (priority) => getStateUpdateCadenceMs(this.isRemoteWorkspaceEnvironment, priority),
onFlushError: (error) => Logger.debug(`[Controller] Failed scheduled state flush: ${error}`),
metrics: {
onFlushStarted: (priority) => {
if (this.schedulerDebugLoggingEnabled) {
Logger.debug(`[Controller] state flush started (${priority})`)
}
},
onFlushCompleted: (durationMs, priority) => {
if (this.schedulerDebugLoggingEnabled) {
Logger.debug(`[Controller] state flush completed (${priority}) in ${durationMs}ms`)
}
},
},
})
Logger.log("[Controller] ClineProvider instantiated")
}
@@ -172,6 +203,7 @@ export class Controller {
}
await this.clearTask()
await this.stateUpdateScheduler.dispose()
this.mcpHub.dispose()
Logger.error("Controller disposed")
@@ -259,7 +291,7 @@ export class Controller {
// Check if the user has completed enough tasks to no longer be considered a "new user"
if (isNewUser && !historyItem && taskHistory && taskHistory.length >= NEW_USER_TASK_COUNT_THRESHOLD) {
this.stateManager.setGlobalState("isNewUser", false)
await this.postStateToWebview()
await this.postStateToWebview({ priority: "immediate" })
}
if (autoApprovalSettings) {
@@ -377,6 +409,7 @@ export class Controller {
}
await this.postStateToWebview()
await this.postStateToWebview({ priority: "immediate" })
// Additional safety
if (this.task) {
@@ -400,7 +433,7 @@ export class Controller {
this.task.api = buildApiHandler({ ...apiConfiguration, ulid: this.task.ulid }, modeToSwitchTo)
}
await this.postStateToWebview()
await this.postStateToWebview({ priority: "immediate" })
if (this.task) {
if (this.task.taskState.isAwaitingPlanResponse && didSwitchToActMode) {
@@ -499,7 +532,7 @@ export class Controller {
}
this.backgroundCommandRunning = running
this.backgroundCommandTaskId = nextTaskId
void this.postStateToWebview()
void this.postStateToWebview({ priority: "normal" })
}
async cancelBackgroundCommand(): Promise<void> {
@@ -550,7 +583,7 @@ export class Controller {
this.task.api = buildApiHandler({ ...updatedConfig, ulid: this.task.ulid }, currentMode)
}
await this.postStateToWebview()
await this.postStateToWebview({ priority: "immediate" })
} catch (error) {
Logger.error("Failed to handle auth callback:", error)
HostProvider.window.showMessage({
@@ -601,7 +634,7 @@ export class Controller {
this.task.api = buildApiHandler({ ...updatedConfig, ulid: this.task.ulid }, currentMode)
}
await this.postStateToWebview()
await this.postStateToWebview({ priority: "immediate" })
} catch (error) {
Logger.error("Failed to handle auth callback:", error)
HostProvider.window.showMessage({
@@ -616,7 +649,7 @@ export class Controller {
async handleMcpOAuthCallback(serverHash: string, code: string, state: string | null) {
try {
await this.mcpHub.completeOAuth(serverHash, code, state)
await this.postStateToWebview()
await this.postStateToWebview({ priority: "immediate" })
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message: `Successfully authenticated MCP server`,
@@ -837,9 +870,38 @@ export class Controller {
return updatedTaskHistory
}
async postStateToWebview() {
async postStateToWebview(options?: { priority?: StateUpdatePriority }) {
// Priority guidance:
// - immediate: task init/clear, task switching, auth changes, mode switches, or any UX-critical hydration.
// - normal: streaming-era snapshot churn such as usage/cost updates and background command metadata.
// - low: reserved for future non-urgent background state sync where extra batching is acceptable.
const priority = options?.priority ?? this.getDefaultStateUpdatePriority()
if (priority === "immediate") {
await this.stateUpdateScheduler.flushNow()
return
}
this.stateUpdateScheduler.requestFlush(priority)
}
private getDefaultStateUpdatePriority(): StateUpdatePriority {
if (!this.task) {
return "immediate"
}
return this.task.taskState.isStreaming ? "normal" : "immediate"
}
private async flushStateToWebview() {
const buildStartedAt = performance.now()
const state = await this.getStateToPostToWebview()
await sendStateUpdate(state)
const buildDurationMs = Math.max(0, performance.now() - buildStartedAt)
const deliveryStats = await sendStateUpdate(state)
this.task?.noteStateUpdateMetrics({
buildDurationMs,
serializedBytes: deliveryStats.payloadBytes,
sendDurationMs: deliveryStats.sendDurationMs,
})
}
async getStateToPostToWebview(): Promise<ExtensionState> {
@@ -0,0 +1,43 @@
import { strict as assert } from "assert"
import { sendStateUpdate, subscribeToState } from "./subscribeToState"
describe("subscribeToState", () => {
it("sends the latest full snapshot immediately when a subscriber connects", async () => {
const received: string[] = []
const initialState = {
mode: "act",
clineMessages: [],
currentTaskItem: { id: "task-42", task: "Resume me", ts: 42 },
}
const controller = {
getStateToPostToWebview: async () => initialState,
} as any
await subscribeToState(controller, {} as any, async (message) => {
received.push(message.stateJson ?? "")
})
assert.equal(received.length, 1)
assert.deepEqual(JSON.parse(received[0]), initialState)
})
it("returns delivery stats and broadcasts updates to subscribers", async () => {
const received: string[] = []
const controller = {
getStateToPostToWebview: async () => ({ mode: "act", clineMessages: [] }),
} as any
await subscribeToState(controller, {} as any, async (message) => {
received.push(message.stateJson ?? "")
})
assert.equal(received.length, 1)
const stats = await sendStateUpdate({ mode: "plan", clineMessages: [] } as any)
assert.equal(received.length, 2)
assert.ok(stats.payloadBytes > 0)
assert.ok(stats.sendDurationMs >= 0)
assert.ok(stats.subscriberCount >= 1)
assert.ok(received[1]?.includes('"mode":"plan"'))
})
})
+21 -3
View File
@@ -9,6 +9,12 @@ import { Controller } from "../index"
// Keep track of active state subscriptions
const activeStateSubscriptions = new Set<StreamingResponseHandler<State>>()
export type StateUpdateDeliveryStats = {
payloadBytes: number
sendDurationMs: number
subscriberCount: number
}
/**
* Subscribe to state updates
* @param controller The controller instance
@@ -58,16 +64,22 @@ export async function subscribeToState(
* Send a state update to all active subscribers
* @param state The state to send
*/
export async function sendStateUpdate(state: ExtensionState): Promise<void> {
export async function sendStateUpdate(state: ExtensionState): Promise<StateUpdateDeliveryStats> {
let stateJson: string
try {
stateJson = JSON.stringify(state)
} catch (error) {
Logger.error("Error serializing state update:", error)
return
return {
payloadBytes: 0,
sendDurationMs: 0,
subscriberCount: activeStateSubscriptions.size,
}
}
recordStateSizeTelemetry(Buffer.byteLength(stateJson, "utf8"))
const payloadBytes = Buffer.byteLength(stateJson, "utf8")
recordStateSizeTelemetry(payloadBytes)
const startedAt = performance.now()
const promises = Array.from(activeStateSubscriptions).map(async (responseStream) => {
try {
@@ -84,6 +96,12 @@ export async function sendStateUpdate(state: ExtensionState): Promise<void> {
})
await Promise.all(promises)
return {
payloadBytes,
sendDurationMs: Math.max(0, performance.now() - startedAt),
subscriberCount: activeStateSubscriptions.size,
}
}
function recordStateSizeTelemetry(sizeBytes: number): void {
+4
View File
@@ -7,6 +7,10 @@ export class TaskState {
// Task-level timing
taskStartTimeMs = Date.now()
taskFirstTokenTimeMs?: number
statePostCount = 0
statePostBuildDurationMs = 0
statePostSerializedBytes = 0
statePostSendDurationMs = 0
// Streaming flags
isStreaming = false
+38
View File
@@ -0,0 +1,38 @@
import { strict as assert } from "assert"
import { afterEach, describe, it } from "mocha"
import { getStateUpdateCadenceMs, isRemoteWorkspaceEnvironment } from "../latency"
describe("task latency helpers", () => {
afterEach(() => {
delete process.env.CLINE_STATE_UPDATE_CADENCE_MS
delete process.env.CLINE_REMOTE_STATE_UPDATE_CADENCE_MS
delete process.env.CLINE_STATE_UPDATE_LOW_CADENCE_MS
delete process.env.CLINE_REMOTE_STATE_UPDATE_LOW_CADENCE_MS
})
it("detects remote workspaces from remoteName, platform, and version metadata", () => {
assert.equal(isRemoteWorkspaceEnvironment({ remoteName: "ssh-remote" }), true)
assert.equal(isRemoteWorkspaceEnvironment({ platform: "VS Code Remote" }), true)
assert.equal(isRemoteWorkspaceEnvironment({ version: "Remote Server 1.0" }), true)
assert.equal(isRemoteWorkspaceEnvironment({ platform: "darwin", version: "1.0.0", remoteName: null }), false)
})
it("uses remote-aware state update cadences", () => {
assert.equal(getStateUpdateCadenceMs(false, "immediate"), 0)
assert.equal(getStateUpdateCadenceMs(false, "normal"), 16)
assert.equal(getStateUpdateCadenceMs(true, "normal"), 110)
assert.equal(getStateUpdateCadenceMs(true, "low"), 150)
})
it("respects cadence overrides from environment variables", () => {
process.env.CLINE_STATE_UPDATE_CADENCE_MS = "18"
process.env.CLINE_REMOTE_STATE_UPDATE_CADENCE_MS = "99"
process.env.CLINE_STATE_UPDATE_LOW_CADENCE_MS = "33"
process.env.CLINE_REMOTE_STATE_UPDATE_LOW_CADENCE_MS = "144"
assert.equal(getStateUpdateCadenceMs(false, "normal"), 18)
assert.equal(getStateUpdateCadenceMs(true, "normal"), 99)
assert.equal(getStateUpdateCadenceMs(false, "low"), 33)
assert.equal(getStateUpdateCadenceMs(true, "low"), 144)
})
})
+17
View File
@@ -253,6 +253,12 @@ export class Task {
// Task Locking (Sqlite)
private taskLockAcquired: boolean
private requestLatencyMetrics = {
statePostCount: 0,
statePostBuildDurationMs: 0,
statePostSerializedBytes: 0,
statePostSendDurationMs: 0,
}
// Command executor for running shell commands (extracted from executeCommandTool)
private commandExecutor!: CommandExecutor
@@ -569,6 +575,17 @@ export class Task {
)
}
public noteStateUpdateMetrics(metrics: { buildDurationMs: number; serializedBytes: number; sendDurationMs: number }) {
this.requestLatencyMetrics.statePostCount += 1
this.requestLatencyMetrics.statePostBuildDurationMs += metrics.buildDurationMs
this.requestLatencyMetrics.statePostSerializedBytes += metrics.serializedBytes
this.requestLatencyMetrics.statePostSendDurationMs += metrics.sendDurationMs
this.taskState.statePostCount += 1
this.taskState.statePostBuildDurationMs += metrics.buildDurationMs
this.taskState.statePostSerializedBytes += metrics.serializedBytes
this.taskState.statePostSendDurationMs += metrics.sendDurationMs
}
// Communicate with webview
// partial has three valid states true (partial message), false (completion of partial message), undefined (individual complete message)
+50
View File
@@ -0,0 +1,50 @@
import type { StateUpdatePriority } from "@core/controller/StateUpdateScheduler"
function readCadenceOverride(envVarName: string): number | undefined {
const rawValue = process.env[envVarName]
if (!rawValue) {
return undefined
}
const parsed = Number.parseInt(rawValue, 10)
if (!Number.isFinite(parsed) || parsed < 0) {
return undefined
}
return parsed
}
function getCadenceOverride(args: { isRemoteWorkspace: boolean; localEnvVar: string; remoteEnvVar: string }): number | undefined {
return args.isRemoteWorkspace ? readCadenceOverride(args.remoteEnvVar) : readCadenceOverride(args.localEnvVar)
}
export function isRemoteWorkspaceEnvironment(host: { platform?: string; version?: string; remoteName?: string | null }): boolean {
if (host.remoteName) {
return true
}
const platform = host.platform?.toLowerCase() ?? ""
const version = host.version?.toLowerCase() ?? ""
return platform.includes("remote") || version.includes("remote")
}
export function getStateUpdateCadenceMs(isRemoteWorkspace: boolean, priority: StateUpdatePriority): number {
if (priority === "immediate") {
return 0
}
const override = getCadenceOverride({
isRemoteWorkspace,
localEnvVar: priority === "low" ? "CLINE_STATE_UPDATE_LOW_CADENCE_MS" : "CLINE_STATE_UPDATE_CADENCE_MS",
remoteEnvVar: priority === "low" ? "CLINE_REMOTE_STATE_UPDATE_LOW_CADENCE_MS" : "CLINE_REMOTE_STATE_UPDATE_CADENCE_MS",
})
if (override !== undefined) {
return override
}
if (priority === "low") {
return isRemoteWorkspace ? 150 : 40
}
return isRemoteWorkspace ? 110 : 16
}
+4
View File
@@ -18,6 +18,10 @@ export function getTaskCompletionTelemetry(config: TaskConfig) {
apiFormat: model.info.apiFormat,
timeToFirstTokenMs: config.taskState.taskFirstTokenTimeMs,
durationMs,
statePostCount: config.taskState.statePostCount,
statePostBuildDurationMs: config.taskState.statePostBuildDurationMs,
statePostSerializedBytes: config.taskState.statePostSerializedBytes,
statePostSendDurationMs: config.taskState.statePostSendDurationMs,
mode: currentMode,
}
}
@@ -705,6 +705,10 @@ export class TelemetryService {
apiFormat?: ApiFormat
timeToFirstTokenMs?: number
durationMs?: number
statePostCount?: number
statePostBuildDurationMs?: number
statePostSerializedBytes?: number
statePostSendDurationMs?: number
mode: Mode
},
) {
@@ -719,6 +723,10 @@ export class TelemetryService {
apiFormatName,
timeToFirstTokenMs: args?.timeToFirstTokenMs,
durationMs: args?.durationMs,
statePostCount: args?.statePostCount,
statePostBuildDurationMs: args?.statePostBuildDurationMs,
statePostSerializedBytes: args?.statePostSerializedBytes,
statePostSendDurationMs: args?.statePostSendDurationMs,
mode: args?.mode,
},
})
@@ -304,6 +304,10 @@ describe("TelemetryService metrics", () => {
apiFormat: ApiFormat.OPENAI_RESPONSES,
timeToFirstTokenMs: 350,
durationMs: 2100,
statePostCount: 6,
statePostBuildDurationMs: 48,
statePostSerializedBytes: 4096,
statePostSendDurationMs: 21,
mode: "act",
})
@@ -317,6 +321,10 @@ describe("TelemetryService metrics", () => {
assert.strictEqual(completionEvent?.properties?.apiFormatName, "OPENAI_RESPONSES")
assert.strictEqual(completionEvent?.properties?.timeToFirstTokenMs, 350)
assert.strictEqual(completionEvent?.properties?.durationMs, 2100)
assert.strictEqual(completionEvent?.properties?.statePostCount, 6)
assert.strictEqual(completionEvent?.properties?.statePostBuildDurationMs, 48)
assert.strictEqual(completionEvent?.properties?.statePostSerializedBytes, 4096)
assert.strictEqual(completionEvent?.properties?.statePostSendDurationMs, 21)
const ttftMetric = provider.histograms.find((entry) => entry.name === TelemetryService.METRICS.API.TTFT_SECONDS)
assert.ok(ttftMetric)
@@ -0,0 +1,483 @@
import { afterEach, before, beforeEach, describe, it } from "mocha"
import "should"
import { Controller } from "@core/controller"
import * as sinon from "sinon"
import { setTimeout as setTimeoutPromise } from "timers/promises"
import { ClineEndpoint } from "@/config"
import { HostProvider } from "@/hosts/host-provider"
describe("Controller postStateToWebview", () => {
let controller: Controller
let stateManagerStub: sinon.SinonStub
let mockStateManager: any
let hostProviderInitialized = false
let mockGetHostVersion: sinon.SinonStub
before(async () => {
if (!ClineEndpoint.isInitialized()) {
await ClineEndpoint.initialize("/test/extension")
}
})
beforeEach(async () => {
if (!HostProvider.isInitialized()) {
mockGetHostVersion = sinon.stub().resolves({
clineVersion: "1.0.0",
platform: "darwin",
clineType: "vscode",
})
const mockHostBridge: any = {
workspaceClient: {},
envClient: {
getHostVersion: mockGetHostVersion,
},
windowClient: {},
diffClient: {},
}
HostProvider.initialize(
() => null as any,
() => null as any,
() => null as any,
() => null as any,
mockHostBridge,
() => {},
async (path: string) => `http://localhost${path}`,
async () => "",
"/test/extension",
"/test/storage",
)
hostProviderInitialized = true
}
await require("@/registry").HostRegistryInfo.init()
mockStateManager = {
getRemoteConfigSettings: sinon.stub().returns({}),
getApiConfiguration: sinon.stub().returns({}),
getGlobalStateKey: sinon.stub().returns(undefined),
getGlobalSettingsKey: sinon.stub().returns(undefined),
getWorkspaceStateKey: sinon.stub().returns(undefined),
setGlobalState: sinon.stub(),
setApiConfiguration: sinon.stub(),
registerCallbacks: sinon.stub(),
}
const StateManager = require("@core/storage/StateManager").StateManager
stateManagerStub = sinon.stub(StateManager, "get").returns(mockStateManager)
controller = new Controller({
globalState: { get: sinon.stub(), update: sinon.stub().resolves() },
workspaceState: { get: sinon.stub(), update: sinon.stub().resolves() },
secrets: { get: sinon.stub().resolves(), store: sinon.stub().resolves(), delete: sinon.stub().resolves() },
subscriptions: [],
extensionPath: "/test/path",
globalStoragePath: "/test/storage",
globalStorageUri: { fsPath: "/test/storage" },
} as any)
})
afterEach(() => {
stateManagerStub.restore()
if (hostProviderInitialized) {
HostProvider.reset()
hostProviderInitialized = false
}
})
it("flushes immediately when there is no active task", async () => {
const flushNow = sinon.stub().resolves()
const requestFlush = sinon.stub()
;(controller as any).stateUpdateScheduler = {
flushNow,
requestFlush,
dispose: sinon.stub().resolves(),
}
await controller.postStateToWebview()
sinon.assert.calledOnce(flushNow)
sinon.assert.notCalled(requestFlush)
})
it("coalesces through the scheduler when the active task is streaming", async () => {
const flushNow = sinon.stub().resolves()
const requestFlush = sinon.stub()
;(controller as any).stateUpdateScheduler = {
flushNow,
requestFlush,
dispose: sinon.stub().resolves(),
}
;(controller as any).task = {
taskState: {
isStreaming: true,
},
}
await controller.postStateToWebview()
sinon.assert.calledOnceWithExactly(requestFlush, "normal")
sinon.assert.notCalled(flushNow)
})
it("honors explicit immediate priority even while streaming", async () => {
const flushNow = sinon.stub().resolves()
const requestFlush = sinon.stub()
;(controller as any).stateUpdateScheduler = {
flushNow,
requestFlush,
dispose: sinon.stub().resolves(),
}
;(controller as any).task = {
taskState: {
isStreaming: true,
},
}
await controller.postStateToWebview({ priority: "immediate" })
sinon.assert.calledOnce(flushNow)
sinon.assert.notCalled(requestFlush)
})
it("treats non-streaming active tasks as immediate by default", async () => {
const flushNow = sinon.stub().resolves()
const requestFlush = sinon.stub()
;(controller as any).stateUpdateScheduler = {
flushNow,
requestFlush,
dispose: sinon.stub().resolves(),
}
;(controller as any).task = {
taskState: {
isStreaming: false,
},
}
await controller.postStateToWebview()
sinon.assert.calledOnce(flushNow)
sinon.assert.notCalled(requestFlush)
})
it("records state update metrics when a flush occurs", async () => {
const noteStateUpdateMetrics = sinon.stub()
;(controller as any).task = { noteStateUpdateMetrics }
const getStateToPostToWebview = sinon.stub(controller, "getStateToPostToWebview").resolves({ foo: "bar" } as any)
const stateModule = require("@core/controller/state/subscribeToState")
const sendStateUpdateStub = sinon.stub(stateModule, "sendStateUpdate").resolves({
payloadBytes: 123,
sendDurationMs: 7,
subscriberCount: 1,
})
try {
await (controller as any).flushStateToWebview()
sinon.assert.calledOnce(getStateToPostToWebview)
sinon.assert.calledOnce(sendStateUpdateStub)
sinon.assert.calledOnce(noteStateUpdateMetrics)
const metrics = noteStateUpdateMetrics.firstCall.args[0]
metrics.serializedBytes.should.equal(123)
metrics.sendDurationMs.should.equal(7)
metrics.buildDurationMs.should.be.greaterThanOrEqual(0)
} finally {
getStateToPostToWebview.restore()
sendStateUpdateStub.restore()
}
})
it("flushes the latest snapshot after coalescing multiple streaming-era state updates", async () => {
let snapshot = { currentTaskItem: { id: "task-1" }, currentFocusChainChecklist: "- [ ] first" }
;(controller as any).task = {
taskState: {
isStreaming: true,
},
noteStateUpdateMetrics: sinon.stub(),
}
const getStateToPostToWebview = sinon.stub(controller, "getStateToPostToWebview").callsFake(async () => snapshot as any)
const stateModule = require("@core/controller/state/subscribeToState")
const sentStates: any[] = []
const sendStateUpdateStub = sinon.stub(stateModule, "sendStateUpdate").callsFake(async (state: any) => {
sentStates.push(state)
return {
payloadBytes: 256,
sendDurationMs: 3,
subscriberCount: 1,
}
})
try {
await controller.postStateToWebview()
snapshot = { currentTaskItem: { id: "task-2" }, currentFocusChainChecklist: "- [x] latest" }
await controller.postStateToWebview()
await setTimeoutPromise(40)
sinon.assert.calledOnce(sendStateUpdateStub)
sinon.assert.calledOnce(getStateToPostToWebview)
sentStates.should.deepEqual([{ currentTaskItem: { id: "task-2" }, currentFocusChainChecklist: "- [x] latest" }])
} finally {
getStateToPostToWebview.restore()
sendStateUpdateStub.restore()
}
})
it("reduces snapshot sends and payload bytes versus immediate flushing for the same streaming burst", async () => {
const stateModule = require("@core/controller/state/subscribeToState")
const runBurst = async ({ immediate }: { immediate: boolean }) => {
let sendCount = 0
let totalPayloadBytes = 0
let snapshotVersion = 0
;(controller as any).task = {
taskState: {
isStreaming: true,
},
noteStateUpdateMetrics: sinon.stub(),
}
const getStateToPostToWebview = sinon.stub(controller, "getStateToPostToWebview").callsFake(async () => {
return {
currentTaskItem: { id: `task-${snapshotVersion}` },
currentFocusChainChecklist: `- [ ] item ${snapshotVersion}`,
} as any
})
const sendStateUpdateStub = sinon.stub(stateModule, "sendStateUpdate").callsFake(async (state: any) => {
sendCount += 1
totalPayloadBytes += Buffer.byteLength(JSON.stringify(state), "utf8")
return {
payloadBytes: Buffer.byteLength(JSON.stringify(state), "utf8"),
sendDurationMs: 1,
subscriberCount: 1,
}
})
try {
for (let i = 0; i < 6; i++) {
snapshotVersion = i
if (immediate) {
await controller.postStateToWebview({ priority: "immediate" })
} else {
await controller.postStateToWebview()
}
}
if (!immediate) {
await setTimeoutPromise(40)
}
return { sendCount, totalPayloadBytes }
} finally {
getStateToPostToWebview.restore()
sendStateUpdateStub.restore()
}
}
const coalesced = await runBurst({ immediate: false })
const immediate = await runBurst({ immediate: true })
coalesced.sendCount.should.be.lessThan(immediate.sendCount)
coalesced.totalPayloadBytes.should.be.lessThan(immediate.totalPayloadBytes)
immediate.sendCount.should.equal(6)
})
it("posts background command state updates at normal priority", async () => {
const postStateToWebview = sinon.stub(controller, "postStateToWebview").resolves()
controller.updateBackgroundCommandState(true, "task-123")
await Promise.resolve()
sinon.assert.calledOnceWithExactly(postStateToWebview, { priority: "normal" })
})
it("keeps mode switches on the immediate path", async () => {
const postStateToWebview = sinon.stub(controller, "postStateToWebview").resolves()
const didSwitch = await controller.togglePlanActMode("plan")
didSwitch.should.equal(false)
sinon.assert.calledOnceWithExactly(postStateToWebview, { priority: "immediate" })
sinon.assert.calledWith(mockStateManager.setGlobalState, "mode", "plan")
})
it("keeps auth callback state hydration on the immediate path", async () => {
mockStateManager.getGlobalSettingsKey.callsFake((key: string) => {
switch (key) {
case "planActSeparateModelsSetting":
return false
case "mode":
return "act"
default:
return undefined
}
})
mockStateManager.getApiConfiguration.returns({
planModeApiProvider: "openrouter",
actModeApiProvider: "openrouter",
})
const handleAuthCallback = sinon.stub(controller.authService, "handleAuthCallback").resolves()
const postStateToWebview = sinon.stub(controller, "postStateToWebview").resolves()
const fetchRemoteConfigModule = require("@core/storage/remote-config/fetch")
const fetchRemoteConfigStub = sinon.stub(fetchRemoteConfigModule, "fetchRemoteConfig").resolves()
try {
await controller.handleAuthCallback("token-123", "google")
sinon.assert.calledOnce(handleAuthCallback)
sinon.assert.calledWith(mockStateManager.setGlobalState, "welcomeViewCompleted", true)
sinon.assert.calledOnceWithExactly(postStateToWebview, { priority: "immediate" })
} finally {
fetchRemoteConfigStub.restore()
}
})
it("reinitializes an existing task from history when switching tasks", async () => {
const historyItem = { id: "task-2", task: "Continue task", ts: Date.now() } as any
const getTaskWithId = sinon.stub(controller, "getTaskWithId").resolves({ historyItem } as any)
const initTask = sinon.stub(controller, "initTask").resolves("task-2")
await controller.reinitExistingTaskFromId("task-2")
sinon.assert.calledOnceWithExactly(getTaskWithId, "task-2")
sinon.assert.calledOnceWithExactly(initTask, undefined, undefined, undefined, historyItem)
})
it("updates task history state and notifies the UI when deleting a task", async () => {
const existingHistory = [
{ id: "task-1", task: "Keep me", ts: 1 },
{ id: "task-2", task: "Remove me", ts: 2 },
]
mockStateManager.getGlobalStateKey.callsFake((key: string) => {
if (key === "taskHistory") {
return existingHistory
}
return undefined
})
const postStateToWebview = sinon.stub(controller, "postStateToWebview").resolves()
const updatedHistory = await controller.deleteTaskFromState("task-2")
updatedHistory.should.deepEqual([{ id: "task-1", task: "Keep me", ts: 1 }])
sinon.assert.calledWith(mockStateManager.setGlobalState, "taskHistory", updatedHistory)
sinon.assert.calledOnce(postStateToWebview)
})
it("includes the active task snapshot when building state for task switching", async () => {
const existingHistory = [
{ id: "task-1", task: "Old task", ts: 1 },
{ id: "task-2", task: "Active task", ts: 2 },
]
mockStateManager.getGlobalStateKey.callsFake((key: string) => {
if (key === "taskHistory") {
return existingHistory
}
if (key === "isNewUser") {
return false
}
return undefined
})
;(controller as any).task = {
taskId: "task-2",
messageStateHandler: {
getClineMessages: () => [],
},
taskState: {
checkpointManagerErrorMessage: undefined,
currentFocusChainChecklist: null,
},
}
const state = await controller.getStateToPostToWebview()
state.currentTaskItem?.id.should.equal("task-2")
state.taskHistory?.map((item) => item.id).should.deepEqual(["task-2", "task-1"])
})
it("uses active-task metadata after task switches so stale checklist state does not leak", async () => {
const existingHistory = [
{ id: "task-1", task: "Old task", ts: 1 },
{ id: "task-2", task: "New task", ts: 2 },
]
mockStateManager.getGlobalStateKey.callsFake((key: string) => {
if (key === "taskHistory") {
return existingHistory
}
if (key === "isNewUser") {
return false
}
return undefined
})
;(controller as any).task = {
taskId: "task-2",
messageStateHandler: {
getClineMessages: () => [{ ts: 22, type: "say", say: "text", text: "new task message" }],
},
taskState: {
checkpointManagerErrorMessage: undefined,
currentFocusChainChecklist: "- [x] switched to the new task",
},
}
controller.updateBackgroundCommandState(true, "task-1")
const state = await controller.getStateToPostToWebview()
state.currentTaskItem?.id.should.equal("task-2")
state.currentFocusChainChecklist?.should.equal("- [x] switched to the new task")
state.clineMessages.should.deepEqual([{ ts: 22, type: "say", say: "text", text: "new task message" }])
state.backgroundCommandTaskId?.should.equal("task-1")
})
it("detects remote workspace host metadata during controller initialization", async () => {
HostProvider.reset()
hostProviderInitialized = false
mockGetHostVersion = sinon.stub().resolves({
clineVersion: "1.0.0",
platform: "darwin",
clineType: "vscode",
remoteName: "ssh-remote",
})
HostProvider.initialize(
() => null as any,
() => null as any,
() => null as any,
() => null as any,
{
workspaceClient: {},
envClient: {
getHostVersion: mockGetHostVersion,
},
windowClient: {},
diffClient: {},
} as any,
() => {},
async (path: string) => `http://localhost${path}`,
async () => "",
"/test/extension",
"/test/storage",
)
hostProviderInitialized = true
controller = new Controller({
globalState: { get: sinon.stub(), update: sinon.stub().resolves() },
workspaceState: { get: sinon.stub(), update: sinon.stub().resolves() },
secrets: { get: sinon.stub().resolves(), store: sinon.stub().resolves(), delete: sinon.stub().resolves() },
subscriptions: [],
extensionPath: "/test/path",
globalStoragePath: "/test/storage",
globalStorageUri: { fsPath: "/test/storage" },
} as any)
await Promise.resolve()
await Promise.resolve()
sinon.assert.called(mockGetHostVersion)
;(controller as any).isRemoteWorkspaceEnvironment.should.equal(true)
})
})