Skip to content

Commit 51c0360

Browse files
Add async diagnostics batching for summaries.
Queue git, health, and dependency diagnostics in a background worker with cache TTL and concurrency controls so full summaries return quickly while diagnostics warm asynchronously, and invalidate summary cache after worker refreshes. Made-with: Cursor
1 parent 10f54ac commit 51c0360

File tree

4 files changed

+191
-18
lines changed

4 files changed

+191
-18
lines changed

docs/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
- Added incremental discovery invalidation in `repos/workspace-hub/server/workspace.ts` by tracking a lightweight repo-tree signature so cached discovery can be reused when unchanged and refreshed automatically when repo folders change.
2020
- Updated workspace cache tests in `repos/workspace-hub/test/workspace-cache-search.test.ts` to verify both repo-tree-driven refresh and explicit cache invalidation behavior.
2121
- Confirmed updated local test-suite pass outside sandbox with `pnpm --dir "repos/workspace-hub" test` (`13 passed, 0 failed`).
22+
- Added async diagnostics batching in `repos/workspace-hub/server/workspace.ts` with a background queue, diagnostics cache TTL, and configurable worker concurrency for full-summary diagnostics refresh.
23+
- Added worker cache-coherency invalidation so refreshed diagnostics propagate to subsequent summary reads.
24+
- Extended `repos/workspace-hub/test/workspace-cache-search.test.ts` with diagnostics warming coverage and confirmed updated local test-suite pass outside sandbox (`14 passed, 0 failed`).
2225

2326
## 2026-04-05
2427

docs/HANDOVER.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,3 +334,31 @@ Verification after incremental invalidation slice:
334334
- `pnpm --dir "repos/workspace-hub" lint`: passed
335335
- `pnpm --dir "repos/workspace-hub" typecheck`: passed
336336
- `pnpm --dir "repos/workspace-hub" test`: passed outside sandbox in local terminal (`13 passed, 0 failed`, duration ~`2518ms`)
337+
338+
### Implementation update (2026-04-07, diagnostics batching worker slice)
339+
340+
Completed in `repos/workspace-hub`:
341+
342+
1. Added background diagnostics batching.
343+
- `server/workspace.ts` now queues and processes diagnostics refresh work asynchronously with configurable concurrency.
344+
- New env controls:
345+
- `WORKSPACE_HUB_DIAGNOSTICS_WORKER_CONCURRENCY` (default `4`)
346+
- `WORKSPACE_HUB_DIAGNOSTICS_CACHE_TTL_MS` (default `10000`)
347+
348+
2. Added diagnostics cache semantics for full summary mode.
349+
- Full summary reads now return quickly with:
350+
- warm cached diagnostics when fresh
351+
- stale diagnostics while a background refresh is queued
352+
- conservative placeholders on first hit while diagnostics warm asynchronously
353+
354+
3. Added cache-coherency fix after worker refresh.
355+
- When worker refresh completes, it now invalidates workspace summary cache so the next summary read can surface updated diagnostics.
356+
357+
4. Added and validated worker-focused test coverage.
358+
- Extended `repos/workspace-hub/test/workspace-cache-search.test.ts` with async diagnostics warming coverage.
359+
360+
Verification after diagnostics worker slice:
361+
362+
- `pnpm --dir "repos/workspace-hub" lint`: passed
363+
- `pnpm --dir "repos/workspace-hub" typecheck`: passed
364+
- `pnpm --dir "repos/workspace-hub" test`: passed outside sandbox in local terminal (`14 passed, 0 failed`, duration ~`2864ms`)

repos/workspace-hub/server/workspace.ts

