Skip to content
53 changes: 53 additions & 0 deletions docs/analysis/2026-09-21-agent-store-ownership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Agent store request and session ownership

Status: draft PR #3338, 2026-09-21. Base: `307c3b8b50bec1cb0bfaea3e570a942bcb1d4451`.

## Reproduced defects

`agentStore` previously let every profile, run-list and run-detail response write its shared surface. Route-clear helpers changed visible values but did not invalidate requests. The store also had no session lifetime, so old-account work could settle after logout/login, including login as the same user ID.

This admitted reverse-settling reads, A-old → B → A-new route reuse, late settlement after route clear, stale error/toast state, false loading clears, and old-session repopulation.

Independent review then exposed two token-refresh boundaries:

1. treating a successful same-user refresh as full identity replacement cleared already loaded agent data;
2. preserving settled data alone still stranded an empty first-load route because the old request was invalidated and the unchanged route did not remount or refetch.

## Contract

Profiles, run lists and run details have independent owner lanes. Each owner carries the current session epoch and a unique request token. A newer request retires only the previous owner in its lane. Route-clear helpers invalidate their own lane before clearing visible state.

User identity, authenticated state and demo-session replacement synchronously advance the epoch, retire all owners, and clear every agent surface, error and loading indicator.

A token-only rotation:

- preserves already loaded profiles, runs and detail;
- retires old-token owners and stale success/failure/toast/loading settlement;
- restarts only active lanes whose visible surface is still empty;
- preserves the exact agent/run parameters captured by the active read;
- never replays a mutation.

Independent lanes remain concurrent. Demo mode retains its no-network behavior. No API, DTO, route, schema, dependency or backend behavior changes.

## Test-first evidence

Initial test-only head `ae52930c6eb5d5b80a2f6a7347242f4ad60a0036` ran the full frontend suite on Ubuntu and Windows. Both platform jobs passed lint, typecheck, production build and PWA validation, then failed in the new real Pinia suite. Ubuntu JUnit recorded **7,159 tests, 8 failures, 0 errors**, all in the intended ownership schedules.

Review-regression head `7b6e137d9625ee6b2ba6ad747a4fdecc5fabe172` added the loaded-data refresh schedule. Ubuntu again passed lint, typecheck, build and PWA validation; all ten ownership cases ran and only the new preservation case failed.

The first corrected head `c48346128982366c0abdf4f1f766246f5cc351dc` then passed Smart CI, Extended and the complete Required CI matrix. Repeat Codex review found the empty-first-load residual described above.

Test-only head `11ab87461ed4773b99af8e35a6625873a52e2425` adds one deferred real-Pinia schedule spanning profiles, runs and detail. A dependency-free runner transpiled and executed the actual production module:

- before the retry correction: each API was called once and all three loading flags became false after rotation;
- after the correction: each API was called twice, old-token settlement was suppressed, and fresh-token results populated all three lanes.

Hosted qualification for the final correction remains authoritative; the supplemental runner does not replace it.

## Remaining gates

Current production correction: `c6a88e78cc9a306992dd95ae28569fb575c4e286` before this documentation commit.

Exact final-head lint, typecheck, production build, complete Vitest on Ubuntu and Windows, Required CI, Extended, Self-Test, and repeat independent review are required. Review should focus on retry capture, route-clear cancellation, no mutation replay, and avoiding refresh loops.

This is client-state integrity, not a claim of server-side authorization bypass or transport cancellation. No merge, release or deployment qualification is claimed.
181 changes: 148 additions & 33 deletions frontend/taskdeck-web/src/store/agentStore.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ref, watch } from 'vue'
import { agentApi } from '../api/agentApi'
import { useToastStore } from './toastStore'
import { useSessionStore } from './sessionStore'
import { isDemoMode } from '../utils/demoMode'
import { getErrorDisplay } from '../composables/useErrorMapper'
import type { AgentProfile, AgentRun, AgentRunDetail } from '../types/agent'

