mirror of
https://github.com/openai/codex.git
synced 2026-04-27 09:51:03 +03:00
Limit Android session retention
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user