Lines changed: 110 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,14 @@ const workspaceDiscoveryCacheTtlMs = Number.parseInt(
148148
process.env.WORKSPACE_HUB_DISCOVERY_CACHE_TTL_MS ?? '1500',
149149
10,
150150
)
151+
const diagnosticsCacheTtlMs = Number.parseInt(
152+
process.env.WORKSPACE_HUB_DIAGNOSTICS_CACHE_TTL_MS ?? '10000',
153+
10,
154+
)
155+
const diagnosticsWorkerConcurrency = Number.parseInt(
156+
process.env.WORKSPACE_HUB_DIAGNOSTICS_WORKER_CONCURRENCY ?? '4',
157+
10,
158+
)
151159
const gitVisibilityCache = new Map<
152160
string,
153161
{
@@ -166,6 +174,12 @@ type WorkspaceSummaryBuildOptions = {
166174
includeDiagnostics?: boolean
167175
}
168176

177+
type RepoDiagnostics = {
178+
dependencies: RepoDependencyState
179+
git: RepoGitState
180+
health: RepoHealth
181+
}
182+
169183
type WorkspaceDiscoveryResult = {
170184
archives: WorkspaceArchive[]
171185
repos: WorkspaceRepo[]
@@ -180,6 +194,15 @@ let cachedWorkspaceDiscovery:
180194
value: WorkspaceDiscoveryResult
181195
}
182196
| null = null
197+
const repoDiagnosticsCache = new Map<
198+
string,
199+
{
200+
expiresAt: number
201+
value: RepoDiagnostics
202+
}
203+
>()
204+
const repoDiagnosticsQueue = new Map<string, () => Promise<RepoDiagnostics>>()
205+
const activeDiagnosticsJobs = new Set<string>()
183206

184207
function buildSnapshotCacheKey(
185208
installSnapshots: Map<string, RepoInstall>,
@@ -207,6 +230,49 @@ export function invalidateWorkspaceSummaryCache() {
207230
cachedWorkspaceDiscovery = null
208231
}
209232

233+
function processRepoDiagnosticsQueue() {
234+
while (
235+
activeDiagnosticsJobs.size < Math.max(1, diagnosticsWorkerConcurrency) &&
236+
repoDiagnosticsQueue.size > 0
237+
) {
238+
const nextEntry = repoDiagnosticsQueue.entries().next()
239+
if (nextEntry.done) {
240+
break
241+
}
242+
243+
const [key, factory] = nextEntry.value
244+
repoDiagnosticsQueue.delete(key)
245+
activeDiagnosticsJobs.add(key)
246+
247+
void factory()
248+
.then((value) => {
249+
repoDiagnosticsCache.set(key, {
250+
expiresAt: Date.now() + Math.max(0, diagnosticsCacheTtlMs),
251+
value,
252+
})
253+
// Diagnostics were refreshed asynchronously, so invalidate the derived
254+
// summary cache and allow subsequent reads to pick up warm diagnostics.
255+
invalidateWorkspaceSummaryCache()
256+
})
257+
.catch(() => {
258+
// Keep stale diagnostics when background refresh fails.
259+
})
260+
.finally(() => {
261+
activeDiagnosticsJobs.delete(key)
262+
processRepoDiagnosticsQueue()
263+
})
264+
}
265+
}
266+
267+
function enqueueRepoDiagnostics(key: string, factory: () => Promise<RepoDiagnostics>) {
268+
if (activeDiagnosticsJobs.has(key) || repoDiagnosticsQueue.has(key)) {
269+
return
270+
}
271+
272+
repoDiagnosticsQueue.set(key, factory)
273+
processRepoDiagnosticsQueue()
274+
}
275+
210276
async function buildReposTreeSignature() {
211277
try {
212278
const rootStat = await stat(reposRoot)
@@ -1445,35 +1511,61 @@ async function buildRepoRecord(
14451511
type,
14461512
})
14471513
const agentTooling = await readAgentTooling(fullPath)
1448-
const [health, git, dependencies] = includeDiagnostics
1449-
? await Promise.all([
1450-
probeHealth(healthcheckUrl ?? resolvedPreview.previewUrl),
1451-
readGitState(fullPath),
1452-
readDependencyState({
1453-
buildCommand: effectiveBuildCommand,
1454-
devCommand: effectiveDevCommand,
1455-
fullPath,
1456-
installCommand,
1457-
names,
1458-
packageManager,
1459-
previewCommand,
1460-
}),
1461-
])
1462-
: [buildUnknownHealth(healthcheckUrl ?? resolvedPreview.previewUrl), buildUnavailableGitState(), buildUnknownDependencyState()]
1514+
const diagnosticsKey = fullPath
1515+
const diagnosticsFactory = async (): Promise<RepoDiagnostics> => {
1516+
const [health, git, dependencies] = await Promise.all([
1517+
probeHealth(healthcheckUrl ?? resolvedPreview.previewUrl),
1518+
readGitState(fullPath),
1519+
readDependencyState({
1520+
buildCommand: effectiveBuildCommand,
1521+
devCommand: effectiveDevCommand,
1522+
fullPath,
1523+
installCommand,
1524+
names,
1525+
packageManager,
1526+
previewCommand,
1527+
}),
1528+
])
1529+
1530+
return { dependencies, git, health }
1531+
}
1532+
let diagnostics: RepoDiagnostics
1533+
if (!includeDiagnostics) {
1534+
diagnostics = {
1535+
dependencies: buildUnknownDependencyState(),
1536+
git: buildUnavailableGitState(),
1537+
health: buildUnknownHealth(healthcheckUrl ?? resolvedPreview.previewUrl),
1538+
}
1539+
} else {
1540+
const cachedDiagnostics = repoDiagnosticsCache.get(diagnosticsKey)
1541+
if (cachedDiagnostics && cachedDiagnostics.expiresAt > Date.now()) {
1542+
diagnostics = cachedDiagnostics.value
1543+
} else if (cachedDiagnostics) {
1544+
diagnostics = cachedDiagnostics.value
1545+
enqueueRepoDiagnostics(diagnosticsKey, diagnosticsFactory)
1546+
} else {
1547+
diagnostics = {
1548+
dependencies: buildUnknownDependencyState(),
1549+
git: buildUnavailableGitState(),
1550+
health: buildUnknownHealth(healthcheckUrl ?? resolvedPreview.previewUrl),
1551+
}
1552+
enqueueRepoDiagnostics(diagnosticsKey, diagnosticsFactory)
1553+
}
1554+
}
14631555

14641556
return {
14651557
agentTooling,
14661558
buildCommand: effectiveBuildCommand,
14671559
collection: collection ?? 'direct',
14681560
detectedBy: hasManifest ? 'manifest' : 'files',
1469-
dependencies,
1561+
dependencies: diagnostics.dependencies,
14701562
devCommand: effectiveDevCommand,
14711563
externalUrl,
14721564
failureReport,
1473-
git,
1565+
git: diagnostics.git,
14741566
hasManifest,
14751567
hasSavedMetadata: Boolean(savedOverrides),
1476-
health,
1568+
health: diagnostics.health,
14771569
healthcheckUrl,
14781570
isPinned: savedOverrides?.pinned ?? false,
14791571
install,

repos/workspace-hub/test/workspace-cache-search.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import assert from 'node:assert/strict'
2+
import { execFile } from 'node:child_process'
23
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
34
import os from 'node:os'
45
import path from 'node:path'
56
import { after, before, test } from 'node:test'
7+
import { promisify } from 'node:util'
68

79
type WorkspaceModule = typeof import('../server/workspace.ts')
810
type WorkspaceSearchModule = typeof import('../server/workspace-search.ts')
911

1012
let tempWorkspaceRoot = ''
13+
const execFileAsync = promisify(execFile)
1114

1215
async function writeTextFile(targetPath: string, content: string) {
1316
await mkdir(path.dirname(targetPath), { recursive: true })
@@ -27,6 +30,11 @@ async function createNodeRepo(root: string, relativePath: string) {
2730
)
2831
}
2932

33+
async function initGitRepo(root: string, relativePath: string) {
34+
const repoPath = path.join(root, 'repos', relativePath)
35+
await execFileAsync('git', ['init'], { cwd: repoPath })
36+
}
37+
3038
async function importWorkspaceModule(root: string, cacheTtlMs: string) {
3139
process.env.WORKSPACE_HUB_WORKSPACE_ROOT = root
3240
process.env.WORKSPACE_HUB_DISCOVERY_CACHE_TTL_MS = cacheTtlMs
@@ -140,3 +148,45 @@ test('base summary mode skips heavy diagnostics while preserving repo discovery'
140148
assert.match(repo.git.summary, /skipped for base summary/i)
141149
assert.match(repo.dependencies.reason, /skipped for base summary/i)
142150
})
151+
152+
test('diagnostics mode warms git diagnostics asynchronously via worker', async () => {
153+
await createNodeRepo(tempWorkspaceRoot, 'repo-git-worker')
154+
await initGitRepo(tempWorkspaceRoot, 'repo-git-worker')
155+
const workspaceModule = await importWorkspaceModule(tempWorkspaceRoot, '60000')
156+
workspaceModule.invalidateWorkspaceSummaryCache()
157+
158+
const firstSummary = await workspaceModule.buildWorkspaceSummary(
159+
4101,
160+
new Map(),
161+
new Map(),
162+
{ includeDiagnostics: true },
163+
)
164+
const firstRepo = firstSummary.repos.find((entry) => entry.relativePath.endsWith('repo-git-worker'))
165+
assert.ok(firstRepo)
166+
assert.equal(firstRepo.git.hasGit, false)
167+
168+
const maxAttempts = 20
169+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
170+
await new Promise((resolve) => {
171+
setTimeout(resolve, 50)
172+
})
173+
174+
const refreshedSummary = await workspaceModule.buildWorkspaceSummary(
175+
4101,
176+
new Map(),
177+
new Map(),
178+
{ includeDiagnostics: true },
179+
)
180+
const refreshedRepo = refreshedSummary.repos.find((entry) =>
181+
entry.relativePath.endsWith('repo-git-worker'),
182+
)
183+
assert.ok(refreshedRepo)
184+
185+
if (refreshedRepo.git.hasGit) {
186+
assert.match(refreshedRepo.git.summary, /Git status available|No commits yet on/i)
187+
return
188+
}
189+
}
190+
191+
assert.fail('Expected background diagnostics worker to warm git diagnostics.')
192+
})

0 commit comments

Comments
 (0)