Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions scripts/test-session-ownership.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import assert from 'node:assert/strict'

import {
collectLiveProcessIds,
collectOwnedSessionIds,
collectUnownedSessionIds,
pickOwnedSessions,
pruneSessionOwnership,
} from '../src/renderer/src/workspace/sessionOwnership'
import { remapSessionMetaRelationships } from '../src/renderer/src/workspace/hook/persistence/rehydrate'
import type {
BuriedPaneRecord,
DetachedSessionRecord,
Expand Down Expand Up @@ -73,6 +75,41 @@ const sessions: Record<string, SessionMeta> = {
)
}

// WHY this block exists:
//
// The previous regression was the inverse of the original OOM bug. After the
// owned-set was introduced to stop orphan metadata from respawning, EVERY
// owner — including detached/buried — got a fresh PTY + mitmdump on rehydrate.
// Each "park this dispatch agent" action permanently added a backend process
// to every future launch. The live-process set is the smaller, stricter
// answer to "what must the user be able to type into RIGHT NOW?", and
// rehydrate filters its Promise.all by this set.
//
// The asserts below pin down the contract:
// - tile leaves ARE live processes (visible-a, visible-b)
// - detached are NOT (dispatch parking, wake-on-attach later)
// - buried are NOT (hidden by user, wake-on-restore later)
// - dispatch focus alone is NOT (it is a selection pointer, not ownership)
{
const live = collectLiveProcessIds({
tabs: [tab],
sessions,
detachedSessions: { 'old-key': detached },
buried: [buried],
})
assert.deepEqual([...live].sort(), ['visible-a', 'visible-b'])

// No tabs ⇒ no live processes, even with detached/buried records present.
// This is the rehydrate-after-bury edge case.
const liveEmpty = collectLiveProcessIds({
tabs: [],
sessions,
detachedSessions: { 'old-key': detached },
buried: [buried],
})
assert.deepEqual([...liveEmpty], [])
}

