Skip to content

Commit e815eba

Browse files
authored
fix(ui): refresh worktrees from OpenCode ready events (NeuralNomadsAI#583)
## Problem Worktrees created through OpenCode can leave CodeNomad's cached worktree inventory and workspace mapping stale. The previous approach in NeuralNomadsAI#516 polled on `session.idle` and attempted to infer which session created a newly observed worktree, which introduced race conditions and unsafe attribution. ## Fix - route OpenCode's `worktree.ready` event through the existing UI SSE manager; - reload the live Git worktree inventory when that event arrives; - synchronize OpenCode workspace mappings only after the refreshed inventory is available; - avoid idle polling and avoid auto-switching a session because the event identifies the new worktree directory but does not identify an originating session. The server bridge already forwards arbitrary OpenCode events and preserves the global event `directory`, so no server change is required. Supersedes NeuralNomadsAI#516 and implements the SSE approach suggested by @shantur NeuralNomadsAI#516 (comment). ## Validation - `bun test --conditions browser packages/ui/src` (`126` passing) - `npm run typecheck --workspace @codenomad/ui` - `git diff --check`
1 parent f31d8c1 commit e815eba

5 files changed

Lines changed: 238 additions & 41 deletions

File tree

packages/ui/src/lib/sse-manager.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,15 @@ interface ServerInstanceDisposedEvent {
7373
}
7474
}
7575

76+
export interface WorktreeReadyEvent {
77+
type: "worktree.ready"
78+
directory?: string
79+
properties: {
80+
name: string
81+
branch?: string
82+
}
83+
}
84+
7685
type EventSessionCreated = Omit<EventSessionUpdated, "type"> & { type: "session.created" }
7786
export interface EventSessionDeleted {
7887
type: "session.deleted"
@@ -110,6 +119,7 @@ type SSEEvent =
110119
| BackgroundProcessUpdatedEvent
111120
| BackgroundProcessRemovedEvent
112121
| ServerInstanceDisposedEvent
122+
| WorktreeReadyEvent
113123
| { type: string; properties?: Record<string, unknown> }
114124

115125
const [connectionStatus, setConnectionStatus] = createSignal<Map<string, ConnectionStatus>>(new Map())
@@ -238,6 +248,16 @@ class SSEManager {
238248
case "server.instance.disposed":
239249
this.onInstanceDisposed?.(instanceId, event as ServerInstanceDisposedEvent)
240250
break
251+
case "worktree.ready":
252+
try {
253+
const result = this.onWorktreeReady?.(instanceId, event as WorktreeReadyEvent)
254+
void result?.catch((error) => {
255+
log.warn("Failed to handle worktree ready event", { instanceId, error })
256+
})
257+
} catch (error) {
258+
log.warn("Failed to handle worktree ready event", { instanceId, error })
259+
}
260+
break
241261
default:
242262
log.warn("Unknown SSE event type", { type: event.type })
243263
}
@@ -271,6 +291,7 @@ class SSEManager {
271291
onBackgroundProcessUpdated?: (instanceId: string, event: BackgroundProcessUpdatedEvent) => void
272292
onBackgroundProcessRemoved?: (instanceId: string, event: BackgroundProcessRemovedEvent) => void
273293
onInstanceDisposed?: (instanceId: string, event: ServerInstanceDisposedEvent) => void
294+
onWorktreeReady?: (instanceId: string, event: WorktreeReadyEvent) => void | Promise<void>
274295
onConnectionLost?: (instanceId: string, reason: string) => void | Promise<void>
275296

276297
getStatus(instanceId: string): ConnectionStatus | null {

packages/ui/src/stores/opencode-workspaces.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,15 +80,17 @@ async function syncOpenCodeWorkspaces(instanceId: string): Promise<void> {
8080
}
8181
})
8282
.finally(() => {
83-
workspaceSyncs.delete(instanceId)
83+
if (workspaceSyncs.get(instanceId) === task) {
84+
workspaceSyncs.delete(instanceId)
85+
}
8486
})
8587

8688
workspaceSyncs.set(instanceId, task)
8789
return task
8890
}
8991

9092
async function reloadOpenCodeWorkspaces(instanceId: string): Promise<void> {
91-
workspaceSyncs.delete(instanceId)
93+
await workspaceSyncs.get(instanceId)
9294
await syncOpenCodeWorkspaces(instanceId)
9395
}
9496

