diff --git a/android/AGENTS.md b/android/AGENTS.md index 0d3ba14d4b..e4f4194cf7 100644 --- a/android/AGENTS.md +++ b/android/AGENTS.md @@ -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. diff --git a/android/app/src/main/java/com/openai/codex/agent/AgentPlannerDesktopSessionHost.kt b/android/app/src/main/java/com/openai/codex/agent/AgentPlannerDesktopSessionHost.kt index 8ad817a48f..bfffb99e0b 100644 --- a/android/app/src/main/java/com/openai/codex/agent/AgentPlannerDesktopSessionHost.kt +++ b/android/app/src/main/java/com/openai/codex/agent/AgentPlannerDesktopSessionHost.kt @@ -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) diff --git a/android/app/src/main/java/com/openai/codex/agent/AgentPlannerRuntimeManager.kt b/android/app/src/main/java/com/openai/codex/agent/AgentPlannerRuntimeManager.kt index c9917e17ea..f7af94cac1 100644 --- a/android/app/src/main/java/com/openai/codex/agent/AgentPlannerRuntimeManager.kt +++ b/android/app/src/main/java/com/openai/codex/agent/AgentPlannerRuntimeManager.kt @@ -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) diff --git a/android/app/src/main/java/com/openai/codex/agent/AgentSessionController.kt b/android/app/src/main/java/com/openai/codex/agent/AgentSessionController.kt index dfcc782fbe..d1a7a0c8f5 100644 --- a/android/app/src/main/java/com/openai/codex/agent/AgentSessionController.kt +++ b/android/app/src/main/java/com/openai/codex/agent/AgentSessionController.kt @@ -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 = 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 { 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>, + ): List { + val tree = mutableListOf() + val stack = ArrayDeque() + 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) { + 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" } } diff --git a/android/app/src/main/java/com/openai/codex/agent/CodexAgentService.kt b/android/app/src/main/java/com/openai/codex/agent/CodexAgentService.kt index 317889b018..22650dbc82 100644 --- a/android/app/src/main/java/com/openai/codex/agent/CodexAgentService.kt +++ b/android/app/src/main/java/com/openai/codex/agent/CodexAgentService.kt @@ -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() private val handledBridgeRequests = java.util.concurrent.ConcurrentHashMap.newKeySet() private val pendingParentRollups = java.util.concurrent.ConcurrentHashMap.newKeySet() + 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( diff --git a/android/bridge/src/main/java/com/openai/codex/bridge/CodexHomeRetention.kt b/android/bridge/src/main/java/com/openai/codex/bridge/CodexHomeRetention.kt new file mode 100644 index 0000000000..17dac4aceb --- /dev/null +++ b/android/bridge/src/main/java/com/openai/codex/bridge/CodexHomeRetention.kt @@ -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, + val failedHomeNames: Map, + ) + + 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, + 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 { it.lastModified() }.thenBy(File::getName)) + .take(retainedSessionHomes.coerceAtLeast(0)) + .mapTo(mutableSetOf(), File::getName) + + val deletedHomeNames = mutableListOf() + val failedHomeNames = linkedMapOf() + 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 + } +} diff --git a/android/bridge/src/test/java/com/openai/codex/bridge/CodexHomeRetentionTest.kt b/android/bridge/src/test/java/com/openai/codex/bridge/CodexHomeRetentionTest.kt new file mode 100644 index 0000000000..c44d6bfbc9 --- /dev/null +++ b/android/bridge/src/test/java/com/openai/codex/bridge/CodexHomeRetentionTest.kt @@ -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) + } + } +} diff --git a/android/genie/src/main/java/com/openai/codex/genie/CodexAppServerHost.kt b/android/genie/src/main/java/com/openai/codex/genie/CodexAppServerHost.kt index 73efa1554f..fc7c6298a8 100644 --- a/android/genie/src/main/java/com/openai/codex/genie/CodexAppServerHost.kt +++ b/android/genie/src/main/java/com/openai/codex/genie/CodexAppServerHost.kt @@ -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 diff --git a/docs/android-agent-genie-refactor.md b/docs/android-agent-genie-refactor.md index 90f6e80acb..12798da816 100644 --- a/docs/android-agent-genie-refactor.md +++ b/docs/android-agent-genie-refactor.md @@ -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/` 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