fix(hub,web): deduplicate sessions by agent session ID#448
fix(hub,web): deduplicate sessions by agent session ID#448hqhq1025 wants to merge 9 commits intotiann:mainfrom
Conversation
When multiple CLI wrappers independently resume the same Codex thread, each generates a random tag, causing the hub to create duplicate session records for a single underlying thread. This leads to duplicate conversations in the web UI and messages routing to the wrong session. Add two-layer deduplication: - Hub: when a metadata update sets an agent session ID (codexSessionId, claudeSessionId, etc.) that already exists on another session in the same namespace, automatically merge the duplicate into the current session using the existing mergeSessions logic. - Web: deduplicate the session list display by agentSessionId as a safety net, keeping the active/most-recent session visible. Closes tiann#446
- Explain single-threaded assumption in before/after metadata comparison - Document merge direction rationale (duplicate → active session) - Document deduplicateInProgress guard as known limitation - Add catch comment explaining web safety net fallback
There was a problem hiding this comment.
Findings
- [Major] Auto-dedup can drop live session state when the kept session is the newly updated duplicate.
deduplicateByAgentSessionId()always merges every collision intosessionId(hub/src/sync/sessionCache.ts:575), butmergeSessions()only preserves metadata/model/effort/todos/teamState and does not carry overagentState,active/thinking, or the other in-memory live fields from the deleted session. If the deleted duplicate is the one holding pending approvals or the current live status, those disappear as soon as the merge runs. Related context:hub/src/sync/sessionCache.ts:404.
Suggested fix:
const duplicate = this.sessions.get(duplicateId)
const keepCurrent = !(duplicate?.active || duplicate?.agentState)
const targetId = keepCurrent ? sessionId : duplicateId
const sourceId = keepCurrent ? duplicateId : sessionId
await this.mergeSessions(sourceId, targetId, session.namespace)- [Minor] The web safety-net can hide the session the user currently has open.
SessionListdeduplicates before it checksselectedSessionIdfor selection/auto-expand (web/src/components/SessionList.tsx:485), and the tie-break only looks atactive/updatedAt(web/src/components/SessionList.tsx:61). If the user is viewing an older duplicate route, the selected row disappears from the sidebar and its group can collapse.
Suggested fix:
group.sort((a, b) => {
if (a.id === selectedSessionId) return -1
if (b.id === selectedSessionId) return 1
if (a.active !== b.active) return a.active ? -1 : 1
return b.updatedAt - a.updatedAt
})Summary
Review mode: initial. Two findings: the new auto-merge path can discard live agent state, and the web fallback dedup can hide the currently selected session. The added tests cover collision/no-op cases, but not state preservation across the merge or the selected-session behavior in the sidebar.
Testing
Not run (automation); bun is not installed in this runner.
HAPI Bot
|
|
||
| for (const duplicateId of duplicates) { | ||
| try { | ||
| await this.mergeSessions(duplicateId, sessionId, session.namespace) |
There was a problem hiding this comment.
[MAJOR] This auto-merge always deletes the pre-existing session and keeps sessionId, but mergeSessions() does not preserve agentState, active/thinking, permissionMode, or the other live cached fields from the deleted side. If the older duplicate is the one holding pending approvals or the live status, those disappear as soon as this branch runs.
Suggested fix:
const duplicate = this.sessions.get(duplicateId)
const keepCurrent = !(duplicate?.active || duplicate?.agentState)
const targetId = keepCurrent ? sessionId : duplicateId
const sourceId = keepCurrent ? duplicateId : sessionId
await this.mergeSessions(sourceId, targetId, session.namespace)| } | ||
|
|
||
| for (const group of byAgentId.values()) { | ||
| group.sort((a, b) => { |
There was a problem hiding this comment.
[MINOR] The dedup tie-breaker never considers selectedSessionId, so the sidebar can hide the session the user is currently viewing if that route points at the older duplicate. After that, the selected row disappears and the auto-expand logic stops finding it.
Suggested fix:
group.sort((a, b) => {
if (a.id === selectedSessionId) return -1
if (b.id === selectedSessionId) return 1
if (a.active !== b.active) return a.active ? -1 : 1
return b.updatedAt - a.updatedAt
})There was a problem hiding this comment.
Findings
- [Major] Auto-dedup still drops stored
agentStatefrom the duplicate being deleted.deduplicateByAgentSessionId()always merges into the current session (hub/src/sync/sessionCache.ts:575), butmergeSessions()only carries messages/metadata/model/effort/todos/teamState before deleting the old row (hub/src/sync/sessionCache.ts:404). BecauserefreshSession()reloadsagentStatefrom the kept DB row only (hub/src/sync/sessionCache.ts:106), pending approval requests on the deleted duplicate disappear.
Suggested fix:
if (newStored.agentState === null && oldStored.agentState !== null) {
const result = this.store.sessions.updateSessionAgentState(
newSessionId,
oldStored.agentState,
newStored.agentStateVersion,
namespace
)
if (result.result !== 'success') {
throw new Error('Failed to preserve session agent state during merge')
}
}- [Minor] The web-side fallback can still hide the session the user currently has open.
SessionListdeduplicates before the selected-session checks run (web/src/components/SessionList.tsx:485), but the dedup tie-breaker only looks atactiveandupdatedAt(web/src/components/SessionList.tsx:61). When the selected route points at the older duplicate, the sidebar row disappears and the auto-expand logic no longer finds it.
Suggested fix:
function deduplicateSessionsByAgentId(
sessions: SessionSummary[],
selectedSessionId?: string | null
): SessionSummary[] {
// ...
group.sort((a, b) => {
if (a.id === selectedSessionId) return -1
if (b.id === selectedSessionId) return 1
if (a.active !== b.active) return a.active ? -1 : 1
return b.updatedAt - a.updatedAt
})
}Summary
Review mode: follow-up after new commits. The latest push adds comments/rationale around the dedup flow, but the two earlier regressions are still present on 8d4030f: server-side dedup can delete stored pending requests, and the web fallback can hide the selected duplicate session. The added tests cover collision/no-op cases, but not preserving agentState during merge or keeping the selected session visible in the sidebar.
Testing
Not run (automation); bun is not installed in this runner.
HAPI Bot
| // is active. Messages from duplicates are moved into it; no data is lost. | ||
| for (const duplicateId of duplicates) { | ||
| try { | ||
| await this.mergeSessions(duplicateId, sessionId, session.namespace) |
There was a problem hiding this comment.
[MAJOR] If this path keeps sessionId, mergeSessions() also needs to migrate oldStored.agentState before deleting the duplicate. Right now the merge only preserves messages/metadata/model/effort/todos/teamState, so any stored pending approval requests on duplicateId are dropped when oldSessionId is deleted.
Suggested fix:
if (newStored.agentState === null && oldStored.agentState !== null) {
const result = this.store.sessions.updateSessionAgentState(
newSessionId,
oldStored.agentState,
newStored.agentStateVersion,
namespace
)
if (result.result !== 'success') {
throw new Error('Failed to preserve session agent state during merge')
}
}
web/src/components/SessionList.tsx
Outdated
| const { renderHeader = true, api, selectedSessionId, machineLabelsById = {} } = props | ||
| const groups = useMemo( | ||
| () => groupSessionsByDirectory(props.sessions), | ||
| () => groupSessionsByDirectory(deduplicateSessionsByAgentId(props.sessions)), |
There was a problem hiding this comment.
[MINOR] The web fallback still prefers only active/updatedAt, so it can collapse away the session the user is currently viewing when that route points at the older duplicate. After dedup, the selected row disappears and the auto-expand logic no longer finds it.
Suggested fix:
function deduplicateSessionsByAgentId(
sessions: SessionSummary[],
selectedSessionId?: string | null
): SessionSummary[] {
// ...
group.sort((a, b) => {
if (a.id === selectedSessionId) return -1
if (b.id === selectedSessionId) return 1
if (a.active !== b.active) return a.active ? -1 : 1
return b.updatedAt - a.updatedAt
})
}- Skip active duplicates during hub-side dedup to avoid deleting sessions with live CLI sockets and pending agent state - Pass selectedSessionId into web dedup sort to prevent hiding the session the user is currently viewing - Add test for active-duplicate-not-merged case
There was a problem hiding this comment.
Findings
- [Major] Active duplicate sessions can now get stuck permanently once they are skipped here. The new
existing.activeguard avoids deleting a live socket, but dedup still only runs when the agent session ID changes (hub/src/sync/syncEngine.ts:165). In the overlap case this means the older live duplicate is skipped athub/src/sync/sessionCache.ts:575, then its latersession-end/ expiry update never retries the merge, so the duplicate row remains and routing can still stay split across two session IDs.
Suggested fix:const becameInactive = Boolean(before?.active) && !after?.active if (after?.metadata && (becameInactive || !this.hasSameAgentSessionIds(before?.metadata ?? null, after.metadata))) { void this.sessionCache.deduplicateByAgentSessionId(event.sessionId).catch(() => {}) } const targetId = [sessionId, ...matches.map(([id]) => id)] .find((id) => this.sessions.get(id)?.active) ?? sessionId
- [Major] Merging a duplicate still drops stored
agentState.deduplicateByAgentSessionId()funnels duplicates throughmergeSessions()(hub/src/sync/sessionCache.ts:584), butmergeSessions()only preserves messages/metadata/model/effort/todos/teamState before deleting the old row (hub/src/sync/sessionCache.ts:404). Any pending approval requests stored inoldStored.agentStatedisappear when the duplicate row is removed.
Suggested fix:if (newStored.agentState === null && oldStored.agentState !== null) { const result = this.store.sessions.updateSessionAgentState( newSessionId, oldStored.agentState, newStored.agentStateVersion, namespace ) if (result.result !== 'success') { throw new Error('Failed to preserve session agent state during merge') } }
Summary
Review mode: follow-up after new commits. The selected-session web fix is present in this head, but two server-side regressions remain: active duplicate sessions are now skipped without a retry path, and any later merge still drops stored agentState. Not found in repo/tests: coverage for "active duplicate becomes inactive and is eventually merged" or "merge preserves pending requests stored in agentState".
Testing
Not run (security: no PR code execution)
HAPI Bot
| // Only merge inactive duplicates. Active ones still have a live CLI socket | ||
| // whose keepalive/messages would fail if we deleted their session record. | ||
| // The web-side display dedup hides active duplicates from the UI. | ||
| if (existing.active) continue |
There was a problem hiding this comment.
[MAJOR] Skipping active duplicates here becomes permanent with the current trigger logic. Dedup only runs when the agent-session ID changes in SyncEngine.handleRealtimeEvent(), so if two wrappers overlap on the same thread, the older live duplicate is skipped now and its later session-end / expiry update never retries the merge. That leaves the duplicate row in place and routing can still stay split across two HAPI session IDs.
Suggested fix:
const becameInactive = Boolean(before?.active) && !after?.active
if (after?.metadata && (becameInactive || !this.hasSameAgentSessionIds(before?.metadata ?? null, after.metadata))) {
void this.sessionCache.deduplicateByAgentSessionId(event.sessionId).catch(() => {})
}
const targetId = [sessionId, ...matches.map(([id]) => id)]
.find((id) => this.sessions.get(id)?.active) ?? sessionId| // is active. Messages from duplicates are moved into it; no data is lost. | ||
| for (const duplicateId of duplicates) { | ||
| try { | ||
| await this.mergeSessions(duplicateId, sessionId, session.namespace) |
There was a problem hiding this comment.
[MAJOR] This merge path still drops stored agentState from the duplicate being deleted. mergeSessions() preserves messages/metadata/model/effort/todos/teamState, but not oldStored.agentState, so any pending approval requests on duplicateId disappear when the old row is removed.
Suggested fix:
if (newStored.agentState === null && oldStored.agentState !== null) {
const result = this.store.sessions.updateSessionAgentState(
newSessionId,
oldStored.agentState,
newStored.agentStateVersion,
namespace
)
if (result.result !== 'success') {
throw new Error('Failed to preserve session agent state during merge')
}
}- Trigger dedup when a session ends (handleSessionEnd), so active duplicates skipped during earlier dedup get merged once they disconnect - Preserve agentState from old session during mergeSessions when the new session has no agentState (mirrors existing model/effort/todos preservation pattern) - Extract triggerDedupIfNeeded helper for reuse across trigger points
There was a problem hiding this comment.
Findings
- [Major] Selected duplicate still wins over the live session in the sidebar. The new comparator checks
selectedSessionIdbeforeactive, so if the user already has an inactive duplicate open, the deduped list hides the real active session row and keeps the stale one visible instead. Any send / approve action on that route still goes through the inactive-session guard and fails instead of reaching the live agent. Evidenceweb/src/components/SessionList.tsx:63, contexthub/src/web/routes/messages.ts:46,hub/src/web/routes/permissions.ts:40.
Suggested fix:group.sort((a, b) => { if (a.active !== b.active) return a.active ? -1 : 1 if (a.id === selectedSessionId) return -1 if (b.id === selectedSessionId) return 1 return b.updatedAt - a.updatedAt })
- [Major] Dedup still deletes agent-state data whenever both session rows already have state. The new preservation block only copies
oldStored.agentStatewhennewStored.agentState === null, so any pending/completed requests that exist only on the duplicate are dropped as soon asmergeSessions()removes the old row. Evidencehub/src/sync/sessionCache.ts:474.
Suggested fix:const mergedAgentState = { ...(oldStored.agentState as Record<string, unknown> | null), ...(newStored.agentState as Record<string, unknown> | null), requests: { ...((oldStored.agentState as { requests?: Record<string, unknown> } | null)?.requests ?? {}), ...((newStored.agentState as { requests?: Record<string, unknown> } | null)?.requests ?? {}) }, completedRequests: { ...((oldStored.agentState as { completedRequests?: Record<string, unknown> } | null)?.completedRequests ?? {}), ...((newStored.agentState as { completedRequests?: Record<string, unknown> } | null)?.completedRequests ?? {}) } } const latest = this.store.sessions.getSessionByNamespace(newSessionId, namespace) if (!latest) throw new Error('Session not found for merge') const result = this.store.sessions.updateSessionAgentState( newSessionId, mergedAgentState, latest.agentStateVersion, namespace ) if (result.result !== 'success') { throw new Error('Failed to preserve session agent state during merge') }
Summary
Review mode: follow-up after new commits. Two regressions still remain in the latest head: the web safety-net can keep users pinned to an inactive duplicate instead of the live session, and the hub merge still drops agent-state entries when both duplicate rows already contain request/history data. Not found in repo/tests: coverage for "selected inactive duplicate while an active twin exists" or "merge unions non-null agentState from both sessions".
Testing
Not run (automation)
HAPI Bot
| for (const group of byAgentId.values()) { | ||
| group.sort((a, b) => { | ||
| // Keep the currently selected session visible so the sidebar row doesn't vanish | ||
| if (a.id === selectedSessionId) return -1 |
There was a problem hiding this comment.
[MAJOR] The dedup comparator still prefers selectedSessionId over active, so an already-open inactive duplicate hides the real live session row. That leaves the user on the stale HAPI session ID, and send/approve actions on that route still hit the inactive-session guard instead of the live agent.
Suggested fix:
group.sort((a, b) => {
if (a.active !== b.active) return a.active ? -1 : 1
if (a.id === selectedSessionId) return -1
if (b.id === selectedSessionId) return 1
return b.updatedAt - a.updatedAt
})
hub/src/sync/sessionCache.ts
Outdated
| ) | ||
| } | ||
|
|
||
| if (oldStored.agentState !== null && newStored.agentState === null) { |
There was a problem hiding this comment.
[MAJOR] This preservation block still drops duplicate-only request state when both session rows already have agentState. Because the copy only runs when newStored.agentState === null, any pending or completed requests that exist only on oldStored.agentState disappear once the old row is deleted.
Suggested fix:
const mergedAgentState = {
...(oldStored.agentState as Record<string, unknown> | null),
...(newStored.agentState as Record<string, unknown> | null),
requests: {
...((oldStored.agentState as { requests?: Record<string, unknown> } | null)?.requests ?? {}),
...((newStored.agentState as { requests?: Record<string, unknown> } | null)?.requests ?? {})
},
completedRequests: {
...((oldStored.agentState as { completedRequests?: Record<string, unknown> } | null)?.completedRequests ?? {}),
...((newStored.agentState as { completedRequests?: Record<string, unknown> } | null)?.completedRequests ?? {})
}
}
const latest = this.store.sessions.getSessionByNamespace(newSessionId, namespace)
if (!latest) throw new Error('Session not found for merge')
const result = this.store.sessions.updateSessionAgentState(
newSessionId,
mergedAgentState,
latest.agentStateVersion,
namespace
)
if (result.result !== 'success') {
throw new Error('Failed to preserve session agent state during merge')
}Active session always wins the dedup tie-break so the live connection is never hidden in favor of a selected inactive duplicate. Among inactive duplicates the selected one is still preferred.
There was a problem hiding this comment.
Findings
- [Major] Timeout-based inactivity still leaves active-skipped duplicates unmerged; see inline comment on
hub/src/sync/syncEngine.ts. - [Major]
mergeSessions()still drops duplicate-onlyagentStateentries when both rows already have state; see inline comment onhub/src/sync/sessionCache.ts.
Summary
Review mode: follow-up after new commits. Two major issues remain in the latest head: retries only happen on explicit session-end, so crash/timeout disconnects can leave duplicates permanently unmerged, and mergeSessions() still discards duplicate-only agent-state entries when both session rows are non-null. Not found in repo/tests: coverage for inactivity-timeout dedup retry or unioning two non-null agentState payloads during merge.
Testing
Not run (bun unavailable in this runner)
HAPI Bot
| this.sessionCache.handleSessionEnd(payload) | ||
| // Retry dedup now that this session is inactive — a prior dedup may have | ||
| // skipped it because it was still active at the time. | ||
| this.triggerDedupIfNeeded(payload.sid) |
There was a problem hiding this comment.
[MAJOR] This retry only runs on explicit session-end. If the skipped duplicate disappears via the inactivity timer instead of a graceful socket close, expireInactive() marks it inactive but never reruns dedup, so the extra row stays in the DB indefinitely. That leaves the hub-side fix incomplete for crash/network-drop disconnects. Evidence: hub/src/sync/sessionCache.ts:284, context cli/src/api/apiSession.ts:496.
Suggested fix:
expireInactive(now: number = Date.now()): string[] {
const expired: string[] = []
const sessionTimeoutMs = 30_000
for (const session of this.sessions.values()) {
if (!session.active) continue
if (now - session.activeAt <= sessionTimeoutMs) continue
session.active = false
session.thinking = false
expired.push(session.id)
this.publisher.emit({ type: 'session-updated', sessionId: session.id, data: { active: false } })
}
return expired
}
// SyncEngine
for (const sessionId of this.sessionCache.expireInactive()) {
this.triggerDedupIfNeeded(sessionId)
}
hub/src/sync/sessionCache.ts
Outdated
|
|
||
| // Preserve agentState if the new session has none. Only inactive duplicates reach | ||
| // this point (active ones are skipped), so the old agentState is typically stale. | ||
| if (oldStored.agentState !== null && newStored.agentState === null) { |
There was a problem hiding this comment.
[MAJOR] This still discards duplicate-only agentState data whenever both session rows already have state. The condition only copies oldStored.agentState when newStored.agentState === null, so pending or completed requests that exist only on the duplicate vanish as soon as the old row is deleted. Evidence: shared/src/schemas.ts:82.
Suggested fix:
const mergedAgentState = oldStored.agentState && newStored.agentState
? {
...(oldStored.agentState as Record<string, unknown>),
...(newStored.agentState as Record<string, unknown>),
requests: {
...((oldStored.agentState as { requests?: Record<string, unknown> }).requests ?? {}),
...((newStored.agentState as { requests?: Record<string, unknown> }).requests ?? {})
},
completedRequests: {
...((oldStored.agentState as { completedRequests?: Record<string, unknown> }).completedRequests ?? {}),
...((newStored.agentState as { completedRequests?: Record<string, unknown> }).completedRequests ?? {})
}
}
: newStored.agentState ?? oldStored.agentState
if (mergedAgentState !== newStored.agentState) {
const result = this.store.sessions.updateSessionAgentState(
newSessionId,
mergedAgentState,
newStored.agentStateVersion,
namespace
)
if (result.result !== 'success') {
throw new Error('Failed to preserve session agent state during merge')
}
}- expireInactive now returns expired session IDs so SyncEngine can trigger dedup for sessions that timed out (crash/network drop) instead of only on explicit session-end - mergeSessions now deep-merges agentState requests/completedRequests from both sessions instead of only copying when new is null
There was a problem hiding this comment.
Findings
- [Major] Completed permission requests can be reintroduced as pending after session merge;
mergeAgentState()unionsrequestsandcompletedRequestsindependently, so the same request ID can survive in both maps. The UI/API still treat anything left inrequestsas pending (shared/src/sessionSummary.ts:27,hub/src/web/routes/permissions.ts:52,hub/src/notifications/notificationHub.ts:85). Evidencehub/src/sync/sessionCache.ts:572.
Summary
Review mode: follow-up after new commits. One major issue remains in the latest head: deduplicating sessions can resurrect an already-completed permission prompt as pending again when the stale duplicate still has that request under requests and the kept session has already moved it to completedRequests. Not found in tests: coverage for this overlap case during merge.
Testing
Not run (bun unavailable in this runner)
HAPI Bot
hub/src/sync/sessionCache.ts
Outdated
| return { | ||
| ...oldObj, | ||
| ...newObj, | ||
| requests: { |
There was a problem hiding this comment.
[MAJOR] mergeAgentState() can resurrect already-completed permission prompts. If the stale duplicate still has requests[requestId] but the kept session has completedRequests[requestId], this union leaves the ID in both maps; pending-count, notifications, and /permissions/:requestId/* still key off requests, so the completed prompt shows up as pending again.
Suggested fix:
const completedRequests = {
...((oldObj.completedRequests as Record<string, unknown> | undefined) ?? {}),
...((newObj.completedRequests as Record<string, unknown> | undefined) ?? {})
}
const completedIds = new Set(Object.keys(completedRequests))
const requests = Object.fromEntries(
Object.entries({
...((oldObj.requests as Record<string, unknown> | undefined) ?? {}),
...((newObj.requests as Record<string, unknown> | undefined) ?? {})
}).filter(([requestId]) => !completedIds.has(requestId))
)
return { ...oldObj, ...newObj, requests, completedRequests }Filter out request IDs that already appear in completedRequests when merging agentState, preventing completed permission prompts from resurrecting as pending after session dedup.
There was a problem hiding this comment.
Findings
- [Major] Auto-dedup can make a successful resume report
resume_failed. The newsession-updateddedup path can delete the old session beforeresumeSession()reaches its existing explicitmergeSessions(old, new)step, so the later merge throws even though the respawned session is already active. Evidencehub/src/sync/syncEngine.ts:171, related resume flowhub/src/sync/syncEngine.ts:447, and resumed sessions already re-assert the agent session ID immediately in at least Gemini/OpenCodecli/src/gemini/loop.ts:45,cli/src/opencode/loop.ts:45.
Suggested fix:if (spawnResult.sessionId !== access.sessionId) { const oldSession = this.sessionCache.getSessionByNamespace(access.sessionId, namespace) if (oldSession) { await this.sessionCache.mergeSessions(access.sessionId, spawnResult.sessionId, namespace) } }
Summary
Review mode: follow-up after new commits. One major issue remains in the latest head: the new automatic dedup path races with the pre-existing resume merge flow and can turn a successful respawn into a false resume_failed response. Not found in tests: coverage for a resume where metadata dedup runs before resumeSession() reaches its explicit merge.
Testing
Not run (automation; bun unavailable in this runner)
HAPI Bot
| this.sessionCache.refreshSession(event.sessionId) | ||
| const after = this.sessionCache.getSession(event.sessionId) | ||
| if (after?.metadata && !this.hasSameAgentSessionIds(before?.metadata ?? null, after.metadata)) { | ||
| void this.sessionCache.deduplicateByAgentSessionId(event.sessionId).catch(() => { |
There was a problem hiding this comment.
[MAJOR] This new auto-dedup path can race with the existing explicit merge in resumeSession(). Resumed Gemini/OpenCode sessions call session.onSessionFound(resumeSessionId) immediately (cli/src/gemini/loop.ts:45, cli/src/opencode/loop.ts:45), which emits update-metadata and can merge/delete the old session here before hub/src/sync/syncEngine.ts:447 runs. When resumeSession() later calls mergeSessions(old, new), it throws Session not found for merge and returns resume_failed even though the new session is already active.
Suggested fix:
if (spawnResult.sessionId !== access.sessionId) {
const oldSession = this.sessionCache.getSessionByNamespace(access.sessionId, namespace)
if (oldSession) {
await this.sessionCache.mergeSessions(access.sessionId, spawnResult.sessionId, namespace)
}
}The automatic dedup (triggered when the spawned CLI sets its agent session ID) can delete the old session before resumeSession reaches its own explicit mergeSessions call. Skip the merge if the old session no longer exists instead of failing the resume with a false error.
There was a problem hiding this comment.
Findings
- No diff-attributable issues found.
Summary
Review mode: follow-up after new commits. No diff-attributable correctness, regression, or maintainability issues found in the latest head. Not found in repo/docs: automated coverage for the web-side dedup path in web/src/components/SessionList.tsx or for the inactive-expiry/session-end retry path in hub/src/sync/syncEngine.ts; hub unit coverage exists for the merge path only.
Testing
Not run (automation; bun unavailable in this runner)
HAPI Bot
Hub tests: - session-end triggers dedup retry for previously-active duplicates - inactivity timeout expiry triggers dedup retry - agentState deep merge filters completed requests from pending set Web tests: - basic dedup by agentSessionId - active session wins over inactive duplicate - selected session preferred among inactive duplicates - active always wins over selected inactive - sessions without agentSessionId pass through - independent dedup across different agentSessionIds
Summary
Changes
sessionCache.ts,syncEngine.ts): when a metadata update sets an agent session ID (codexSessionId,claudeSessionId, etc.) that already exists on another session in the same namespace, automatically merge the duplicate into the current session using existingmergeSessionslogicSessionList.tsx): deduplicate the session list byagentSessionIdas a safety net, keeping the active/most-recent session visiblesessionSummary.ts): exposeagentSessionIdonSessionSummaryMetadatasessionModel.test.ts): 4 test cases covering collision merge, different IDs preserved, cross-namespace isolation, and no-op on missing IDTest plan
bun run typecheck(cli, web, hub all pass)bun test hub/src/sync/sessionModel.test.ts(18/18 pass)Closes #446