Repository navigation
[Story 3.2c] Extension-side task-scoping guard #370
Description
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
taskIdfield tomessageUpdatedevents:provider?.postMessageToWebview({ type: "messageUpdated", clineMessage: message, taskId: this.taskId }). - Gate
postStateToWebviewWithoutTaskHistory()andpostMessageToWebview({ type: "messageUpdated" }): only post ifthis.taskId === this.provider.taskRegistry.current?.taskId. Non-focused tasks accumulate messages in their ownclineMessagesarray 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 bygetStateToPostToWebview(), populated fromtaskRegistry.getRunning().map(t => t.taskId). - Split
showTaskWithIdinto three paths (currently line 2006 callscreateTaskWithHistoryItemunconditionally 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?: stringtoExtensionMessageformessageUpdatedtype. - Add
clineMessagesTaskId?: stringstub alongsideclineMessagesSeqin 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 callpostMessageToWebview. Assert via spy. - Focused task:
postStateToWebviewWithoutTaskHistory()callspostMessageToWebviewnormally. messageUpdatedincludestaskIdfield matching the emitting task.- Focus switch via
taskRegistry.setCurrent(newTaskId): subsequent posts from old task are suppressed; new task's posts go through. runningTaskIdsin state reflects all running tasks fromTaskRegistry.showTaskWithIdon a live task:createTaskWithHistoryItemis NOT called, prior live task is NOT aborted/disposed,taskRegistry.currentupdates to the target task.showTaskWithIdon a historical task: existingcreateTaskWithHistoryItempath is taken unchanged.- Provisional 3.2b task-scope parameters are consolidated without regressing fan-out parent/child mode/profile isolation.
Acceptance Criteria
messageUpdatedevents includetaskId—grep -n "messageUpdated" src/core/task/Task.tsshowstaskIdin every call.- Non-focused tasks do not post to the webview — verified by spy-based test.
runningTaskIdsis present in webview state and reflectsTaskRegistry.getRunning().showTaskWithIdfor a live task must not callcreateTaskWithHistoryItem, must not abort or dispose the prior live task, and must updatetaskRegistry.currentand push the target task's messages to the webview.showTaskWithIdfor 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/skipCurrentTaskRebuildspellings remain. - All existing single-task tests pass unchanged — the guard is transparent when only one task runs.
Out of Scope
- Story [Story 3.2b] Fan-out: parent stays active while child runs #369 owns the fan-out correctness baseline: parent execution context isolation and rollback/permit safety during child creation.
- Story [Story 3.2d] Webview-side task-scoping guard + per-task seq #371 owns webview-side rejection and per-task sequence handling.
- Story [Story 3.3] Child completion callback and parent wakeup #372 owns child completion callback and live-parent result injection.
Activity
edelauna commentedon Jul 29, 2026
Note from implementing Story 3.2b (#369): the review on that story caught a bug worth flagging here since this story's
showTaskWithIdsplit is solving the same class of problem.The bug (3.2b):
delegateParentAndOpenChild's fan-out path calledhandleModeSwitch(mode), which internally doesconst task = this.getCurrentTask()and mutatestask._taskMode. In fan-out, the parent is stillgetCurrentTask()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 makehandleModeSwitchaccept an explicit target task (defaulting to current, but callable withnullto 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 callscreateTaskWithHistoryItemunconditionally 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 readsgetCurrentTask()(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
currentmoments before.zoomote commentedon Aug 21, 2026
#1279 found the persistence-side version of this story's ownership problem: an evicted
Taskcan 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.zoomote commentedon Aug 21, 2026
Coordination note for #1223: this story's live-task
showTaskWithIdpath 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 persistednewTask/finishTaskapproval without appending genericresume_task.