{
assert.deepEqual(
collectUnownedSessionIds({
Expand Down Expand Up @@ -142,3 +179,84 @@ const sessions: Record<string, SessionMeta> = {
'stale-a',
])
}

// WHY this block exists:
//
// The first fix shipped a hibernated-spawn cut but kept
// remapSessionMetaRelationships using a strict idMap.get(...) lookup with no
// fallback. Two hibernated sessions in a parent/child orchestration tree
// would both survive rehydrate (their metadata round-trips under the
// original sessionId), but the link BETWEEN them would silently disappear
// because neither endpoint is in idMap. After wake-on-attach lands, that
// shows up as orphaned children rendering as top-level rows.
//
// The asserts below pin the contract:
// 1. spawned->spawned: remap through idMap (existing behavior).
// 2. hibernated->hibernated: preserved via the original id when the
// endpoint is listed in knownSessionIds.
// 3. hibernated->vanished: dropped (endpoint not in idMap NOR
// knownSessionIds) — keeps the "honest state" invariant.
// 4. No knownSessionIds passed (back-compat): old strict behavior.
{
const childMeta: SessionMeta = {
cwd: '/child',
kind: 'claude',
providerSessionId: 'child-provider',
linkedParentId: 'parent-old',
orchestrationParentId: 'parent-old',
orchestrationRootId: 'root-old',
}

// Case 1: spawned parent, spawned child. idMap maps both old ids forward.
const idMapSpawned = new Map<string, string>([
['parent-old', 'parent-new'],
['root-old', 'root-new'],
])
const knownAfterSpawn = new Set(['parent-new', 'root-new'])
const remappedSpawned = remapSessionMetaRelationships(
childMeta,
idMapSpawned,
knownAfterSpawn,
)
assert.equal(remappedSpawned.linkedParentId, 'parent-new')
assert.equal(remappedSpawned.orchestrationParentId, 'parent-new')
assert.equal(remappedSpawned.orchestrationRootId, 'root-new')

// Case 2: hibernated parent, hibernated child. Empty idMap, but parent/root
// are still in knownSessionIds (under their original ids).
const idMapHibernated = new Map<string, string>()
const knownHibernated = new Set(['parent-old', 'root-old'])
const remappedHibernated = remapSessionMetaRelationships(
childMeta,
idMapHibernated,
knownHibernated,
)
assert.equal(remappedHibernated.linkedParentId, 'parent-old')
assert.equal(remappedHibernated.orchestrationParentId, 'parent-old')
assert.equal(remappedHibernated.orchestrationRootId, 'root-old')

// Case 3: parent vanished entirely (not in idMap, not in knownSessionIds).
// The link is dropped — the relationship endpoint is gone and pretending
// otherwise would resurrect a dead pointer.
const remappedOrphaned = remapSessionMetaRelationships(
childMeta,
new Map(),
new Set(),
)
assert.equal(remappedOrphaned.linkedParentId, undefined)
assert.equal(remappedOrphaned.orchestrationParentId, undefined)
assert.equal(remappedOrphaned.orchestrationRootId, undefined)

// Case 4: back-compat — calling without knownSessionIds preserves the
// strict pre-PR behavior (drop when not in idMap). Important so any future
// caller outside rehydrate keeps the explicit-opt-in shape.
const remappedNoKnown = remapSessionMetaRelationships(childMeta, idMapSpawned)
assert.equal(remappedNoKnown.linkedParentId, 'parent-new')
assert.equal(remappedNoKnown.orchestrationParentId, 'parent-new')
assert.equal(remappedNoKnown.orchestrationRootId, 'root-new')

const remappedNoKnownEmpty = remapSessionMetaRelationships(childMeta, new Map())
assert.equal(remappedNoKnownEmpty.linkedParentId, undefined)
assert.equal(remappedNoKnownEmpty.orchestrationParentId, undefined)
assert.equal(remappedNoKnownEmpty.orchestrationRootId, undefined)
}
17 changes: 15 additions & 2 deletions src/renderer/src/workspace/hook/actions/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type { WorkspaceRefs } from '@renderer/workspace/hook/refs'
import { loadInitialHistoryForSession } from '@renderer/workspace/hook/actions/initialHistory'
import { commandTargetSessionIdForState } from '@renderer/workspace/hook/selectors/commandTargetSessionId'
import {
collectLiveProcessIds,
collectOwnedSessionIds,
collectUnownedSessionIds,
pickOwnedSessions,
Expand Down Expand Up @@ -476,7 +477,7 @@ export function useSessionActions(
const reloadAgentSessions = useCallback(
async (dangerousMode = refs.dangerousAgentsRef.current) => {
const current = refs.stateRef.current
const ownedIds = collectOwnedSessionIds(current)
const liveProcessIds = collectLiveProcessIds(current)
const staleIds = collectUnownedSessionIds(current)
if (staleIds.length > 0) {
// WHY reload prunes but does not kill unowned ids directly:
Expand All @@ -491,8 +492,20 @@ export function useSessionActions(
// eslint-disable-next-line no-console
console.warn('[workspace] dropping unowned sessions during agent reload:', staleIds)
}
// WHY filter by liveProcessIds, not ownedIds (mirrors the rehydrate fix):
//
// After the rehydrate live-vs-owned split, hibernated dispatch agents
// (entries in `state.sessions` whose ids are NOT in any tile leaf) have
// no PTY, no mitmdump, and no provider process to reload. Toggling
// dangerous mode while parked agents exist used to call killSession +
// spawnSession on every one of them, which re-introduced the original
// fork-bomb in a different code path: a single mode toggle would
// resurrect N hibernated agents as live processes. liveProcessIds
// restricts the reload to tile-leaf sessions actually exposed to the
// user; hibernated agents pick up the new dangerous-mode setting when
// the wake-on-attach UI later spawns them.
const agentEntries = Object.entries(current.sessions).filter(([id, meta]) => {
if (!ownedIds.has(id)) return false
if (!liveProcessIds.has(id)) return false
const kind = meta.kind ?? 'claude'
return kind === 'claude' || kind === 'codex'
})
Expand Down
Loading