mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-20 11:16:06 +00:00
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:
parent
7cf9b014cd
commit
c05f7956d6
7 changed files with 274 additions and 23 deletions
|
|
@ -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.
|
||||
|
|
|
|||
62
dev-docs/EVENT_RELAY_OWNERSHIP.md
Normal file
62
dev-docs/EVENT_RELAY_OWNERSHIP.md
Normal 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.
|
||||
120
packages/server/src/workspaces/instance-event-ownership.test.ts
Normal file
120
packages/server/src/workspaces/instance-event-ownership.test.ts
Normal 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)
|
||||
})
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue