Skip to content

fix(hub,web): deduplicate sessions by agent session ID#448

Open
hqhq1025 wants to merge 9 commits intotiann:mainfrom
hqhq1025:fix/session-dedup-by-agent-id
Open

fix(hub,web): deduplicate sessions by agent session ID#448
hqhq1025 wants to merge 9 commits intotiann:mainfrom
hqhq1025:fix/session-dedup-by-agent-id

Conversation

@hqhq1025
Copy link
Copy Markdown
Collaborator

Summary

  • 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 to fix both the data layer and the display layer

Changes

  • Hub auto-dedup (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 existing mergeSessions logic
  • Web display dedup (SessionList.tsx): deduplicate the session list by agentSessionId as a safety net, keeping the active/most-recent session visible
  • Wire type (sessionSummary.ts): expose agentSessionId on SessionSummaryMetadata
  • Tests (sessionModel.test.ts): 4 test cases covering collision merge, different IDs preserved, cross-namespace isolation, and no-op on missing ID

Test plan

  • bun run typecheck (cli, web, hub all pass)
  • bun test hub/src/sync/sessionModel.test.ts (18/18 pass)
  • Manual: open multiple Codex conversations pointing to the same thread, verify web shows only one
  • Manual: send a message in one conversation, verify it lands on the correct thread

Closes #446

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
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Auto-dedup can drop live session state when the kept session is the newly updated duplicate. deduplicateByAgentSessionId() always merges every collision into sessionId (hub/src/sync/sessionCache.ts:575), but mergeSessions() only preserves metadata/model/effort/todos/teamState and does not carry over agentState, 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. SessionList deduplicates before it checks selectedSessionId for selection/auto-expand (web/src/components/SessionList.tsx:485), and the tie-break only looks at active/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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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
})

Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Auto-dedup still drops stored agentState from the duplicate being deleted. deduplicateByAgentSessionId() always merges into the current session (hub/src/sync/sessionCache.ts:575), but mergeSessions() only carries messages/metadata/model/effort/todos/teamState before deleting the old row (hub/src/sync/sessionCache.ts:404). Because refreshSession() reloads agentState from 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. SessionList deduplicates before the selected-session checks run (web/src/components/SessionList.tsx:485), but the dedup tie-breaker only looks at active and updatedAt (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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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')
    }
}

const { renderHeader = true, api, selectedSessionId, machineLabelsById = {} } = props
const groups = useMemo(
() => groupSessionsByDirectory(props.sessions),
() => groupSessionsByDirectory(deduplicateSessionsByAgentId(props.sessions)),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Active duplicate sessions can now get stuck permanently once they are skipped here. The new existing.active guard 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 at hub/src/sync/sessionCache.ts:575, then its later session-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 through mergeSessions() (hub/src/sync/sessionCache.ts:584), but mergeSessions() only preserves messages/metadata/model/effort/todos/teamState before deleting the old row (hub/src/sync/sessionCache.ts:404). Any pending approval requests stored in oldStored.agentState disappear 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Selected duplicate still wins over the live session in the sidebar. The new comparator checks selectedSessionId before active, 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. Evidence web/src/components/SessionList.tsx:63, context hub/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.agentState when newStored.agentState === null, so any pending/completed requests that exist only on the duplicate are dropped as soon as mergeSessions() removes the old row. Evidence hub/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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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
})

)
}

if (oldStored.agentState !== null && newStored.agentState === null) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-only agentState entries when both rows already have state; see inline comment on hub/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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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)
}


// 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) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Completed permission requests can be reintroduced as pending after session merge; mergeAgentState() unions requests and completedRequests independently, so the same request ID can survive in both maps. The UI/API still treat anything left in requests as pending (shared/src/sessionSummary.ts:27, hub/src/web/routes/permissions.ts:52, hub/src/notifications/notificationHub.ts:85). Evidence hub/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

return {
...oldObj,
...newObj,
requests: {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Auto-dedup can make a successful resume report resume_failed. The new session-updated dedup path can delete the old session before resumeSession() reaches its existing explicit mergeSessions(old, new) step, so the later merge throws even though the respawned session is already active. Evidence hub/src/sync/syncEngine.ts:171, related resume flow hub/src/sync/syncEngine.ts:447, and resumed sessions already re-assert the agent session ID immediately in at least Gemini/OpenCode cli/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(() => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hapi Web misroutes Codex sessions: duplicate active conversations and messages can attach to the wrong thread

1 participant