fix(events): prevent relay timeout on worktree discovery waits (#837)

## Summary

Follow-up to merged #835: fix the demonstrated dependency behind real
`Instance event relay routing timed out` disconnects, not duplicate
connection notifications.

Existing diagnostics captured relay ownership blocked in `getWorktrees
-> WorktreeInventory -> POST /api/worktree/refresh`, including a refresh
pending beyond 150 seconds and a separate ~220-second completed call.
That exceeds the unchanged 60-second relay job deadline, so the shared
relay reconnects and Usage correctly clears for all projects.

Event authorization now uses a separate **registered-only** native
worktree inventory and never starts or joins native strategy discovery.
Native project/checkout checks, physical Git membership, clone
exclusion, configured roots, nested-folder and WSL projection remain in
the shared catalogue implementation. Native/local invalidation and
workspace disposal fence both inventories and their distinct
directory-cache namespaces. Ordinary request authorization and explicit
worktree discovery retain refresh and read-your-writes behavior.
Full-location checks receive the connection attempt's abort signal.

No timeout increase, swallowed reconnect, stale authorization fallback
or quota-specific workaround. FIFO lanes, ownership retries,
generation/workspace fences and retained-work budgets are unchanged.

## Validation

- Baseline-red production-manager + relay reproduction: hold discovery
unresolved and require the linked-worktree event to route before
releasing it.
- All **248 workspace tests pass**, including event order/reconnect,
ownership, no-Git, WSL, configured Git roots and inventory invalidation.
- Server TypeScript check passes.
- Isolated OpenCode **2.0.22** location/worktree/event fixture passes,
including real pre-refresh native registration and ordered real session
events during a held fixture-manager discovery request.
- Isolated no-Git native prompt/recovery fixture passes.
- `git diff --check` passes.

## Scope / limitations

The native refresh's internal delay is not claimed fixed; the relay no
longer depends on that unnecessary discovery operation. Actual transport
failures and other stuck native reads still trigger bounded
reconnect/reconciliation. Compaction and window-resize jank are not
established causes or fixed by this PR.

The installed application, shared daemon, user settings and independent
#824 worktree are untouched. This conversation remains attached to
`D:\CodeNomad`.

Existing oversized source touched:
`packages/server/src/workspaces/manager.ts`, approximately 1,260 lines.
No unrelated size-only refactor.

Independent Gatekeeper review will be published against the exact PR
head, with findings resolved and fresh passes until zero findings before
the requested administrator merge.
This commit is contained in:
Pascal André 2026-10-03 04:04:03 +02:00 • committed by GitHub
parent 7cf9b014cd
commit c05f7956d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 274 additions and 23 deletions

View file

@ -71,6 +71,7 @@
- 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.
- The shared native event relay consumes upstream events before slow routing I/O. Preserve per-session/PTY/Shell and per-recipient FIFO, validated full-location ownership, invalidation/connection/workspace fences and bounded-backlog reconnect recovery. A slow recipient must not block another or the shared SDK subscriber. Validate with the relay regressions and isolated native location fixture.
- Event ownership uses registered-only native worktree snapshots, isolated from display/request discovery scans (`ownsDirectory` / `ownsLocation` with the `event` purpose). Never run native strategy refresh from routing or join its pending inventory. Keep physical Git identity, nested-folder/WSL projection, native registration, invalidation and disposal checks shared with ordinary ownership; ordinary requests and explicit worktree discovery retain refresh semantics. Regression: `instance-event-ownership.test.ts` and the isolated native worktree fixture.
- 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.

View file

@ -0,0 +1,62 @@
# Event relay ownership and discovery isolation
## Failure and scope
PR #835 fixed duplicate connection notifications clearing Usage snapshots. It
did not fix real `Instance event relay routing timed out` disconnects.
Existing passive diagnostics captured ownership waiting on
`WorkspaceManager.getWorktrees -> WorktreeInventory.read/load -> native POST
/api/worktree/refresh`. One captured refresh was still pending after 150,964 ms;
another completed in approximately 220 seconds. These waits exceed the relay's
unchanged 60-second retained-job budget. A recipient timeout fails the shared
subscription, reconnects and correctly invalidates Usage for all projects.
The native refresh's internal delay is not identified by these observations.
This fix removes that unnecessary discovery dependency from CodeNomad routing;
it does not claim to repair native strategy execution or every possible network
stall. Compaction and the separate window-resizing report are not reproduction
requirements or established causes of this failure.
## Boundary
- Display reads, request authorization and explicit worktree operations retain
native strategy refresh, discovery of agent-created Git worktrees, fresh family
validation and their existing read-your-writes behavior.
- Event authorization reads **native registered worktrees without refresh** in
its own `WorktreeInventory`. An unresolved discovery scan cannot block it or
supply stale ownership. Native event locations are registered by OpenCode;
isolated real-runtime coverage checks this without an initial refresh.
- The catalogue implementation remains shared: authenticated native project and
checkout checks, physical Git common-directory identity, Git registration,
effective configured checkout roots, nested folder projection and WSL host
translation are unchanged. A shared project ID never grants clone membership.
- Directory caches are separated by the event namespace. Both inventories and
both directory-cache namespaces are invalidated by native worktree changes and
local mutation fences, and forgotten at workspace disposal. A changed ordinary
discovery snapshot also invalidates the event snapshot.
- FIFO lanes, ownership-revision retries, workspace epochs, connection fences,
retained-byte/job budgets and the 60-second watchdog remain intact. Unknown
or revoked registrations fail closed; routing never starts strategy discovery
as a fallback. Legacy full-location validation retains its native identity
check and now receives the current attempt's abort signal.
## Reproduction and validation
`instance-event-ownership.test.ts` uses the production manager and bridge with
real temporary Git checkouts. It holds a native discovery refresh unresolved and
requires a linked-worktree delta to publish before that refresh is released.
The test failed on the baseline and passes with registered-only event ownership.
Its harness deadline is not a changed product timeout.
Additional assertions cover independent clones sharing a native project ID,
nested openings, junction/symlink aliases, registration removal/re-addition,
blocking invalidation, ordinary discovery and stopped-workspace authorization.
Existing relay tests cover FIFO, full legacy locations, session moves/deletions,
native/local invalidation, reconnect/shutdown, backlog failure and recovery.
`scripts/test-opencode-location-native.mjs <absolute CLI executable>` starts its
own authenticated daemon and isolated config/database. Its worktree fixture
checks real pre-refresh native registration, then holds only the fixture
manager's discovery call while real native session rename events must route in
order. The user's daemon, application and settings are never changed.

View file

@ -0,0 +1,120 @@
import assert from "node:assert/strict"
import { execFileSync } from "node:child_process"
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"
import { tmpdir } from "node:os"
import path from "node:path"
import { test } from "node:test"
import type { LocationRef, OpenCodeClient, OpenCodeEvent } from "@opencode/client"
import pino from "pino"
import { EventBus } from "../events/bus"
import { WorkspaceManager } from "./manager"
import { InstanceEventBridge } from "./instance-events"
test("event ownership does not join a stalled native discovery refresh", async t => {
const root = mkdtempSync(path.join(tmpdir(), "codenomad-relay-ownership-"))
const linked = path.join(root, "linked")
const repo = path.join(root, "repo")
const git = (directory: string, ...args: string[]) => execFileSync("git", ["-C", directory, ...args], { encoding: "utf8", stdio: "pipe" }).trim()
git(root, "init", "-b", "main", repo)
git(repo, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "--allow-empty", "-m", "fixture")
git(repo, "worktree", "add", "-b", "linked", linked)
const events = new EventBus()
let release!: () => void
const held = new Promise<void>(resolve => { release = resolve })
let refreshes = 0
const client = {
location: { get: async ({ location }: { location: { directory: string } }) => ({
directory: location.directory,
project: { id: "fixture", directory: git(location.directory, "rev-parse", "--show-toplevel"), canonical: repo },
}) },
worktree: {
refresh: async () => { refreshes++; await held },
list: async () => [{ directory: repo }, { directory: linked }],
},
} as unknown as OpenCodeClient
const logger = pino({ level: "silent" })
const manager = new WorkspaceManager({ rootDir: root, eventBus: events, logger,
settings: { getOwner: () => ({ environmentVariables: {} }) } as any,
binaryResolver: { resolveDefault: () => ({ path: process.execPath, label: "Fixture" }) } as any,
sharedService: {
client: async () => client, validateLocation: async (location: LocationRef) => client.location.get({ location }),
headers: async () => ({}), shutdown: async () => {},
subscribe: async (options: { signal: AbortSignal }) => (async function* () {
yield { type: "server.connected", data: {} } as OpenCodeEvent
yield { id: "delta", created: Date.now(), type: "session.text.delta", location: { directory: linked }, data: { sessionID: "s", delta: "fixture" } } as OpenCodeEvent
await new Promise<void>(resolve => options.signal.addEventListener("abort", () => resolve(), { once: true }))
})(),
} as any,
})
let bridge: InstanceEventBridge | undefined
let scan: Promise<unknown> | undefined
t.after(async () => { bridge?.shutdown(); release(); await scan; await manager.shutdown(); rmSync(root, { recursive: true, force: true }) })
const { workspace } = await manager.create(repo)
scan = manager.getWorktrees(workspace.id)
while (!refreshes) await new Promise(resolve => setTimeout(resolve, 1))
let delivered!: () => void
const received = new Promise<void>(resolve => { delivered = resolve })
events.on("instance.event", event => { if (event.event.type === "session.text.delta") delivered() })
bridge = new InstanceEventBridge({ workspaceManager: manager, eventBus: events, logger })
events.publish({ type: "workspace.started", workspace })
// Allow real Git/filesystem checks on loaded CI hosts. The refresh remains
// unresolved throughout, so this tests isolation, not a latency threshold.
const timeout = setTimeout(() => delivered(), 10_000)
let published = false
events.on("instance.event", event => { if (event.event.type === "session.text.delta") published = true })
await received
clearTimeout(timeout)
assert.equal(published, true, "a delta must be routed before the unrelated discovery scan completes")
assert.equal(refreshes, 1, "event authorization must not start its own native discovery mutation")
})
test("registered-only event ownership retains clone, nested-folder, alias and invalidation fences", async t => {
const root = mkdtempSync(path.join(tmpdir(), "codenomad-event-authority-"))
const repo = path.join(root, "repo"), linked = path.join(root, "linked"), clone = path.join(root, "clone")
const git = (directory: string, ...args: string[]) => execFileSync("git", ["-C", directory, ...args], { encoding: "utf8", stdio: "pipe" }).trim()
git(root, "init", "-b", "main", repo)
git(repo, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "--allow-empty", "-m", "fixture")
git(repo, "worktree", "add", "-b", "linked", linked)
git(root, "clone", "--no-hardlinks", repo, clone)
for (const directory of [repo, linked, clone]) mkdirSync(path.join(directory, "nested"))
const alias = path.join(root, "alias")
symlinkSync(linked, alias, process.platform === "win32" ? "junction" : "dir")
let registered = [repo, linked, clone]
let refreshes = 0
const client = {
location: { get: async ({ location }: { location: { directory: string } }) => ({ directory: location.directory,
project: { id: "same-project", directory: git(location.directory, "rev-parse", "--show-toplevel"), canonical: repo },
}) },
worktree: { refresh: async () => { refreshes++ }, list: async () => registered.map(directory => ({ directory })) },
} as unknown as OpenCodeClient
const manager = new WorkspaceManager({ rootDir: root, eventBus: new EventBus(), logger: pino({ level: "silent" }),
settings: { getOwner: () => ({ environmentVariables: {} }) } as any,
binaryResolver: { resolveDefault: () => ({ path: process.execPath, label: "Fixture" }) } as any,
sharedService: { client: async () => client, validateLocation: async (location: LocationRef) => client.location.get({ location }),
headers: async () => ({}), evictLocation: async () => {}, shutdown: async () => {} } as any,
})
t.after(async () => { await manager.shutdown(); rmSync(root, { recursive: true, force: true }) })
const { workspace } = await manager.create(path.join(repo, "nested"))
const owns = (directory: string) => manager.ownsDirectory(workspace.id, directory, "event")
assert.equal(await owns(path.join(linked, "nested")), true)
assert.equal(await owns(path.join(alias, "nested")), true)
assert.equal(await owns(linked), false, "a nested opening must not grant the whole sibling checkout")
assert.equal(await owns(path.join(clone, "nested")), false, "native project IDs cannot authorize an independent physical clone")
assert.equal(refreshes, 0)
assert.equal(await manager.getServiceDirectoryForPath(workspace.id, path.join(alias, "nested"), "event"), realpathSync(path.join(linked, "nested")))
// Even if the files remain present, removal from the native registration is
// sufficient to revoke membership after a worktree event/mutation fence.
registered = [repo, clone]
manager.invalidateWorktrees("blocking")
assert.equal(await owns(path.join(linked, "nested")), false)
assert.equal(refreshes, 0, "negative refresh-on-miss must remain registered-only")
registered = [repo, linked, clone]
manager.invalidateWorktrees()
assert.equal(await owns(path.join(linked, "nested")), true)
// Keep the ordinary discovery path unchanged, including newly agent-created
// worktrees which have not produced any native event or registration yet.
await manager.getWorktrees(workspace.id, "fresh")
assert.equal(refreshes, 1)
await manager.delete(workspace.id)
assert.equal(await owns(path.join(linked, "nested")), false)
})

View file

@ -219,7 +219,7 @@ export class InstanceEventBridge {
let revision: number
do {
revision = this.ownershipRevision
if (location) allowed = await this.resolveLocationOwner(instanceId, location, current)
if (location) allowed = await this.resolveLocationOwner(instanceId, location, current, route.signal)
if (!current()) return
} while (revision !== this.ownershipRevision)
// Stopped recipients and work from an earlier connection never publish.
@ -293,15 +293,15 @@ export class InstanceEventBridge {
return location
}
private resolveLocationOwner(instanceId: string, location: LocationRef, current: () => boolean): Promise<boolean> {
private resolveLocationOwner(instanceId: string, location: LocationRef, current: () => boolean, signal: AbortSignal): Promise<boolean> {
const now = Date.now()
const key = JSON.stringify([instanceId, location.directory, location.workspaceID])
const cached = this.locationOwners.get(key)
if (cached && cached.expiresAt > now) return cached.owns
const resolve = () => location.workspaceID === undefined
? this.options.workspaceManager.ownsDirectory(instanceId, location.directory)
: this.options.workspaceManager.ownsLocation(instanceId, location)
? this.options.workspaceManager.ownsDirectory(instanceId, location.directory, "event")
: this.options.workspaceManager.ownsLocation(instanceId, location, undefined, signal, "event")
const owns = Promise.resolve().then(resolve).catch(() => current() ? resolve() : false)
.catch(error => {
if (current()) this.options.logger.warn({ err: error, instanceId, location }, "Failed to resolve instance event location owner")

View file

@ -257,21 +257,21 @@ export class WorkspaceManager {
this.sharedService.invalidate?.()
}
async ownsDirectory(id: string, directory: string): Promise<boolean> {
async ownsDirectory(id: string, directory: string, purpose: "request" | "event" = "request"): Promise<boolean> {
const record = this.workspaces.get(id)
if (!record?.[WORKSPACE_STATE].published) return false
if (directory === record.path || directory === record.location?.directory) return true
if (await this.ownsHostDirectory(record, directory)) return true
if (await this.ownsHostDirectory(record, directory, purpose)) return true
if (!record.wslDistro) return false
const hostDirectory = await this.resolveWslHostDirectory(directory, record.wslDistro, DEFAULT_LAUNCH_TIMEOUT_MS)
return Boolean(hostDirectory && await this.ownsHostDirectory(record, hostDirectory))
return Boolean(hostDirectory && await this.ownsHostDirectory(record, hostDirectory, purpose))
}
async ownsLocation(id: string, location: LocationRef, client?: OpenCodeClient, signal?: AbortSignal): Promise<boolean> {
async ownsLocation(id: string, location: LocationRef, client?: OpenCodeClient, signal?: AbortSignal, purpose: "request" | "event" = "request"): Promise<boolean> {
signal?.throwIfAborted()
const record = this.workspaces.get(id)
if (!record?.[WORKSPACE_STATE].published) return false
const directory = await this.getServiceDirectoryForPath(id, location.directory)
const directory = await this.getServiceDirectoryForPath(id, location.directory, purpose)
signal?.throwIfAborted()
if (!directory) return false
if (location.workspaceID === undefined) return true
@ -289,7 +289,7 @@ export class WorkspaceManager {
}
}
async getServiceDirectoryForPath(id: string, directory: string): Promise<string | undefined> {
async getServiceDirectoryForPath(id: string, directory: string, purpose: "request" | "event" = "request"): Promise<string | undefined> {
const record = this.workspaces.get(id)
if (!record?.[WORKSPACE_STATE].published) return undefined
// Directory authorization does not need the checkout's Git mutation identity.
@ -300,7 +300,7 @@ export class WorkspaceManager {
])
if (target && target === root) return target
}
const owned = await this.resolveOwnedWorktree(record, directory)
const owned = await this.resolveOwnedWorktree(record, directory, purpose)
if (!owned) return undefined
if (!record.wslDistro) return owned.directory
return await this.resolveWslServiceDirectory(owned.directory, record.wslDistro, DEFAULT_LAUNCH_TIMEOUT_MS) ?? undefined
@ -358,6 +358,8 @@ export class WorkspaceManager {
load: (id) => this.nativeWorktreeContext(id).then(listNativeWorktrees),
changed: (id) => {
invalidateWorktreeCache(id)
invalidateWorktreeCache(`event:${id}`)
this.eventWorktreeInventory.invalidate(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"),
@ -368,8 +370,28 @@ export class WorkspaceManager {
return this.worktreeInventory.read(id, mode)
}
// Registered-only reads cannot join discovery scans. They retain all native,
// physical Git, mutation and disposal checks used by the ordinary catalogue.
private readonly eventWorktreeInventory = new WorktreeInventory({
load: (id) => this.nativeWorktreeContext(id).then(context => listNativeWorktrees(context, { refresh: false })),
changed: (id) => {
invalidateWorktreeCache(`event:${id}`)
this.options.eventBus.publish({ type: "workspace.worktreesChanged", workspaceId: id })
},
failed: (id, error) => this.options.logger.debug({ id, err: error }, "Event worktree inventory refresh failed"),
now: () => this.now(),
})
private async ownershipWorktrees(id: string, refresh: boolean | undefined, purpose: "request" | "event") {
const mode = refresh ? "fresh" : "validated"
return (await (purpose === "event"
? this.eventWorktreeInventory.read(id, mode)
: this.getWorktrees(id, mode))).worktrees
}
invalidateWorktrees(mode: "lazy" | "blocking" = "lazy"): void {
this.worktreeInventory.invalidate(undefined, mode)
this.eventWorktreeInventory.invalidate(undefined, mode)
invalidateWorktreeCache()
}
@ -385,22 +407,22 @@ export class WorkspaceManager {
finally { this.invalidateWorktrees("blocking") }
}
private async ownsHostDirectory(record: WorkspaceRecord, directory: string): Promise<boolean> {
private async ownsHostDirectory(record: WorkspaceRecord, directory: string, purpose: "request" | "event" = "request"): Promise<boolean> {
const [target, root] = await Promise.all([
realpath(directory).catch(() => undefined), realpath(record.path).catch(() => undefined),
])
if (target && target === root) return true
if (target && root && !isPathWithinWorktree(root, target) && !await sharesGitCommonDirectory(root, target)) return false
return (await resolveOwnedWorktreePath({
workspaceId: record.id,
workspaceId: purpose === "event" ? `event:${record.id}` : record.id,
workspacePath: record.path,
directory,
loadWorktrees: async (refresh) => (await this.getWorktrees(record.id, refresh ? "fresh" : "validated")).worktrees,
loadWorktrees: (refresh) => this.ownershipWorktrees(record.id, refresh, purpose),
logger: this.options.logger,
})) !== null
}
private async resolveOwnedWorktree(record: WorkspaceRecord, directory: string) {
private async resolveOwnedWorktree(record: WorkspaceRecord, directory: string, purpose: "request" | "event" = "request") {
const hostDirectory = record.wslDistro
? isWindowsHostPath(directory)
? directory
@ -426,10 +448,10 @@ export class WorkspaceManager {
}
if (target && root && !isPathWithinWorktree(root, target) && !await sharesGitCommonDirectory(root, target)) return null
return resolveOwnedWorktreePath({
workspaceId: record.id,
workspaceId: purpose === "event" ? `event:${record.id}` : record.id,
workspacePath: record.path,
directory: hostDirectory,
loadWorktrees: async (refresh) => (await this.getWorktrees(record.id, refresh ? "fresh" : "validated")).worktrees,
loadWorktrees: (refresh) => this.ownershipWorktrees(record.id, refresh, purpose),
logger: this.options.logger,
})
}
@ -1150,7 +1172,9 @@ export class WorkspaceManager {
if (this.workspaces.get(id) !== record) return
this.workspaces.delete(id)
this.worktreeInventory.forget(id)
this.eventWorktreeInventory.forget(id)
invalidateWorktreeCache(id)
invalidateWorktreeCache(`event:${id}`)
clearWorkspaceSearchCache(record.path)
if (publishStopped) this.publishStopped(record, reason)
}

View file

@ -23,7 +23,7 @@ async function sameDirectory(left: string, right: string): Promise<boolean> {
return a.isDirectory() && b.isDirectory() && a.dev === b.dev && a.ino === b.ino && a.birthtimeMs === b.birthtimeMs
}
export async function listNativeWorktrees(context: NativeWorktreeContext): Promise<WorktreeListResponse> {
export async function listNativeWorktrees(context: NativeWorktreeContext, options: { refresh?: boolean } = {}): Promise<WorktreeListResponse> {
const { client, location, workspacePath, toHost } = context
let isGitRepo: boolean
try {
@ -42,9 +42,11 @@ export async function listNativeWorktrees(context: NativeWorktreeContext): Promi
if (!mainHost || !await sameDirectory(local.common, (await readCheckout(mainHost)).common)) {
throw new Error("OpenCode resolved a different local repository")
}
const options = locationRequestOptions(location, { includeDirectory: true })
await client.worktree.refresh({ projectID: current.project.id }, options)
const native = await client.worktree.list({ projectID: current.project.id }, options)
const requestOptions = locationRequestOptions(location, { includeDirectory: true })
// Routing native events must not run strategy discovery or join a display
// scan held inside worktree.refresh. Native registration remains authority.
if (options.refresh !== false) await client.worktree.refresh({ projectID: current.project.id }, requestOptions)
const native = await client.worktree.list({ projectID: current.project.id }, requestOptions)
const verifyCheckoutRoot = await createCheckoutRootVerifier(workspacePath)
const annotations = new Map<string, Awaited<ReturnType<typeof readWorktreeAnnotations>>[number]>()
for (const annotation of await readWorktreeAnnotations(workspacePath)) {

View file

@ -4,6 +4,7 @@ import { mkdir, writeFile, rm, realpath } from "node:fs/promises"
import path from "node:path"
import { tsImport } from "tsx/esm/api"
import pino from "pino"
import { setTimeout as delay } from "node:timers/promises"
// Invoked by the isolated daemon fixture only. No service discovery/user state.
export async function testNativeWorktreeManagement({ client, root }) {
@ -32,6 +33,8 @@ export async function testNativeWorktreeManagement({ client, root }) {
await client.location.get({ location: { directory: external } })
await client.location.get({ location: { directory: clone } })
const context = { client, location, workspacePath: repo, toHost: async directory => directory }
const registered = await listNativeWorktrees(context, { refresh: false })
assert.equal(registered.worktrees.length, 2, "native event locations must be registered without strategy refresh; shared-project clones remain excluded")
let catalogue = await listNativeWorktrees(context)
assert.equal(catalogue.worktrees.length, 2, "same-project independent clone must be excluded")
const source = catalogue.worktrees.find(entry => entry.branch === "agent")
@ -105,24 +108,59 @@ export async function testNativeWorktreeManagement({ client, root }) {
}
const { WorkspaceManager } = await tsImport("../packages/server/src/workspaces/manager.ts", import.meta.url)
const { EventBus } = await tsImport("../packages/server/src/events/bus.ts", import.meta.url)
const { InstanceEventBridge } = await tsImport("../packages/server/src/workspaces/instance-events.ts", import.meta.url)
const bus = new EventBus()
let holdRefresh = false, refreshEntered = false, releaseRefresh
const heldRefresh = new Promise(resolve => { releaseRefresh = resolve })
const managerClient = { ...client, worktree: { ...client.worktree, refresh: async (...args) => {
if (holdRefresh) { refreshEntered = true; await heldRefresh }
return client.worktree.refresh(...args)
} } }
// Exercise the production manager's post-mutation invalidation using only
// this fixture's authenticated client. No native discovery/user service.
const manager = new WorkspaceManager({
rootDir: root,
settings: { getOwner: () => ({ environmentVariables: {} }) },
binaryResolver: { resolveDefault: () => ({ path: process.execPath, label: "Isolated fixture" }) },
eventBus: new EventBus(),
eventBus: bus,
logger: pino({ level: "silent" }),
sharedService: {
client: async () => client,
client: async () => managerClient,
headers: async () => ({}),
validateLocation: async location => client.location.get({ location }),
subscribe: async options => client.event.subscribe(options),
shutdown: async () => {},
},
})
let bridge, pendingScan, eventSession
try {
const { workspace } = await manager.create(repo)
const before = await manager.getWorktrees(workspace.id)
const statuses = [], renames = []
bus.on("instance.eventStatus", event => statuses.push(event.status))
bus.on("instance.event", ({ event }) => { if (event.type === "session.renamed") renames.push(event.data.title) })
bridge = new InstanceEventBridge({ workspaceManager: manager, eventBus: bus, logger: pino({ level: "silent" }) })
const until = async predicate => {
const deadline = Date.now() + 10_000
while (!predicate()) { assert.ok(Date.now() < deadline, "native event ownership joined the held discovery refresh"); await delay(10) }
}
bus.publish({ type: "workspace.started", workspace })
await until(() => statuses.includes("connected"))
holdRefresh = true
pendingScan = manager.getWorktrees(workspace.id, "fresh")
await until(() => refreshEntered)
// This location and its events are real native registrations, not a stub
// inventory. Only this isolated manager's discovery request is held.
eventSession = await client.session.create({ location: { directory: external } })
await client.session.update({ sessionID: eventSession.id, title: "held-discovery-event-1" })
await client.session.update({ sessionID: eventSession.id, title: "held-discovery-event-2" })
await until(() => renames.length === 2)
assert.deepEqual(renames, ["held-discovery-event-1", "held-discovery-event-2"])
assert.equal(statuses.includes("error"), false)
assert.equal(await manager.ownsDirectory(workspace.id, clone, "event"), false)
releaseRefresh(); holdRefresh = false; await pendingScan
bridge.shutdown()
console.log("PASS: production event relay routes ordered native worktree events while a separate discovery refresh is stalled")
const added = await manager.createWorktree(workspace.id, "cached-create")
const afterCreate = await manager.getWorktrees(workspace.id)
assert.equal(afterCreate.worktrees.length, before.worktrees.length + 1)
@ -133,6 +171,10 @@ export async function testNativeWorktreeManagement({ client, root }) {
assert.ok(!afterRemove.worktrees.some(entry => entry.slug === added.slug), "post-delete refresh must not resurrect a cached checkout")
console.log("PASS: production manager warm-cache create/remove read-your-writes")
} finally {
bridge?.shutdown()
releaseRefresh()
await pendingScan
if (eventSession) await client.session.remove({ sessionID: eventSession.id })
await manager.shutdown()
}
console.log("PASS: native worktree discovery/create/remove, clone scope, selected HEAD, default parent, named branches, stable identity, nested paths and dirty/checked-out guards")