Skip to content

[Story 3.2c] Extension-side task-scoping guard #370

Open

Description

@edelauna

Part of #358 (Epic 3: TaskScheduler + Subtask Fan-out).

Depends on: Story 3.2b (Fan-out correctness baseline). Story 3.2d (webview-side guard) depends on this and must follow.

Context

The webview is fundamentally single-task. messageUpdated events have no taskId field (Task.ts line 1081), and ChatView renders whatever arrives without verifying it belongs to currentTaskId. If two tasks post to the webview simultaneously — which becomes possible when maxConcurrency > 1 via Story 3.2b — messages from a background task corrupt the displayed task's chat.

The primary fix lives in the extension host: non-focused tasks simply do not post. VS Code's WebviewView is a single instance per view ID (no multi-pane option), and VS Code's own guidance recommends keeping authoritative state in the extension host and passing serializable snapshots to the webview rather than depending on hidden webview runtime state (webviews are resource-heavy and their contents can be destroyed when hidden). The extension host must therefore enforce focus gating. This story implements the extension-side half. Story 3.2d implements the webview-side defense-in-depth (rejection guard + per-task seq migration).

Story 3.2b introduced the first focused-vs-target task split. This story should also consolidate any provisional task-scope API shapes from 3.2b so the extension host has one consistent way to say whether an operation targets the focused task, a specific live task, or no task.

Developer Notes

In src/core/task/Task.ts:

  • Add taskId field to messageUpdated events: provider?.postMessageToWebview({ type: "messageUpdated", clineMessage: message, taskId: this.taskId }).
  • Gate postStateToWebviewWithoutTaskHistory() and postMessageToWebview({ type: "messageUpdated" }): only post if this.taskId === this.provider.taskRegistry.current?.taskId. Non-focused tasks accumulate messages in their own clineMessages array silently; the webview receives them when the user navigates to that task.

In src/core/webview/ClineProvider.ts:

  • Add runningTaskIds: string[] to the state shape returned by getStateToPostToWebview(), populated from taskRegistry.getRunning().map(t => t.taskId).
  • Split showTaskWithId into three paths (currently line 2006 calls createTaskWithHistoryItem unconditionally for non-current tasks, which destroys live task state):
async showTaskWithId(id: string) {
  if (id === this.taskRegistry.current?.taskId) {
    // Already focused — nothing to do except scroll into view
  } else if (this.taskRegistry.hasRunning(id)) {
    // Live task focus switch — do NOT call createTaskWithHistoryItem
    this.taskRegistry.setCurrent(id)
    await this.postStateToWebview()   // pushes live task's clineMessages
  } else {
    // Historical task — existing behavior unchanged
    const { historyItem } = await this.getTaskWithId(id)
    await this.createTaskWithHistoryItem(historyItem)
  }
  await this.postMessageToWebview({ type: "action", action: "chatButtonClicked" })
}
  • Consolidate provisional task-scope parameters introduced in Story 3.2b. Prefer one explicit options shape over multiple boolean skip flags. The shape must distinguish:
    • focused task (normal existing behavior)
    • specific task (operation is about a known live task)
    • no task (operation deliberately has only global side effects and must not mutate any current task)
  • Do not push this concern into postStateToWebview() itself unless needed. Most call sites correctly mean "render currently focused task"; fix the call sites that know they are mid-transition.

In packages/types/src/vscode-extension-host.ts:

  • Add taskId?: string to ExtensionMessage for messageUpdated type.
  • Add clineMessagesTaskId?: string stub alongside clineMessagesSeq in state messages (consumed by Story 3.2d).

Files: src/core/task/Task.ts, src/core/webview/ClineProvider.ts, packages/types/src/vscode-extension-host.ts

Tests (src/core/task/__tests__/TaskWebviewGuard.spec.ts):

  • Non-focused task: postStateToWebviewWithoutTaskHistory() does NOT call postMessageToWebview. Assert via spy.
  • Focused task: postStateToWebviewWithoutTaskHistory() calls postMessageToWebview normally.
  • messageUpdated includes taskId field matching the emitting task.
  • Focus switch via taskRegistry.setCurrent(newTaskId): subsequent posts from old task are suppressed; new task's posts go through.
  • runningTaskIds in state reflects all running tasks from TaskRegistry.
  • showTaskWithId on a live task: createTaskWithHistoryItem is NOT called, prior live task is NOT aborted/disposed, taskRegistry.current updates to the target task.
  • showTaskWithId on a historical task: existing createTaskWithHistoryItem path is taken unchanged.
  • Provisional 3.2b task-scope parameters are consolidated without regressing fan-out parent/child mode/profile isolation.

Acceptance Criteria

  • messageUpdated events include taskId — grep -n "messageUpdated" src/core/task/Task.ts shows taskId in every call.
  • Non-focused tasks do not post to the webview — verified by spy-based test.
  • runningTaskIds is present in webview state and reflects TaskRegistry.getRunning().
  • showTaskWithId for a live task must not call createTaskWithHistoryItem, must not abort or dispose the prior live task, and must update taskRegistry.current and push the target task's messages to the webview.
  • showTaskWithId for a historical task preserves existing behavior exactly.
  • There is one consistent focused-vs-target task scoping convention in the extension host for the fan-out call tree; no parallel targetTask/skipCurrentTaskRebuild spellings remain.
  • All existing single-task tests pass unchanged — the guard is transparent when only one task runs.

Out of Scope

Activity

  1. edelauna commented on Jul 29, 2026

    @edelauna
    ContributorAuthor

    Note from implementing Story 3.2b (#369): the review on that story caught a bug worth flagging here since this story's showTaskWithId split is solving the same class of problem.

    The bug (3.2b): delegateParentAndOpenChild's fan-out path called handleModeSwitch(mode), which internally does const task = this.getCurrentTask() and mutates task._taskMode. In fan-out, the parent is still getCurrentTask() at that point (the child doesn't exist yet), so a mode switch intended for the child silently overwrote the parent's own mode. The fix was to make handleModeSwitch accept an explicit target task (defaulting to current, but callable with null to skip the per-task mutation) instead of always trusting "current" to mean "the task this operation is about."

    Why it's relevant here: your dev notes already correctly flag the analogous hazard for showTaskWithId — "line 2006 calls createTaskWithHistoryItem unconditionally for non-current tasks, which destroys live task state" — and the three-path split (current / live-but-not-current / historical) is the right fix. Worth extending the same scrutiny to any other webview-triggered action in this story's scope that reads getCurrentTask() (or similar "current" accessors) as a stand-in for "the task this action targets." A background task that's alive-but-not-focused should never have its own per-task fields (mode, config, etc.) mutated by an action the user fired while looking at a different task.

    Suggest adding an explicit acceptance criterion / test: switching focus to task B must not mutate any per-task state on task A, even though A was current moments before.

  2. zoomote commented on Aug 21, 2026

    @zoomote
    Contributor

    #1279 found the persistence-side version of this story's ownership problem: an evicted Task can finish an awaited history read and write afterward. The #1279 fix will fence disk writes by cancellation/current-instance identity. Story 3.2c should keep its webview focus scope, but its explicit focused-vs-target identity should remain compatible with the persistence ownership guard.

  3. zoomote commented on Aug 21, 2026

    @zoomote
    Contributor

    Coordination note for #1223: this story's live-task showTaskWithId path should prevent destructive rehydration when the target task is still running, covering one leave-and-return case. #1223 still needs a durable pending-action restoration path for historical tasks, extension reloads, and the current single-live-task flow. Please keep the live-focus API compatible with restoring a persisted newTask / finishTask approval without appending generic resume_task.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions