Skip to content

Commit 4d8a999

Browse files
authored
perf: v0.2.38 — skip window matching for claimed windows (#122)
## Summary - Filter `matchWindowsToLogsByExactRg` to only unclaimed windows in the match worker - When all windows are already associated with sessions, matching is skipped entirely - Eliminates ~90 `rg` subprocess spawns and 12 `tmux capture-pane` calls per poll cycle ## Measured impact | Scenario | Before | After | |---|---|---| | Steady-state (all windows claimed, warm) | ~2,049ms | ~150ms | | Under load (load avg 28+) | ~2,200ms avg, 5s spikes | ~300ms est | ## Why this is safe - The main thread already refuses to steal claimed windows (`logPoller.ts:769-780`) - Slug-based supersede handles legitimate window handoffs independently of rg matching - `noMessageWindows` deferral already filters claimed windows on the main thread ## Test plan - [x] 761 tests passing (2 new tests added) - [x] New test: window recovered after orphan cleanup - [x] New test: slug supersede works without rg matching - [x] Lint and typecheck passing
1 parent 2103be9 commit 4d8a999

3 files changed

Lines changed: 155 additions & 5 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@gbasin/agentboard",
3-
"version": "0.2.37",
3+
"version": "0.2.38",
44
"type": "module",
55
"description": "Web GUI for tmux optimized for AI agent TUIs",
66
"author": "gbasin",

src/server/__tests__/logPoller.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -985,6 +985,144 @@ describe('LogPoller', () => {
985985
db.close()
986986
})
987987

988+
test('matches new session to window when existing session is orphaned between polls', async () => {
989+
const db = initDatabase({ path: ':memory:' })
990+
const registry = new SessionRegistry()
991+
registry.replaceSessions([baseSession])
992+
993+
const tokensA = Array.from({ length: 60 }, (_, i) => `token${i}`).join(' ')
994+
setTmuxOutput(baseSession.tmuxWindow, buildLastExchangeOutput(tokensA))
995+
996+
const projectPath = baseSession.projectPath
997+
const encoded = encodeProjectPath(projectPath)
998+
const logDir = path.join(
999+
process.env.CLAUDE_CONFIG_DIR ?? '',
1000+
'projects',
1001+
encoded
1002+
)
1003+
await fs.mkdir(logDir, { recursive: true })
1004+
1005+
// Poll 1: Session A is discovered and matched to the window
1006+
const logPathA = path.join(logDir, 'session-a.jsonl')
1007+
const lineA = buildUserLogEntry(tokensA, {
1008+
sessionId: 'claude-session-a',
1009+
cwd: projectPath,
1010+
})
1011+
const assistantLineA = JSON.stringify({
1012+
type: 'assistant',
1013+
message: { content: [{ type: 'text', text: tokensA }] },
1014+
})
1015+
await fs.writeFile(logPathA, `${lineA}\n${assistantLineA}\n`)
1016+
1017+
const poller = new LogPoller(db, registry, {
1018+
matchWorkerClient: new InlineMatchWorkerClient(),
1019+
})
1020+
await poller.pollOnce()
1021+
1022+
const recordA = db.getSessionById('claude-session-a')
1023+
expect(recordA?.currentWindow).toBe(baseSession.tmuxWindow)
1024+
1025+
// Between polls: Session A's window claim is removed (simulating orphan cleanup)
1026+
db.updateSession('claude-session-a', { currentWindow: null })
1027+
const orphanedA = db.getSessionById('claude-session-a')
1028+
expect(orphanedA?.currentWindow).toBeNull()
1029+
1030+
// Poll 2: Session B appears with different content.
1031+
// The window is now unclaimed, so the optimization should allow matching.
1032+
const tokensB = Array.from({ length: 60 }, (_, i) => `next${i}`).join(' ')
1033+
setTmuxOutput(baseSession.tmuxWindow, buildLastExchangeOutput(tokensB))
1034+
1035+
const logPathB = path.join(logDir, 'session-b.jsonl')
1036+
const lineB = buildUserLogEntry(tokensB, {
1037+
sessionId: 'claude-session-b',
1038+
cwd: projectPath,
1039+
})
1040+
const assistantLineB = JSON.stringify({
1041+
type: 'assistant',
1042+
message: { content: [{ type: 'text', text: tokensB }] },
1043+
})
1044+
await fs.writeFile(logPathB, `${lineB}\n${assistantLineB}\n`)
1045+
1046+
await poller.pollOnce()
1047+
1048+
// Session B should get matched to the now-unclaimed window
1049+
const newRecord = db.getSessionById('claude-session-b')
1050+
expect(newRecord?.currentWindow).toBe(baseSession.tmuxWindow)
1051+
1052+
db.close()
1053+
})
1054+
1055+
test('new session with slug supersedes claimed window without needing match', async () => {
1056+
const db = initDatabase({ path: ':memory:' })
1057+
const registry = new SessionRegistry()
1058+
registry.replaceSessions([baseSession])
1059+
1060+
const tokensA = Array.from({ length: 60 }, (_, i) => `token${i}`).join(' ')
1061+
setTmuxOutput(baseSession.tmuxWindow, buildLastExchangeOutput(tokensA))
1062+
1063+
const projectPath = baseSession.projectPath
1064+
const encoded = encodeProjectPath(projectPath)
1065+
const logDir = path.join(
1066+
process.env.CLAUDE_CONFIG_DIR ?? '',
1067+
'projects',
1068+
encoded
1069+
)
1070+
await fs.mkdir(logDir, { recursive: true })
1071+
1072+
// Poll 1: Session A gets matched to the window (with slug)
1073+
const logPathA = path.join(logDir, 'session-a.jsonl')
1074+
const lineA = buildUserLogEntry(tokensA, {
1075+
sessionId: 'claude-session-a',
1076+
cwd: projectPath,
1077+
slug: 'supersede-slug',
1078+
})
1079+
const assistantLineA = JSON.stringify({
1080+
type: 'assistant',
1081+
message: { content: [{ type: 'text', text: tokensA }] },
1082+
})
1083+
await fs.writeFile(logPathA, `${lineA}\n${assistantLineA}\n`)
1084+
1085+
const poller = new LogPoller(db, registry, {
1086+
matchWorkerClient: new InlineMatchWorkerClient(),
1087+
})
1088+
await poller.pollOnce()
1089+
1090+
const recordA = db.getSessionById('claude-session-a')
1091+
expect(recordA?.currentWindow).toBe(baseSession.tmuxWindow)
1092+
1093+
// Poll 2: Session B appears with the same slug + project.
1094+
// The window is still claimed by A, so match-based matching is skipped
1095+
// for this window. However, slug supersede should detach A and give
1096+
// the window to B without needing rg matching.
1097+
const tokensB = Array.from({ length: 60 }, (_, i) => `next${i}`).join(' ')
1098+
setTmuxOutput(baseSession.tmuxWindow, buildLastExchangeOutput(tokensB))
1099+
1100+
const logPathB = path.join(logDir, 'session-b.jsonl')
1101+
const lineB = buildUserLogEntry(tokensB, {
1102+
sessionId: 'claude-session-b',
1103+
cwd: projectPath,
1104+
slug: 'supersede-slug',
1105+
})
1106+
const assistantLineB = JSON.stringify({
1107+
type: 'assistant',
1108+
message: { content: [{ type: 'text', text: tokensB }] },
1109+
})
1110+
await fs.writeFile(logPathB, `${lineB}\n${assistantLineB}\n`)
1111+
1112+
await poller.pollOnce()
1113+
1114+
// Session A should be detached (superseded by slug)
1115+
const oldRecord = db.getSessionById('claude-session-a')
1116+
expect(oldRecord?.currentWindow).toBeNull()
1117+
1118+
// Session B should claim the window via slug supersede,
1119+
// even though the optimization skipped rg matching for claimed windows
1120+
const newRecord = db.getSessionById('claude-session-b')
1121+
expect(newRecord?.currentWindow).toBe(baseSession.tmuxWindow)
1122+
1123+
db.close()
1124+
})
1125+
9881126
test('ignores external windows in name-based orphan fallback', async () => {
9891127
const db = initDatabase({ path: ':memory:' })
9901128
const registry = new SessionRegistry()

src/server/logMatchWorker.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,17 +93,29 @@ export function handleMatchWorkerRequest(
9393
payload.windows.map((w) => [w.tmuxWindow, w] as const)
9494
)
9595

96+
// Only match against unclaimed windows — claimed windows would be
97+
// rejected by the main thread anyway (logPoller refuses to steal),
98+
// so capturing their scrollback and running rg is wasted work.
99+
const claimedWindows = new Set(
100+
payload.sessions
101+
.map((s) => s.currentWindow)
102+
.filter(Boolean) as string[]
103+
)
104+
const unclaimedWindows = payload.windows.filter(
105+
(w) => !claimedWindows.has(w.tmuxWindow)
106+
)
107+
96108
const entriesToMatch = getEntriesNeedingMatch(entries, payload.sessions, {
97109
minTokens: payload.minTokensForMatch ?? 0,
98110
skipMatchingPatterns: payload.skipMatchingPatterns ?? [],
99111
})
100-
if (entriesToMatch.length === 0) {
112+
if (entriesToMatch.length === 0 || unclaimedWindows.length === 0) {
101113
matchSkipped = true
102114
} else {
103115
const matchStart = performance.now()
104116
const matchLogPaths = entriesToMatch.map((entry) => entry.logPath)
105117
const matchResult = matchWindowsToLogsByExactRg(
106-
payload.windows,
118+
unclaimedWindows,
107119
logDirs,
108120
payload.scrollbackLines ?? DEFAULT_SCROLLBACK_LINES,
109121
{
@@ -114,7 +126,7 @@ export function handleMatchWorkerRequest(
114126
}
115127
)
116128
matchMs = performance.now() - matchStart
117-
matchWindowCount = payload.windows.length
129+
matchWindowCount = unclaimedWindows.length
118130
matchLogCount = matchLogPaths.length
119131
resolved = Array.from(matchResult.matches.entries()).map(([logPath, window]) => ({
120132
logPath,
@@ -139,7 +151,7 @@ export function handleMatchWorkerRequest(
139151
Math.min(os.cpus().length, 4)
140152
)
141153
const orphanMatchResult = matchWindowsToLogsByExactRg(
142-
payload.windows,
154+
unclaimedWindows,
143155
logDirs,
144156
payload.scrollbackLines ?? DEFAULT_SCROLLBACK_LINES,
145157
{

0 commit comments

Comments
 (0)