export const useAgentStore = defineStore('agent', () => {
const toast = useToastStore()
const session = useSessionStore()

const profiles = ref<AgentProfile[]>([])
const profilesLoading = ref(false)
Expand All @@ -21,80 +23,193 @@ export const useAgentStore = defineStore('agent', () => {
const runDetailLoading = ref(false)
const runDetailError = ref<string | null>(null)

type ReadLane = 'profiles' | 'runs' | 'detail'
type ReadRetry = () => Promise<void>

interface ReadOwner {
epoch: number
token: symbol
}

let sessionEpoch = 0
const readOwners = new Map<ReadLane, ReadOwner>()
const readRetries = new Map<ReadLane, ReadRetry>()

function setLaneLoading(lane: ReadLane, value: boolean): void {
if (lane === 'profiles') profilesLoading.value = value
else if (lane === 'runs') runsLoading.value = value
else runDetailLoading.value = value
}

function clearLaneError(lane: ReadLane): void {
if (lane === 'profiles') profilesError.value = null
else if (lane === 'runs') runsError.value = null
else runDetailError.value = null
}

function beginRead(lane: ReadLane, retry: ReadRetry): ReadOwner {
const owner = { epoch: sessionEpoch, token: Symbol(lane) }
readOwners.set(lane, owner)
readRetries.set(lane, retry)
clearLaneError(lane)
setLaneLoading(lane, true)
return owner
}

function ownsRead(lane: ReadLane, owner: ReadOwner): boolean {
const current = readOwners.get(lane)
return owner.epoch === sessionEpoch && current?.token === owner.token
}

function finishRead(lane: ReadLane, owner: ReadOwner): void {
if (!ownsRead(lane, owner)) return
readOwners.delete(lane)
readRetries.delete(lane)
setLaneLoading(lane, false)
}

function invalidateLane(lane: ReadLane): void {
readOwners.delete(lane)
readRetries.delete(lane)
clearLaneError(lane)
setLaneLoading(lane, false)
}

function invalidateReads(): void {
sessionEpoch += 1
readOwners.clear()
readRetries.clear()
profilesLoading.value = false
runsLoading.value = false
runDetailLoading.value = false
profilesError.value = null
runsError.value = null
runDetailError.value = null
}

function retryEmptyActiveReads(): void {
const retries: ReadRetry[] = []
const profilesRetry = readOwners.has('profiles') && profiles.value.length === 0
? readRetries.get('profiles')
: undefined
const runsRetry = readOwners.has('runs') && runs.value.length === 0
? readRetries.get('runs')
: undefined
const detailRetry = readOwners.has('detail') && runDetail.value === null
? readRetries.get('detail')
: undefined

if (profilesRetry) retries.push(profilesRetry)
if (runsRetry) retries.push(runsRetry)
if (detailRetry) retries.push(detailRetry)

invalidateReads()
for (const retry of retries) {
void retry().catch(() => {
// The retried store action owns current error/toast state.
})
}
}

function resetForSession(): void {
invalidateReads()
profiles.value = []
runs.value = []
runDetail.value = null
}

watch(
() => [session.userId, session.isAuthenticated, session.isDemo],
resetForSession,
{ flush: 'sync' },
)

watch(
() => session.token,
retryEmptyActiveReads,
{ flush: 'sync' },
Comment on lines +127 to +130

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry initial reads after token rotation

When Extend Session completes while an Agent route's initial read is still pending, this watcher removes that request's ownership and turns off loading; its eventual result is then ignored, but AgentsView, AgentRunsView, and AgentRunDetailView do not issue another read until mount or a route-parameter change, so the unchanged route displays a false empty/blank state. Fresh evidence in the corrected head is that the new refresh regression seeds every surface with existing data before rotating the token, leaving the first-load-empty case uncovered. Preserve same-user in-flight reads or restart them under the new token.

AGENTS.md reference: frontend/AGENTS.md:L6-L7

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and corrected. Test-only head 11ab874 adds a deferred real-Pinia schedule that starts empty profile, run-list, and run-detail reads, rotates the token for the same user, and requires all three lanes to restart under the new token while old settlements remain suppressed.

A bounded runner transpiled and executed the actual production module: before correction each API was called once and loading dropped false after rotation; current source retries each empty active lane, preserves exact run/detail parameters, suppresses old settlement, and installs fresh-token results. Exact final head is a2c9b57; hosted Self-Test, Extended, Required CI, and repeat review are still pending, so the PR remains draft and this thread remains open.

)

async function fetchProfiles(): Promise<void> {
if (isDemoMode) {
profilesLoading.value = true
profilesError.value = null
invalidateLane('profiles')
profiles.value = []
profilesLoading.value = false
return
}

const owner = beginRead('profiles', fetchProfiles)
try {
profilesLoading.value = true
profilesError.value = null
profiles.value = await agentApi.listProfiles()
const result = await agentApi.listProfiles()
if (!ownsRead('profiles', owner)) return
profiles.value = result
} catch (e: unknown) {
const msg = getErrorDisplay(e, 'Failed to load agent profiles').message
profilesError.value = msg
toast.error(msg)
if (ownsRead('profiles', owner)) {
const msg = getErrorDisplay(e, 'Failed to load agent profiles').message
profilesError.value = msg
toast.error(msg)
}
throw e
} finally {
profilesLoading.value = false
finishRead('profiles', owner)
}
}

async function fetchRuns(agentId: string, limit = 100): Promise<void> {
if (isDemoMode) {
runsLoading.value = true
runsError.value = null
invalidateLane('runs')
runs.value = []
runsLoading.value = false
return
}

const owner = beginRead('runs', () => fetchRuns(agentId, limit))
try {
runsLoading.value = true
runsError.value = null
runs.value = await agentApi.listRuns(agentId, limit)
const result = await agentApi.listRuns(agentId, limit)
if (!ownsRead('runs', owner)) return
runs.value = result
} catch (e: unknown) {
const msg = getErrorDisplay(e, 'Failed to load agent runs').message
runsError.value = msg
toast.error(msg)
if (ownsRead('runs', owner)) {
const msg = getErrorDisplay(e, 'Failed to load agent runs').message
runsError.value = msg
toast.error(msg)
}
throw e
} finally {
runsLoading.value = false
finishRead('runs', owner)
}
}

async function fetchRunDetail(agentId: string, runId: string): Promise<void> {
if (isDemoMode) {
runDetailLoading.value = true
runDetailError.value = null
invalidateLane('detail')
runDetail.value = null
runDetailLoading.value = false
return
}

const owner = beginRead('detail', () => fetchRunDetail(agentId, runId))
try {
runDetailLoading.value = true
runDetailError.value = null
runDetail.value = await agentApi.getRunDetail(agentId, runId)
const result = await agentApi.getRunDetail(agentId, runId)
if (!ownsRead('detail', owner)) return
runDetail.value = result
} catch (e: unknown) {
const msg = getErrorDisplay(e, 'Failed to load run details').message
runDetailError.value = msg
toast.error(msg)
if (ownsRead('detail', owner)) {
const msg = getErrorDisplay(e, 'Failed to load run details').message
runDetailError.value = msg
toast.error(msg)
}
throw e
} finally {
runDetailLoading.value = false
finishRead('detail', owner)
}
}

function clearRuns(): void {
invalidateLane('runs')
runs.value = []
runsError.value = null
}

function clearRunDetail(): void {
invalidateLane('detail')
runDetail.value = null
runDetailError.value = null
}

return {
Expand Down
Loading
Loading