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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.yugahashimoto.andcode.core.diagnostics

import android.content.Context
import android.os.Build
import com.yugahashimoto.andcode.BuildConfig
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
Expand Down Expand Up @@ -51,9 +52,11 @@ object CrashLog {
buildString {
appendLine("AndCode crash")
appendLine("Time: ${TIMESTAMP.format(Date())}")
appendLine("Version: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
appendLine("Thread: ${thread.name}")
appendLine("Android: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})")
appendLine("Device: ${Build.MANUFACTURER} ${Build.MODEL}")
appendLine("Heap: ${heapUsage()}")
appendLine()
append(stackTrace.take(MAX_TRACE_CHARS))
}
Expand All @@ -62,8 +65,17 @@ object CrashLog {
target.writeText(report)
}

private fun heapUsage(): String {
val runtime = Runtime.getRuntime()
val used = (runtime.totalMemory() - runtime.freeMemory()) / MEGABYTE
val max = runtime.maxMemory() / MEGABYTE
return "$used MB used of $max MB"
}

private fun file(context: Context): File = File(File(context.filesDir, "diagnostics"), "last-crash.txt")

private const val MEGABYTE = 1024L * 1024L

private val TIMESTAMP = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
private const val MAX_TRACE_CHARS = 12_000
}
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,9 @@ private fun DiagnosticsSectionHeader(title: String) {
)
}

private const val CRASH_PREVIEW_LINES = 12
// The report's header is eight lines (including the blank one before the stack), so this leaves
// roughly the same amount of the stack visible as before the version and heap rows were added.
private const val CRASH_PREVIEW_LINES = 14

