Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@

## Coding Principles

- Follow `dev-docs/CACHE_REFRESH_CONVENTIONS.md` for display snapshots, coalesced trailing refreshes, stale-response fencing and authoritative mutation reads. Check existing feature semantics before adding another cache or refresh policy.

- Worktree discovery/create/remove use `workspaces/native-worktrees.ts` and the native OpenCode worktree API. CodeNomad supplies the `.codenomad/worktrees` default, named-branch policy and verified family transactions. Git common-directory identity scopes the native inventory to the opened local repository; opaque worktree identifiers are separate from mutable branch labels. Validate through `scripts/test-opencode-location-native.mjs` with an isolated CLI and `tests/browser/worktrees.test.ts` for selector gestures.
- Worktree inventory snapshots live in `workspaces/worktree-inventory.ts`: display reads serve cached data and lazily revalidate, directory authorization uses validated reads, and family transactions force fresh reads. Invalidation retains display data and fences pending scans; `workspace.worktreesChanged` refreshes existing UI consumers after a changed snapshot is published. Keep selector opening independent of refresh completion and suppress duplicate selection events during inventory reconciliation.

- Session pruning is a narrow V2 plugin/RPC exception under `packages/server/src/opencode/session-pruning/`; see `dev-docs/SESSION_PRUNING_RPC.md`. Bundle it with the shared server for both desktop hosts and provision through normal native plugin discovery. RPC registrations follow backend presence; clean shutdown removes that backend's lease and crashes expire. Loading never deletes content. Deletion occurs only on an explicit pruning request, without an extra enable-write switch or beta-number gate. Keep generic RPC proxy access closed. Writes validate actual storage, a fresh daemon-storage identity challenge and the native durable execution claim inside a synchronous SQLite transaction. Run isolated native concurrency/payload and client-cache regressions; tests must never target the shared daemon or a user's database.
- Favor KISS by keeping modules narrowly scoped and limiting public APIs to what callers actually need.
Expand Down
41 changes: 41 additions & 0 deletions dev-docs/CACHE_REFRESH_CONVENTIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Cache and refresh conventions

Use consistent refresh semantics across features. Keep domain-specific implementations
where their authority, lifetime or runtime differs.

## Common rules

- Keep the last successful display snapshot while a passive refresh runs.
- Share concurrent reads for the same identity. During a read, coalesce refresh
requests into one pending follow-up rather than queueing one request per event.
- Invalidate on relevant events or mutations; a TTL expiring does not itself start
work. Avoid background polling merely to keep a hidden view warm.
- A response must still belong to the current identity/generation before publication.
Serialize follow-up reads so an older response cannot overwrite their result.
- Preserve the last good snapshot on refresh failure and permit recovery. Surface
failures for explicit operations rather than reporting stale data as success.
- A successful mutation must be visible to its next dependent read. Display-cache
policies must not weaken ownership checks or transactional revalidation.
- Updating a displayed collection is not a user selection. Preserve its open state,
stable-key keyboard target and inline actions through background refreshes.

## Current implementations and limits

| Area | Current behaviour | Implementation |
| --- | --- | --- |
| Providers/models | Retained signals; shared in-flight catalogue load; dirty-bit trailing refresh; instance, location and request-generation checks. No completed-result TTL inside `fetchProviders` itself. | `packages/ui/src/stores/session-api.ts` |
| Git changes | Filesystem events debounce for 100 ms; one passive refresh plus a pending follow-up; hidden tab marked stale; request versions protect status/diff. Server shares concurrent status requests, not completed results. | `useGitChanges.ts`, `filesystem-events.ts`, server `workspaces/git-status.ts` |
| Worktree display | Last successful server snapshot; demand-driven refresh after 10 s or invalidation; one scan per workspace; obsolete scans discarded and followed by validation; UI requests coalesced. | server `workspaces/worktree-inventory.ts`, UI `stores/worktrees.ts` |
| Worktree authority | Validated reads await stale-inventory revalidation; family transactions force scans. Ownership misses can bypass a warm inventory once per directory-cache lifetime. Create/remove requires a validated next display read. | server `workspaces/worktree-directory.ts`, `manager.ts` |
| Render cache | Explicit versioned values scoped to instance/session; no network scheduler or TTL policy. | UI `lib/global-cache.ts` |
| Virtualized lists | Session list, transcript and timeline use `virtua/solid`; virtualization limits rendered rows, not network refreshes. | UI `session-list.tsx`, `virtual-follow-list.tsx`, `message-timeline.tsx` |

Git updates are regulated, but not incremental: every new server status calculation
runs five Git commands plus untracked-file processing, and the UI also requests
native status. Continuous activity can sustain repeated full calculations and
selected-diff reads. This is not a completed-result cache.