packages/ui/src/stores/sessions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import {
6565
} from "./session-state"
6666

6767
import { getDefaultModel } from "./session-models"
68+
import { handleWorktreeReady } from "./worktrees"
6869
import {
6970
createSession,
7071
deleteSession,
@@ -119,6 +120,7 @@ sseManager.onPermissionUpdated = handlePermissionUpdated
119120
sseManager.onPermissionReplied = handlePermissionReplied
120121
sseManager.onQuestionAsked = handleQuestionAsked
121122
sseManager.onQuestionAnswered = handleQuestionAnswered
123+
sseManager.onWorktreeReady = handleWorktreeReady
122124

123125
export {
124126
abortSession,
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import assert from "node:assert/strict"
2+
import { describe, it } from "node:test"
3+
4+
import { serverApi } from "../lib/api-client.ts"
5+
import { ensureWorktreesLoaded, getWorktrees, handleWorktreeReady, reloadWorktrees } from "./worktrees.ts"
6+
7+
describe("handleWorktreeReady", () => {
8+
it("refreshes worktrees before synchronizing OpenCode workspaces", async () => {
9+
const calls: string[] = []
10+
11+
await handleWorktreeReady(
12+
"instance-1",
13+
{
14+
type: "worktree.ready",
15+
directory: "/tmp/opencode/worktree/feature",
16+
properties: { name: "feature", branch: "opencode/feature" },
17+
},
18+
async (instanceId) => {
19+
calls.push(`worktrees:${instanceId}`)
20+
},
21+
async (instanceId) => {
22+
calls.push(`workspaces:${instanceId}`)
23+
},
24+
)
25+
26+
assert.deepEqual(calls, ["worktrees:instance-1", "workspaces:instance-1"])
27+
})
28+
29+
it("serializes overlapping ready events for the same instance", async () => {
30+
const calls: string[] = []
31+
let releaseFirst!: () => void
32+
const firstPending = new Promise<void>((resolve) => {
33+
releaseFirst = resolve
34+
})
35+
let refreshCount = 0
36+
37+
const refreshWorktrees = async () => {
38+
refreshCount += 1
39+
calls.push(`worktrees:${refreshCount}`)
40+
if (refreshCount === 1) await firstPending
41+
}
42+
const refreshWorkspaces = async () => {
43+
calls.push(`workspaces:${refreshCount}`)
44+
}
45+
const event = {
46+
type: "worktree.ready" as const,
47+
directory: "/tmp/opencode/worktree/feature",
48+
properties: { name: "feature" },
49+
}
50+
51+
const first = handleWorktreeReady("instance-concurrent", event, refreshWorktrees, refreshWorkspaces)
52+
await Promise.resolve()
53+
const second = handleWorktreeReady("instance-concurrent", event, refreshWorktrees, refreshWorkspaces)
54+
await Promise.resolve()
55+
56+
assert.deepEqual(calls, ["worktrees:1"])
57+
58+
releaseFirst()
59+
await Promise.all([first, second])
60+
61+
assert.deepEqual(calls, ["worktrees:1", "workspaces:1", "worktrees:2", "workspaces:2"])
62+
})
63+
64+
it("continues processing after an earlier refresh rejects", async () => {
65+
const event = {
66+
type: "worktree.ready" as const,
67+
properties: { name: "feature" },
68+
}
69+
70+
await assert.rejects(
71+
handleWorktreeReady(
72+
"instance-recovery",
73+
event,
74+
async () => {
75+
throw new Error("refresh failed")
76+
},
77+
async () => undefined,
78+
),
79+
/refresh failed/,
80+
)
81+
82+
const calls: string[] = []
83+
await handleWorktreeReady(
84+
"instance-recovery",
85+
event,
86+
async () => {
87+
calls.push("worktrees")
88+
},
89+
async () => {
90+
calls.push("workspaces")
91+
},
92+
)
93+
94+
assert.deepEqual(calls, ["worktrees", "workspaces"])
95+
})
96+
97+
it("orders initial hydration before a trailing reload", async () => {
98+
const instanceId = "instance-initial-reload"
99+
const originalFetchWorktrees = serverApi.fetchWorktrees
100+
let resolveInitial!: (value: Awaited<ReturnType<typeof serverApi.fetchWorktrees>>) => void
101+
let resolveReload!: (value: Awaited<ReturnType<typeof serverApi.fetchWorktrees>>) => void
102+
const initialResponse = new Promise<Awaited<ReturnType<typeof serverApi.fetchWorktrees>>>((resolve) => {
103+
resolveInitial = resolve
104+
})
105+
const reloadResponse = new Promise<Awaited<ReturnType<typeof serverApi.fetchWorktrees>>>((resolve) => {
106+
resolveReload = resolve
107+
})
108+
let requestCount = 0
109+
110+
serverApi.fetchWorktrees = async () => {
111+
requestCount += 1
112+
return requestCount === 1 ? initialResponse : reloadResponse
113+
}
114+
115+
try {
116+
const initial = ensureWorktreesLoaded(instanceId)
117+
const reload = reloadWorktrees(instanceId)
118+
await Promise.resolve()
119+
120+
assert.equal(requestCount, 1)
121+
122+
resolveInitial({
123+
isGitRepo: true,
124+
worktrees: [{ slug: "root", directory: "/repo", kind: "root" }],
125+
})
126+
await initial
127+
await Promise.resolve()
128+
129+
assert.equal(requestCount, 2)
130+
131+
resolveReload({
132+
isGitRepo: true,
133+
worktrees: [
134+
{ slug: "root", directory: "/repo", kind: "root" },
135+
{ slug: "feature", directory: "/repo-feature", kind: "worktree" },
136+
],
137+
})
138+
await reload
139+
140+
assert.deepEqual(getWorktrees(instanceId).map((worktree) => worktree.slug), ["root", "feature"])
141+
} finally {
142+
serverApi.fetchWorktrees = originalFetchWorktrees
143+
}
144+
})
145+
})

packages/ui/src/stores/worktrees.ts

Lines changed: 66 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,21 @@ import { getSessionRoot, sessions } from "./session-state"
55
import { getLogger } from "../lib/logger"
66
import { getCodeNomadSessionMetadata, setSessionWorktreeSlugWithClient } from "./session-metadata"
77
import { getRootClient } from "./opencode-client"
8+
import type { WorktreeReadyEvent } from "../lib/sse-manager"
89

910
const log = getLogger("api")
1011

1112
const [worktreesByInstance, setWorktreesByInstance] = createSignal<Map<string, WorktreeDescriptor[]>>(new Map())
1213
const [worktreeMapByInstance, setWorktreeMapByInstance] = createSignal<Map<string, WorktreeMap>>(new Map())
1314
const [gitRepoStatusByInstance, setGitRepoStatusByInstance] = createSignal<Map<string, boolean | null>>(new Map())
1415

15-
const worktreeLoads = new Map<string, Promise<void>>()
16+
const worktreeRequests = new Map<string, Promise<void>>()
17+
const worktreeReadyRefreshes = new Map<string, Promise<void>>()
1618
const mapLoads = new Map<string, Promise<void>>()
1719
const mapMigrations = new Map<string, Promise<void>>()
1820

21+
type WorktreeReadyRefresh = (instanceId: string) => Promise<void>
22+
1923
function normalizeMap(input?: WorktreeMap | null): WorktreeMap {
2024
if (!input || typeof input !== "object") {
2125
return { version: 1, defaultWorktreeSlug: "root", parentSessionWorktreeSlug: {} }
@@ -27,15 +31,11 @@ function normalizeMap(input?: WorktreeMap | null): WorktreeMap {
2731
}
2832
}
2933

30-
async function ensureWorktreesLoaded(instanceId: string): Promise<void> {
31-
if (!instanceId) return
32-
if (worktreesByInstance().has(instanceId) && gitRepoStatusByInstance().has(instanceId)) return
33-
const existing = worktreeLoads.get(instanceId)
34-
if (existing) return existing
35-
36-
const task = serverApi
37-
.fetchWorktrees(instanceId)
38-
.then((response) => {
34+
async function queueWorktreeRequest(instanceId: string, initial: boolean): Promise<void> {
35+
const previous = worktreeRequests.get(instanceId)
36+
const task = (previous?.catch(() => undefined) ?? Promise.resolve()).then(async () => {
37+
try {
38+
const response = await serverApi.fetchWorktrees(instanceId)
3939
setWorktreesByInstance((prev) => {
4040
const next = new Map(prev)
4141
next.set(instanceId, response.worktrees ?? [])
@@ -52,9 +52,10 @@ async function ensureWorktreesLoaded(instanceId: string): Promise<void> {
5252
if (worktreeMapByInstance().has(instanceId)) {
5353
void pruneWorktreeMap(instanceId).catch(() => undefined)
5454
}
55-
})
56-
.catch((error) => {
57-
log.warn("Failed to load worktrees", { instanceId, error })
55+
} catch (error) {
56+
log.warn(initial ? "Failed to load worktrees" : "Failed to reload worktrees", { instanceId, error })
57+
if (!initial) return
58+
5859
setWorktreesByInstance((prev) => {
5960
const next = new Map(prev)
6061
next.set(instanceId, [])
@@ -68,39 +69,64 @@ async function ensureWorktreesLoaded(instanceId: string): Promise<void> {
6869
next.set(instanceId, null)
6970
return next
7071
})
71-
})
72-
.finally(() => {
73-
worktreeLoads.delete(instanceId)
74-
})
72+
}
73+
})
7574

76-
worktreeLoads.set(instanceId, task)
77-
return task
75+
worktreeRequests.set(instanceId, task)
76+
await task.finally(() => {
77+
if (worktreeRequests.get(instanceId) === task) {
78+
worktreeRequests.delete(instanceId)
79+
}
80+
})
81+
}
82+
83+
async function ensureWorktreesLoaded(instanceId: string): Promise<void> {
84+
if (!instanceId) return
85+
if (worktreesByInstance().has(instanceId) && gitRepoStatusByInstance().has(instanceId)) return
86+
87+
const existing = worktreeRequests.get(instanceId)
88+
if (existing) {
89+
await existing
90+
if (worktreesByInstance().has(instanceId) && gitRepoStatusByInstance().has(instanceId)) return
91+
}
92+
93+
await queueWorktreeRequest(instanceId, true)
7894
}
7995

8096
async function reloadWorktrees(instanceId: string): Promise<void> {
8197
if (!instanceId) return
82-
await serverApi
83-
.fetchWorktrees(instanceId)
84-
.then((response) => {
85-
setWorktreesByInstance((prev) => {
86-
const next = new Map(prev)
87-
next.set(instanceId, response.worktrees ?? [])
88-
return next
89-
})
98+
await queueWorktreeRequest(instanceId, false)
99+
}
90100

91-
setGitRepoStatusByInstance((prev) => {
92-
const next = new Map(prev)
93-
next.set(instanceId, typeof response.isGitRepo === "boolean" ? response.isGitRepo : null)
94-
return next
95-
})
101+
async function handleWorktreeReady(
102+
instanceId: string,
103+
event: WorktreeReadyEvent,
104+
refreshWorktrees: WorktreeReadyRefresh = reloadWorktrees,
105+
refreshWorkspaces: WorktreeReadyRefresh = async (id) => {
106+
const { reloadOpenCodeWorkspaces } = await import("./opencode-workspaces")
107+
await reloadOpenCodeWorkspaces(id)
108+
},
109+
): Promise<void> {
110+
if (!instanceId) return
96111

97-
if (worktreeMapByInstance().has(instanceId)) {
98-
void pruneWorktreeMap(instanceId).catch(() => undefined)
99-
}
100-
})
101-
.catch((error) => {
102-
log.warn("Failed to reload worktrees", { instanceId, error })
103-
})
112+
log.info("OpenCode worktree ready", {
113+
instanceId,
114+
directory: event.directory,
115+
name: event.properties?.name,
116+
})
117+
118+
const previous = worktreeReadyRefreshes.get(instanceId)
119+
const task = (previous?.catch(() => undefined) ?? Promise.resolve()).then(async () => {
120+
await refreshWorktrees(instanceId)
121+
await refreshWorkspaces(instanceId)
122+
})
123+
124+
worktreeReadyRefreshes.set(instanceId, task)
125+
await task.finally(() => {
126+
if (worktreeReadyRefreshes.get(instanceId) === task) {
127+
worktreeReadyRefreshes.delete(instanceId)
128+
}
129+
})
104130
}
105131

106132
function getGitRepoStatus(instanceId: string): boolean | null {
@@ -401,6 +427,7 @@ export {
401427
gitRepoStatusByInstance,
402428
ensureWorktreesLoaded,
403429
reloadWorktrees,
430+
handleWorktreeReady,
404431
reloadWorktreeMap,
405432
ensureWorktreeMapLoaded,
406433
getGitRepoStatus,

0 commit comments

Comments
 (0)