diff --git a/AGENTS.md b/AGENTS.md index 651bf0310..8567b8bef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/dev-docs/EVENT_RELAY_OWNERSHIP.md b/dev-docs/EVENT_RELAY_OWNERSHIP.md new file mode 100644 index 000000000..e9be0536d --- /dev/null +++ b/dev-docs/EVENT_RELAY_OWNERSHIP.md @@ -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 ` 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. diff --git a/packages/server/src/workspaces/instance-event-ownership.test.ts b/packages/server/src/workspaces/instance-event-ownership.test.ts new file mode 100644 index 000000000..55ca18b11 --- /dev/null +++ b/packages/server/src/workspaces/instance-event-ownership.test.ts @@ -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(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(resolve => options.signal.addEventListener("abort", () => resolve(), { once: true })) + })(), + } as any, + }) + let bridge: InstanceEventBridge | undefined + let scan: Promise | 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(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) +}) diff --git a/packages/server/src/workspaces/instance-events.ts b/packages/server/src/workspaces/instance-events.ts index 8dde42882..8799388d5 100644 --- a/packages/server/src/workspaces/instance-events.ts +++ b/packages/server/src/workspaces/instance-events.ts @@ -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 { + private resolveLocationOwner(instanceId: string, location: LocationRef, current: () => boolean, signal: AbortSignal): Promise { 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") diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index ed4ece838..24e7818fe 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -257,21 +257,21 @@ export class WorkspaceManager { this.sharedService.invalidate?.() } - async ownsDirectory(id: string, directory: string): Promise { + async ownsDirectory(id: string, directory: string, purpose: "request" | "event" = "request"): Promise { 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 { + async ownsLocation(id: string, location: LocationRef, client?: OpenCodeClient, signal?: AbortSignal, purpose: "request" | "event" = "request"): Promise { 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 { + async getServiceDirectoryForPath(id: string, directory: string, purpose: "request" | "event" = "request"): Promise { 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 { + private async ownsHostDirectory(record: WorkspaceRecord, directory: string, purpose: "request" | "event" = "request"): Promise { 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) } diff --git a/packages/server/src/workspaces/native-worktrees.ts b/packages/server/src/workspaces/native-worktrees.ts index 474aee102..44cb113f7 100644 --- a/packages/server/src/workspaces/native-worktrees.ts +++ b/packages/server/src/workspaces/native-worktrees.ts @@ -23,7 +23,7 @@ async function sameDirectory(left: string, right: string): Promise { return a.isDirectory() && b.isDirectory() && a.dev === b.dev && a.ino === b.ino && a.birthtimeMs === b.birthtimeMs } -export async function listNativeWorktrees(context: NativeWorktreeContext): Promise { +export async function listNativeWorktrees(context: NativeWorktreeContext, options: { refresh?: boolean } = {}): Promise { 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>[number]>() for (const annotation of await readWorktreeAnnotations(workspacePath)) { diff --git a/scripts/test-native-worktree-management.mjs b/scripts/test-native-worktree-management.mjs index 24a73a068..b68aa6caa 100644 --- a/scripts/test-native-worktree-management.mjs +++ b/scripts/test-native-worktree-management.mjs @@ -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")