private fun formatBytes(bytes: Long): String {
val gb = bytes / (1024.0 * 1024.0 * 1024.0)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.yugahashimoto.andcode.runtime.local

import android.util.Log
import com.yugahashimoto.andcode.core.api.OpenCodeMessage
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.json.encodeToStream
import java.io.File

/**
* Chat history for the local Antigravity agent, kept in memory and flushed to disk.
*
* Like [ClaudeMessageStore], the history is read and written as a stream. The runtime used to load
* it with `readText()` and serialize the whole map with `encodeToString()` on every message, which
* held the entire file as a String on top of the decoded messages and could exhaust the heap on a
* long history (issue #392). The write also goes to a temporary file first, so an allocation
* failure or a killed process mid-write cannot truncate the history already on disk.
*/
@OptIn(ExperimentalSerializationApi::class)
internal class AntigravityMessageStore(
private val file: File,
private val json: Json,
) {
private val messages = linkedMapOf<String, MutableList<OpenCodeMessage>>()
private var loaded = false
private var dirty = false

init {
// No history yet is normal, not a read failure worth warning about.
loaded =
!file.exists() ||
runCatching {
file.inputStream().buffered().use { json.decodeFromStream<Map<String, List<OpenCodeMessage>>>(it) }
}.mapCatching { decoded ->
decoded.forEach { (sessionId, sessionMessages) ->
messages[sessionId] = sessionMessages.toMutableList()
}
}.onFailure { Log.w(TAG, "Could not read the Antigravity history", it) }
.isSuccess
}

@Synchronized
fun list(sessionId: String): List<OpenCodeMessage> = messages[sessionId].orEmpty().toList()

@Synchronized
fun append(
sessionId: String,
message: OpenCodeMessage,
) {
messages.getOrPut(sessionId) { mutableListOf() } += message
dirty = true
}

@Synchronized
fun remove(sessionId: String): Boolean {
val removed = messages.remove(sessionId) != null
if (removed) dirty = true
return removed
}

@Synchronized
fun flush() {
if (!dirty) return
runCatching {
file.parentFile?.mkdirs()
// A failed load (corrupt JSON, or an OOM while decoding a long history) used to leave an
// empty map behind, so the next flush silently replaced the real history with only the
// messages written since. Keep the unreadable file instead of destroying it.
if (!loaded) {
// The backup name is fixed: a second unreadable history replaces the first, which
// keeps one copy for manual recovery without piling up files on every failure.
file.takeIf(File::exists)?.let { unreadable ->
val backup = File(unreadable.parentFile, "${unreadable.name}.unreadable")
if (backup.exists()) backup.delete()
check(unreadable.renameTo(backup)) { "Cannot preserve unreadable ${unreadable.name}" }
}
loaded = true
}
val temporary = File(file.parentFile, ".${file.name}.tmp")
temporary.outputStream().buffered().use { output ->
json.encodeToStream(messages.mapValues { it.value.toList() }, output)
}
if (!temporary.renameTo(file)) {
temporary.delete()
error("Could not replace ${file.name}")
}
dirty = false
}.onFailure { Log.w(TAG, "Could not write the Antigravity history", it) }
}

private companion object {
const val TAG = "AntigravityMessageStore"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ class AntigravityRuntime(
private val recordsFile = File(runtimeDirectory, "antigravity-sessions.json")
private val messagesFile = File(runtimeDirectory, "antigravity-messages.json")
private val records = linkedMapOf<String, AntigravitySessionRecord>()
private val messages = linkedMapOf<String, MutableList<OpenCodeMessage>>()
private val messageStore = AntigravityMessageStore(messagesFile, json)
private val processes = linkedMapOf<String, Process>()

/** See [AntigravityAbortTracker] for why an intentional kill must not be reported as a crash. */
Expand Down Expand Up @@ -244,7 +244,8 @@ class AntigravityRuntime(
// bubble is replaced by that snapshot - so a user turn that only lands at the
// end of the send would vanish from the screen (and never reappear on a failed
// result, which persisted nothing for it).
messages.getOrPut(sessionId) { mutableListOf() }.add(
messageStore.append(
sessionId,
OpenCodeMessage(
OpenCodeMessageInfo(userId, sessionId, "user", OpenCodeTime(turnNow, turnNow, turnNow)),
// A part without an id is dropped when the chat maps the transcript for
Expand All @@ -267,7 +268,7 @@ class AntigravityRuntime(
},
),
)
persist()
persistMessages()
var discoveredConversationId: String? = null
var turnFinished = false
var turnError: String? = null
Expand All @@ -283,15 +284,16 @@ class AntigravityRuntime(
// one-turn lag seen on device. Claude Code avoids this by upserting before
// it emits, so the assistant reply (and its tool parts) land first here too.
parsed.messages.forEach { assistant ->
messages.getOrPut(sessionId) { mutableListOf() }.add(
messageStore.append(
sessionId,
assistant.copy(
info =
assistant.info.copy(
model = model?.let { OpenCodeModelReference(AntigravityModels.PROVIDER_ID, it) },
),
),
)
persist()
persistMessages()
}
parsed.events.forEach(events::tryEmit)
if (parsed.turnFinished) {
Expand All @@ -317,7 +319,7 @@ class AntigravityRuntime(
updatedAt = System.currentTimeMillis(),
lastStep = record0.lastStep + 1,
)
persist()
persistRecords()
if (turnFinished) {
// On a failed result the parser already emitted SessionError then SessionIdle;
// the user turn is persisted above, and there is no assistant reply to add.
Expand Down Expand Up @@ -375,19 +377,20 @@ class AntigravityRuntime(
if (record.archived) return record
val archived = record.copy(archived = true)
records[sessionId] = archived
persist()
persistRecords()
return archived
}

fun listMessages(sessionId: String): List<OpenCodeMessage> = messages[sessionId].orEmpty().toList()
fun listMessages(sessionId: String): List<OpenCodeMessage> = messageStore.list(sessionId)

fun remove(sessionId: String): Boolean {
abort(sessionId)
val removed = records.remove(sessionId) != null
messages.remove(sessionId)
messageStore.remove(sessionId)
val safeSession = sessionId.replace(Regex("[^A-Za-z0-9._-]"), "_")
File(runtimeDirectory, "workspace/$ATTACHMENT_DIRECTORY/$safeSession").deleteRecursively()
persist()
persistRecords()
persistMessages()
return removed
}

Expand All @@ -399,7 +402,7 @@ class AntigravityRuntime(
) {
records[sessionId] =
AntigravitySessionRecord(sessionId, null, workspace, title = title, permissionMode = permissionMode.cliValue)
persist()
persistRecords()
}

/** Remembers the model/variant a session's next message should use. */
Expand All @@ -410,7 +413,7 @@ class AntigravityRuntime(
) {
val record = records[sessionId] ?: return
records[sessionId] = record.copy(model = model, variant = variant)
persist()
persistRecords()
}

/** Renames a session; see [AntigravitySessionRecord.title]. */
Expand All @@ -420,7 +423,7 @@ class AntigravityRuntime(
) {
val record = records[sessionId] ?: return
records[sessionId] = record.copy(title = title, updatedAt = System.currentTimeMillis())
persist()
persistRecords()
}

/**
Expand Down Expand Up @@ -497,7 +500,7 @@ class AntigravityRuntime(
) {
val record = records[sessionId] ?: return
records[sessionId] = record.copy(permissionMode = mode.cliValue)
persist()
persistRecords()
}

fun abortAll() {
Expand Down Expand Up @@ -535,20 +538,18 @@ class AntigravityRuntime(
recordsFile.readText(),
).forEach { records[it.appSessionId] = it }
}
runCatching {
json.decodeFromString<Map<String, List<OpenCodeMessage>>>(
messagesFile.readText(),
).forEach { messages[it.key] = it.value.toMutableList() }
}
}

private fun persist() {
private fun persistRecords() {
runCatching {
recordsFile.parentFile?.mkdirs()
writeAtomically(recordsFile, json.encodeToString(records.values.toList()))
writeAtomically(messagesFile, json.encodeToString(messages.mapValues { it.value.toList() }))
}
}

private fun persistMessages() {
messageStore.flush()
}
}

private fun writeAtomically(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,9 @@ class AntigravityTarget(internal val runtime: AntigravityRuntime) : RuntimeTarge
val id = UUID.randomUUID().toString()
// Same as ClaudeCodeTarget: the mode settings shows is what a new chat runs in. Leaving the
// record's own default here is what made a full-access setting start chats in accept-edits.
// persist() serializes the whole message store to disk, so this must stay off the caller's
// (usually main) thread - left inline it blocked the UI long enough for Crashlytics to see
// it as an ANR once the store grew.
// Creating the record writes the session list to disk, so this stays off the caller's
// (usually main) thread - left inline the write blocked the UI long enough for Crashlytics
// to see it as an ANR once the list grew.
withContext(kotlinx.coroutines.Dispatchers.IO) {
runtime.create(id, directory ?: "/workspace", title, mutableDefaultPermissionMode.value)
}
Expand Down Expand Up @@ -131,14 +131,19 @@ class AntigravityTarget(internal val runtime: AntigravityRuntime) : RuntimeTarge

/** Same as [ClaudeCodeTarget.listMessages]: names the model for chats held before it was stored. */
override suspend fun listMessages(sessionId: String): List<OpenCodeMessage> {
val messages = runtime.listMessages(sessionId)
val model = runtime.findSession(sessionId)?.model ?: return messages
val reference = OpenCodeModelReference(AntigravityModels.PROVIDER_ID, model)
return messages.map { message ->
if (message.info.role == "assistant" && message.info.model == null) {
message.copy(info = message.info.copy(model = reference))
} else {
message
// The store's lock is held while the whole history is flushed, so reading (and remapping a
// long transcript) on the chat's dispatcher (main) could block the UI for the length of a
// write.
return withContext(Dispatchers.IO) {
val messages = runtime.listMessages(sessionId)
val model = runtime.findSession(sessionId)?.model ?: return@withContext messages
val reference = OpenCodeModelReference(AntigravityModels.PROVIDER_ID, model)
messages.map { message ->
if (message.info.role == "assistant" && message.info.model == null) {
message.copy(info = message.info.copy(model = reference))
} else {
message
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.yugahashimoto.andcode.runtime.local

import com.yugahashimoto.andcode.core.api.OpenCodeMessage
import com.yugahashimoto.andcode.core.api.OpenCodeMessageInfo
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File

class AntigravityMessageStoreTest {
private val json = Json { encodeDefaults = true }

@Test
fun `messages are written through a temporary file and survive a reload`() {
val directory = java.nio.file.Files.createTempDirectory("antigravity-message-store").toFile()
val file = File(directory, "antigravity-messages.json")
val store = AntigravityMessageStore(file, json)
store.append("s1", message("m1", "s1"))
store.flush()
store.append("s1", message("m2", "s1"))
store.flush()

assertEquals(listOf("antigravity-messages.json"), directory.list()?.toList())
assertEquals(listOf("m1", "m2"), AntigravityMessageStore(file, json).list("s1").map { it.info.id })
directory.deleteRecursively()
}

@Test
fun `removing a session deletes its messages from disk`() {
val directory = java.nio.file.Files.createTempDirectory("antigravity-message-store").toFile()
val file = File(directory, "antigravity-messages.json")
val store = AntigravityMessageStore(file, json)
store.append("kept", message("m1", "kept"))
store.append("removed", message("m2", "removed"))
store.flush()

assertTrue(store.remove("removed"))
assertFalse(store.remove("missing"))
store.flush()

val reloaded = AntigravityMessageStore(file, json)
assertEquals(listOf("m1"), reloaded.list("kept").map { it.info.id })
assertTrue(reloaded.list("removed").isEmpty())
directory.deleteRecursively()
}

@Test
fun `a corrupt history is preserved instead of being overwritten`() {
val directory = java.nio.file.Files.createTempDirectory("antigravity-message-store").toFile()
val file = File(directory, "antigravity-messages.json").also { it.writeText("{not json") }
val store = AntigravityMessageStore(file, json)

assertTrue(store.list("s1").isEmpty())
store.append("s1", message("m1", "s1"))
store.flush()

assertEquals("{not json", File(directory, "antigravity-messages.json.unreadable").readText())
assertEquals(listOf("m1"), AntigravityMessageStore(file, json).list("s1").map { it.info.id })
directory.deleteRecursively()
}

private fun message(
id: String,
sessionId: String,
) = OpenCodeMessage(info = OpenCodeMessageInfo(id, sessionId, "user"))
}
Loading