The worktree cache is in memory. It does not reduce the first native inventory scan,
the cost of mandatory authoritative scans, or all latency in the serial event relay.
An isolated native fixture covers warm-cache create/remove visibility; browser
fixtures cover menu updates, focus, old responses and refresh bursts.
2 changes: 2 additions & 0 deletions packages/server/src/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,7 @@ export type WorkspaceEventType =
| "workspace.error"
| "workspace.stopped"
| "workspace.log"
| "workspace.worktreesChanged"
| "sidecar.updated"
| "sidecar.removed"
| "storage.configChanged"
Expand All @@ -484,6 +485,7 @@ export type WorkspaceEventPayload =
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
| { type: "workspace.stopped"; workspaceId: string; reason?: "deleted" | "stopped" }
| { type: "workspace.log"; entry: WorkspaceLogEntry }
| { type: "workspace.worktreesChanged"; workspaceId: string }
| { type: "sidecar.updated"; sidecar: SideCar }
| { type: "sidecar.removed"; sidecarId: string }
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket }
Expand Down
2 changes: 2 additions & 0 deletions packages/server/src/events/bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export class EventBus extends EventEmitter {
this.on("workspace.error", handler)
this.on("workspace.stopped", handler)
this.on("workspace.log", handler)
this.on("workspace.worktreesChanged", handler)
this.on("sidecar.updated", handler)
this.on("sidecar.removed", handler)
this.on("storage.configChanged", handler)
Expand All @@ -51,6 +52,7 @@ export class EventBus extends EventEmitter {
this.off("workspace.error", handler)
this.off("workspace.stopped", handler)
this.off("workspace.log", handler)
this.off("workspace.worktreesChanged", handler)
this.off("sidecar.updated", handler)
this.off("sidecar.removed", handler)
this.off("storage.configChanged", handler)
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/server/routes/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ async function resolveGitWorktreeDirectory(
workspaceId: workspace.id,
workspacePath: workspace.path,
worktreeSlug,
loadWorktrees: async () => (await workspaceManager.getWorktrees(workspace.id)).worktrees,
loadWorktrees: async (refresh) => (await workspaceManager.getWorktrees(workspace.id, refresh ? "fresh" : "validated")).worktrees,
logger,
})
if (!directory) {
Expand Down
5 changes: 2 additions & 3 deletions packages/server/src/server/routes/worktrees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) {

try {
const response: WorktreeListResponse = await deps.workspaceManager.getWorktrees(workspace.id)
invalidateWorktreeCache(workspace.id)
return response
} catch (error) {
return handleError(error, reply)
Expand Down Expand Up @@ -85,7 +84,7 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) {
return { error: "Workspace is not a Git repository" }
}

const catalogue = await deps.workspaceManager.getWorktrees(workspace.id)
const catalogue = await deps.workspaceManager.getWorktrees(workspace.id, "fresh")
const source = catalogue.worktrees.find(entry => entry.slug === (body.fromSlug ?? "root"))
if (!source) throw new ProjectSessionError("Source worktree not found", 404)
const identities = await Promise.all(catalogue.worktrees.map(entry => (
Expand Down Expand Up @@ -265,7 +264,7 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) {
}

function strictWorktrees(manager: WorkspaceManager, workspaceId: string) {
return manager.getWorktrees(workspaceId).then(result => result.worktrees).catch((error) => {
return manager.getWorktrees(workspaceId, "fresh").then(result => result.worktrees).catch((error) => {
throw new ProjectSessionError(error instanceof Error ? error.message : "Unable to read native worktree inventory", 502)
})
}
Expand Down
8 changes: 7 additions & 1 deletion packages/server/src/workspaces/git-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from "node:assert/strict"
import { mkdtemp, rm } from "node:fs/promises"
import fs from "node:fs/promises"
import { tmpdir } from "node:os"
import { syncBuiltinESMExports } from "node:module"
import path from "node:path"
import { describe, it } from "node:test"

Expand All @@ -20,13 +21,16 @@ describe("worktree git status singleflight", () => {
let canonicalized = 0
let ready!: () => void
const bothCanonicalized = new Promise<void>((resolve) => { ready = resolve })
t.mock.method(fs, "realpath", async (value: string) => {
const canonicalization = t.mock.method(fs, "realpath", async (value: string) => {
const result = await realpath(value)
canonicalized += 1
if (canonicalized === 2) ready()
await bothCanonicalized
return result
})
// Native ESM named imports do not see default-export monkey patches until
// synchronized. The CI runner uses ESM even when a local tsx run uses CJS.
syncBuiltinESMExports()
const run = async () => {
calls += 1
await blocked
Expand All @@ -49,6 +53,8 @@ describe("worktree git status singleflight", () => {
assert.equal(calls, 10)
} finally {
release()
canonicalization.mock.restore()
syncBuiltinESMExports()
await rm(directory, { recursive: true, force: true })
}
})
Expand Down
3 changes: 1 addition & 2 deletions packages/server/src/workspaces/instance-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { EventBus } from "../events/bus"
import { Logger } from "../logger"
import { WorkspaceManager } from "./manager"
import { InstanceStreamStatus } from "../api-types"
import { invalidateWorktreeCache } from "./worktree-directory"

const RECONNECT_DELAY_MS = 1000
const LOCATION_OWNER_CACHE_MS = 2000
Expand Down Expand Up @@ -110,7 +109,7 @@ export class InstanceEventBridge {

private async publishEvent(event: OpenCodeEvent) {
if (event.type === "worktree.updated") {
invalidateWorktreeCache()
this.options.workspaceManager.invalidateWorktrees()
this.locationOwners.clear()
}
const sessionId = this.sessionId(event)
Expand Down
37 changes: 24 additions & 13 deletions packages/server/src/workspaces/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
import { WslOpenCodeService } from "./wsl-opencode-service"
import { invalidateWorktreeCache, isPathOwnedByWorktree, resolveOwnedWorktreePath } from "./worktree-directory"
import { listNativeWorktrees, createNativeWorktree, removeNativeWorktree } from "./native-worktrees"
import { WorktreeInventory } from "./worktree-inventory"
import { resolveRepoRoot } from "./git-worktrees"
import { locationRequestOptions, readLocationRef, sameLocation } from "../opencode/compatibility/location"

Expand Down Expand Up @@ -317,25 +318,33 @@ export class WorkspaceManager {
}
}

private readonly worktreeInventoryRequests = new Map<string, ReturnType<typeof listNativeWorktrees>>()
private readonly worktreeInventory = new WorktreeInventory({
load: (id) => this.nativeWorktreeContext(id).then(listNativeWorktrees),
changed: (id) => {
invalidateWorktreeCache(id)
this.options.eventBus.publish({ type: "workspace.worktreesChanged", workspaceId: id })
},
failed: (id, error) => this.options.logger.warn({ workspaceId: id, err: error }, "Failed to refresh worktree inventory"),
now: () => this.now(),
})

async getWorktrees(id: string) {
const pending = this.worktreeInventoryRequests.get(id)
if (pending) return pending
const task = this.nativeWorktreeContext(id).then(listNativeWorktrees)
this.worktreeInventoryRequests.set(id, task)
try { return await task }
finally { if (this.worktreeInventoryRequests.get(id) === task) this.worktreeInventoryRequests.delete(id) }
getWorktrees(id: string, mode: "cached" | "validated" | "fresh" = "cached") {
return this.worktreeInventory.read(id, mode)
}

invalidateWorktrees(mode: "lazy" | "blocking" = "lazy"): void {
this.worktreeInventory.invalidate(undefined, mode)
invalidateWorktreeCache()
}

async createWorktree(id: string, branch: string, fromSlug?: string) {
try { return await createNativeWorktree(await this.nativeWorktreeContext(id), branch, fromSlug) }
finally { invalidateWorktreeCache() }
finally { this.invalidateWorktrees("blocking") }
}

async removeWorktree(id: string, serviceDirectory: string, force: boolean) {
try { return await removeNativeWorktree(await this.nativeWorktreeContext(id), serviceDirectory, force) }
finally { invalidateWorktreeCache() }
finally { this.invalidateWorktrees("blocking") }
}

private async ownsHostDirectory(record: WorkspaceRecord, directory: string): Promise<boolean> {
Expand All @@ -347,7 +356,7 @@ export class WorkspaceManager {
workspaceId: record.id,
workspacePath: record.path,
directory,
loadWorktrees: async () => (await this.getWorktrees(record.id)).worktrees,
loadWorktrees: async (refresh) => (await this.getWorktrees(record.id, refresh ? "fresh" : "validated")).worktrees,
logger: this.options.logger,
})) !== null
}
Expand All @@ -372,7 +381,7 @@ export class WorkspaceManager {
workspaceId: record.id,
workspacePath: record.path,
directory: hostDirectory,
loadWorktrees: async () => (await this.getWorktrees(record.id)).worktrees,
loadWorktrees: async (refresh) => (await this.getWorktrees(record.id, refresh ? "fresh" : "validated")).worktrees,
logger: this.options.logger,
})
}
Expand All @@ -391,7 +400,7 @@ export class WorkspaceManager {
workspaceId: record.id,
workspacePath: record.path,
candidate,
loadWorktrees: async () => (await this.getWorktrees(record.id)).worktrees,
loadWorktrees: async (refresh) => (await this.getWorktrees(record.id, refresh ? "fresh" : "validated")).worktrees,
logger: this.options.logger,
})
}
Expand Down Expand Up @@ -1042,6 +1051,8 @@ export class WorkspaceManager {
): void {
if (this.workspaces.get(id) !== record) return
this.workspaces.delete(id)
this.worktreeInventory.forget(id)
invalidateWorktreeCache(id)
clearWorkspaceSearchCache(record.path)
if (publishStopped) this.publishStopped(record, reason)
}
Expand Down
Loading
Loading