Limit Android session retention

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Iliyan Malchev
2026-04-05 22:44:17 -07:00
parent 1bc1fce1a6
commit 36ef35d1fe
9 changed files with 324 additions and 4 deletions

View File

@@ -134,6 +134,14 @@ stub SDK docs and the local refactor doc:
the same planner turn can continue and produce the child-Genie plan
- child Genie questions remain separate child-session questions that roll up
through the parent session when user escalation is needed
- Retention is intentionally bounded:
- keep only the most recent 10 terminal top-level session trees in framework
history; never prune active/queued/waiting sessions
- prune stale Agent planner `CODEX_HOME` cache directories under the Agent app
cache to the most recent 10
- prune stale Genie `CODEX_HOME` cache directories per paired target app to
the most recent 10 from inside the Genie runtime, because the Agent app does
not have direct filesystem ownership of other app sandboxes
- HOME icon / notification taps for question or final-result states should route
to `SessionPopupActivity`, which uses one dialog-style popup shape for both
question answering and result follow-up.

View File

@@ -3,6 +3,7 @@ package com.openai.codex.agent
import android.app.agent.AgentManager
import android.content.Context
import android.util.Log
import com.openai.codex.bridge.CodexHomeRetention
import com.openai.codex.bridge.FrameworkEventBridge
import com.openai.codex.bridge.HostedCodexConfig
import com.openai.codex.bridge.SessionExecutionSettings
@@ -138,7 +139,14 @@ internal class AgentPlannerDesktopSessionHost(
}
localProxy?.close()
if (::codexHome.isInitialized) {
CodexHomeRetention.clearActive(codexHome)
runCatching { codexHome.deleteRecursively() }
runCatching {
CodexHomeRetention.pruneSessionHomes(
root = checkNotNull(codexHome.parentFile),
keepHomeNames = emptySet(),
)
}
}
if (::process.isInitialized) {
process.destroy()
@@ -204,10 +212,16 @@ internal class AgentPlannerDesktopSessionHost(
}
private fun startProcess() {
codexHome = File(context.cacheDir, "planner-desktop-codex-home/$sessionId").apply {
val codexHomeRoot = File(context.cacheDir, "planner-desktop-codex-home")
codexHome = File(codexHomeRoot, sessionId).apply {
deleteRecursively()
mkdirs()
}
CodexHomeRetention.markActive(codexHome)
CodexHomeRetention.pruneSessionHomes(
root = codexHomeRoot,
keepHomeNames = setOf(sessionId),
)
localProxy = AgentLocalCodexProxy { requestBody ->
forwardResponsesRequest(requestBody)
}.also(AgentLocalCodexProxy::start)

View File

@@ -3,6 +3,7 @@ package com.openai.codex.agent
import android.app.agent.AgentManager
import android.content.Context
import android.util.Log
import com.openai.codex.bridge.CodexHomeRetention
import com.openai.codex.bridge.HostedCodexConfig
import com.openai.codex.bridge.SessionExecutionSettings
import java.io.BufferedWriter
@@ -108,7 +109,7 @@ object AgentPlannerRuntimeManager {
private class AgentPlannerRuntime(
private val context: Context,
private val frameworkSessionId: String?,
private val frameworkSessionId: String,
) : Closeable {
companion object {
private const val REQUEST_TIMEOUT_MS = 30_000L
@@ -159,7 +160,14 @@ object AgentPlannerRuntimeManager {
}
localProxy?.close()
if (::codexHome.isInitialized) {
CodexHomeRetention.clearActive(codexHome)
runCatching { codexHome.deleteRecursively() }
runCatching {
CodexHomeRetention.pruneSessionHomes(
root = checkNotNull(codexHome.parentFile),
keepHomeNames = emptySet(),
)
}
}
if (::process.isInitialized) {
runCatching { process.destroy() }
@@ -167,10 +175,16 @@ object AgentPlannerRuntimeManager {
}
private fun startProcess() {
codexHome = File(context.cacheDir, "planner-codex-home/$frameworkSessionId").apply {
val codexHomeRoot = File(context.cacheDir, "planner-codex-home")
codexHome = File(codexHomeRoot, frameworkSessionId).apply {
deleteRecursively()
mkdirs()
}
CodexHomeRetention.markActive(codexHome)
CodexHomeRetention.pruneSessionHomes(
root = codexHomeRoot,
keepHomeNames = setOf(codexHome.name),
)
localProxy = AgentLocalCodexProxy { requestBody ->
forwardResponsesRequest(requestBody)
}.also(AgentLocalCodexProxy::start)

View File

@@ -7,10 +7,12 @@ import android.content.Context
import android.os.Binder
import android.os.Process
import android.util.Log
import com.openai.codex.bridge.CodexHomeRetention
import com.openai.codex.bridge.DesktopSessionBootstrap
import com.openai.codex.bridge.DetachedTargetCompat
import com.openai.codex.bridge.FrameworkSessionTransportCompat
import com.openai.codex.bridge.SessionExecutionSettings
import java.io.File
import java.util.concurrent.Executor
class AgentSessionController(context: Context) {
@@ -23,6 +25,7 @@ class AgentSessionController(context: Context) {
private const val PREFERRED_GENIE_PACKAGE = "com.openai.codex.genie"
private const val QUESTION_ANSWER_RETRY_COUNT = 10
private const val QUESTION_ANSWER_RETRY_DELAY_MS = 50L
private const val MAX_RETAINED_TERMINAL_SESSION_TREES = 10
}
private enum class ChildSessionLaunchMode {
@@ -685,6 +688,83 @@ class AgentSessionController(context: Context) {
)
}
fun enforceRetentionPolicy() {
val manager = agentManager ?: return
runCatching {
pruneTerminalSessionTrees(manager)
}.onFailure { err ->
Log.w(TAG, "Failed to prune terminal framework sessions", err)
}
val keepSessionIds: Set<String> = runCatching {
manager.getSessions(currentUserId()).mapTo(mutableSetOf()) { it.sessionId }
}.getOrElse { err ->
Log.w(TAG, "Failed to load sessions for planner cache retention", err)
emptySet()
}
prunePlannerCodexHomes(keepSessionIds)
}
private fun pruneTerminalSessionTrees(manager: AgentManager) {
val sessions = manager.getSessions(currentUserId())
val sessionsById = sessions.associateBy(AgentSessionInfo::getSessionId)
val childrenByParent = sessions
.filter { !it.parentSessionId.isNullOrBlank() }
.groupBy { it.parentSessionId }
val terminalRoots = sessions
.filter { session ->
session.parentSessionId.isNullOrBlank() ||
!sessionsById.containsKey(session.parentSessionId)
}
.filter { root ->
terminalTreeFor(root, childrenByParent)
.all { isTerminalState(it.state) }
}
.sortedWith(
compareByDescending<AgentSessionInfo> { it.createdElapsedRealtimeMillis }
.thenBy(AgentSessionInfo::getSessionId),
)
terminalRoots
.drop(MAX_RETAINED_TERMINAL_SESSION_TREES)
.forEach { session ->
runCatching {
cancelSessionTree(session.sessionId)
}.onFailure { err ->
Log.w(TAG, "Failed to prune terminal session tree ${session.sessionId}", err)
}
}
}
private fun terminalTreeFor(
root: AgentSessionInfo,
childrenByParent: Map<String?, List<AgentSessionInfo>>,
): List<AgentSessionInfo> {
val tree = mutableListOf<AgentSessionInfo>()
val stack = ArrayDeque<AgentSessionInfo>()
stack.add(root)
while (!stack.isEmpty()) {
val session = stack.removeLast()
tree += session
childrenByParent[session.sessionId].orEmpty().forEach(stack::add)
}
return tree
}
private fun prunePlannerCodexHomes(keepSessionIds: Set<String>) {
listOf(
File(appContext.cacheDir, "planner-codex-home"),
File(appContext.cacheDir, "planner-desktop-codex-home"),
).forEach { root ->
runCatching {
CodexHomeRetention.pruneSessionHomes(
root = root,
keepHomeNames = keepSessionIds,
)
}.onFailure { err ->
Log.w(TAG, "Failed to prune planner codex homes under ${root.absolutePath}", err)
}
}
}
private fun requireAgentManager(): AgentManager {
return checkNotNull(agentManager) { "AgentManager unavailable" }
}

View File

@@ -6,6 +6,7 @@ import android.app.agent.AgentSessionEvent
import android.app.agent.AgentSessionInfo
import android.os.Process
import android.util.Log
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.thread
import org.json.JSONObject
@@ -20,6 +21,7 @@ class CodexAgentService : AgentService() {
private val pendingQuestionLoads = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
private val handledBridgeRequests = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
private val pendingParentRollups = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
private val retentionPruneScheduled = AtomicBoolean(false)
}
private val agentManager by lazy { getSystemService(AgentManager::class.java) }
@@ -28,6 +30,7 @@ class CodexAgentService : AgentService() {
override fun onCreate() {
super.onCreate()
scheduleRetentionPrune()
}
override fun onSessionChanged(session: AgentSessionInfo) {
@@ -46,6 +49,10 @@ class CodexAgentService : AgentService() {
reason = "Planner session ended before the question was answered",
)
AgentPlannerRuntimeManager.closeSession(session.sessionId)
scheduleRetentionPrune()
}
if (isTerminalSessionState(session.state)) {
scheduleRetentionPrune()
}
if (session.state != AgentSessionInfo.STATE_WAITING_FOR_USER) {
return
@@ -77,6 +84,20 @@ class CodexAgentService : AgentService() {
handledGenieQuestions.removeIf { it.startsWith("$sessionId:") }
handledBridgeRequests.removeIf { it.startsWith("$sessionId:") }
pendingGenieQuestions.removeIf { it.startsWith("$sessionId:") }
scheduleRetentionPrune()
}
private fun scheduleRetentionPrune() {
if (!retentionPruneScheduled.compareAndSet(false, true)) {
return
}
thread(name = "CodexAgentRetentionPrune") {
try {
sessionController.enforceRetentionPolicy()
} finally {
retentionPruneScheduled.set(false)
}
}
}
override fun onShowOrUpdateSessionNotification(

View File

@@ -0,0 +1,81 @@
package com.openai.codex.bridge
import java.io.File
object CodexHomeRetention {
const val DEFAULT_RETAINED_SESSION_HOMES: Int = 10
private const val ACTIVE_MARKER = ".codex-active-session"
private const val STALE_ACTIVE_MARKER_MS = 6 * 60 * 60 * 1000L
data class PruneResult(
val deletedHomeNames: List<String>,
val failedHomeNames: Map<String, String>,
)
fun markActive(codexHome: File) {
codexHome.mkdirs()
File(codexHome, ACTIVE_MARKER).writeText(System.currentTimeMillis().toString())
}
fun clearActive(codexHome: File) {
File(codexHome, ACTIVE_MARKER).delete()
}
fun pruneSessionHomes(
root: File,
keepHomeNames: Set<String>,
retainedSessionHomes: Int = DEFAULT_RETAINED_SESSION_HOMES,
nowMillis: Long = System.currentTimeMillis(),
): PruneResult {
val children = root.listFiles()
?.filter(File::isDirectory)
?.filter { it.name.isNotBlank() }
.orEmpty()
if (children.isEmpty()) {
return PruneResult(deletedHomeNames = emptyList(), failedHomeNames = emptyMap())
}
val candidates = children.filterNot { home ->
home.name in keepHomeNames || hasFreshActiveMarker(home, nowMillis)
}
val retainedCandidateNames = candidates
.sortedWith(compareByDescending<File> { it.lastModified() }.thenBy(File::getName))
.take(retainedSessionHomes.coerceAtLeast(0))
.mapTo(mutableSetOf(), File::getName)
val deletedHomeNames = mutableListOf<String>()
val failedHomeNames = linkedMapOf<String, String>()
candidates
.filterNot { it.name in retainedCandidateNames }
.forEach { home ->
runCatching {
home.deleteRecursively()
}.onSuccess { deleted ->
if (deleted) {
deletedHomeNames += home.name
} else {
failedHomeNames[home.name] = "deleteRecursively returned false"
}
}.onFailure { err ->
failedHomeNames[home.name] = err.message ?: err::class.java.simpleName
}
}
return PruneResult(
deletedHomeNames = deletedHomeNames,
failedHomeNames = failedHomeNames,
)
}
private fun hasFreshActiveMarker(home: File, nowMillis: Long): Boolean {
val marker = File(home, ACTIVE_MARKER)
if (!marker.isFile) {
return false
}
val markerTime = marker.readText()
.trim()
.toLongOrNull()
?: marker.lastModified()
return nowMillis - markerTime < STALE_ACTIVE_MARKER_MS
}
}

View File

@@ -0,0 +1,79 @@
package com.openai.codex.bridge
import java.io.File
import kotlin.io.path.createTempDirectory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class CodexHomeRetentionTest {
@Test
fun prunesOldHomesButKeepsExplicitAndActiveHomes() {
val root = createTempDirectory("codex-home-retention").toFile()
try {
val oldHome = makeHome(root, "old", lastModified = 1_000)
val newerHome = makeHome(root, "newer", lastModified = 2_000)
val newestHome = makeHome(root, "newest", lastModified = 3_000)
val explicitKeepHome = makeHome(root, "explicit", lastModified = 100)
val activeHome = makeHome(root, "active", lastModified = 50)
CodexHomeRetention.markActive(activeHome)
val result = CodexHomeRetention.pruneSessionHomes(
root = root,
keepHomeNames = setOf("explicit"),
retainedSessionHomes = 2,
)
assertEquals(
CodexHomeRetention.PruneResult(
deletedHomeNames = listOf("old"),
failedHomeNames = emptyMap(),
),
result,
)
assertFalse(oldHome.exists())
assertTrue(newerHome.exists())
assertTrue(newestHome.exists())
assertTrue(explicitKeepHome.exists())
assertTrue(activeHome.exists())
} finally {
root.deleteRecursively()
}
}
@Test
fun staleActiveMarkerDoesNotKeepHome() {
val root = createTempDirectory("codex-home-retention").toFile()
try {
val staleActiveHome = makeHome(root, "stale-active", lastModified = 1_000)
CodexHomeRetention.markActive(staleActiveHome)
val result = CodexHomeRetention.pruneSessionHomes(
root = root,
keepHomeNames = emptySet(),
retainedSessionHomes = 0,
nowMillis = System.currentTimeMillis() + 7 * 60 * 60 * 1000L,
)
assertEquals(
CodexHomeRetention.PruneResult(
deletedHomeNames = listOf("stale-active"),
failedHomeNames = emptyMap(),
),
result,
)
assertFalse(staleActiveHome.exists())
} finally {
root.deleteRecursively()
}
}
private fun makeHome(root: File, name: String, lastModified: Long): File {
return File(root, name).apply {
mkdirs()
resolve("payload").writeText(name)
setLastModified(lastModified)
}
}
}

View File

@@ -5,6 +5,7 @@ import android.app.agent.GenieRequest
import android.app.agent.GenieService
import android.content.Context
import android.util.Log
import com.openai.codex.bridge.CodexHomeRetention
import com.openai.codex.bridge.DesktopSessionBootstrap
import com.openai.codex.bridge.DetachedTargetCompat
import com.openai.codex.bridge.FrameworkEventBridge
@@ -211,10 +212,16 @@ class CodexAppServerHost(
}
private fun startProcess() {
codexHome = File(context.cacheDir, "codex-home/${request.sessionId}").apply {
val codexHomeRoot = File(context.cacheDir, "codex-home")
codexHome = File(codexHomeRoot, request.sessionId).apply {
deleteRecursively()
mkdirs()
}
CodexHomeRetention.markActive(codexHome)
CodexHomeRetention.pruneSessionHomes(
root = codexHomeRoot,
keepHomeNames = setOf(request.sessionId),
)
HostedCodexConfig.installAgentsFile(codexHome, bridgeClient.readInstalledAgentsMarkdown())
val processBuilder = ProcessBuilder(
listOf(
@@ -414,7 +421,14 @@ class CodexAppServerHost(
runCatching { process.destroy() }
}
if (::codexHome.isInitialized) {
CodexHomeRetention.clearActive(codexHome)
runCatching { codexHome.deleteRecursively() }
runCatching {
CodexHomeRetention.pruneSessionHomes(
root = checkNotNull(codexHome.parentFile),
keepHomeNames = emptySet(),
)
}
}
control.process = null
activeThreadId = null

View File

@@ -136,6 +136,15 @@ The current repo now contains these implementation slices:
selecting packages and delegated Genie objectives
- child Genie questions are still represented as child-session questions and
roll up to the parent only when they need user escalation
- Session retention is bounded:
- the Agent keeps only the most recent 10 terminal top-level session trees in
the framework session list and never prunes active/queued/waiting sessions
- Agent planner `CODEX_HOME` cache directories are also pruned to the most
recent 10 stale homes, while live homes are protected with an active marker
- each Genie prunes stale per-target `cache/codex-home/<sessionId>` homes in
the paired app sandbox to the most recent 10 for that target app; global
cross-target cache cleanup would need framework support because the Agent
app cannot directly delete other apps' cache directories
- Codex Agent still uses `cancelSession(sessionId)` for user-driven cancellation
because it is the AGENT-role app, not a HOME-role surface. The
HOME-only `cancelHomeSession(sessionId)` API is reserved for Launcher/HOME