From 6a258e5e1ee668b5af7ef14a877e2aa32b79942e Mon Sep 17 00:00:00 2001 From: Stephen Edwards Date: Fri, 24 Apr 2026 13:48:12 -0400 Subject: [PATCH 01/16] Improve queue visibility and session restart tooling Amp-Thread-ID: https://ampcode.com/threads/T-019dc029-f25d-767c-8005-e2996169f6f8 Co-authored-by: Amp --- desktop-sidecar/README.md | 8 +- desktop-sidecar/build.gradle.kts | 1 + .../sidecar/EnvironmentSnapshot.kt | 493 +++++++++++ .../com/block/agenttaskqueue/sidecar/Main.kt | 770 +++++++++++++++++- .../agenttaskqueue/sidecar/MetricsSnapshot.kt | 96 +++ .../agenttaskqueue/sidecar/QueueSnapshot.kt | 208 ++++- .../sidecar/TaskQueueDatabase.kt | 36 + .../sidecar/EnvironmentSnapshotTest.kt | 70 ++ .../sidecar/MetricsSnapshotTest.kt | 25 + .../sidecar/QueueSnapshotTest.kt | 157 ++++ queue_core.py | 96 ++- task_queue.py | 76 +- tests/test_queue.py | 82 ++ tests/test_tq_cli.py | 111 +++ tq.py | 401 ++++++++- 15 files changed, 2578 insertions(+), 52 deletions(-) create mode 100644 desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshot.kt create mode 100644 desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/MetricsSnapshot.kt create mode 100644 desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshotTest.kt create mode 100644 desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/MetricsSnapshotTest.kt diff --git a/desktop-sidecar/README.md b/desktop-sidecar/README.md index aeb420b..3f91369 100644 --- a/desktop-sidecar/README.md +++ b/desktop-sidecar/README.md @@ -2,11 +2,15 @@ Minimal Compose Multiplatform desktop app for watching the local `agent-task-queue` database in real time. -The sidecar reads the existing SQLite queue DB directly and shows: +The sidecar reads the existing SQLite queue DB directly and also inspects live task queue +server args plus `adb devices -l` when available. It shows: - running tasks - waiting tasks - exact queues grouped by root scope so hierarchical queue activity is easier to understand +- configured `--queue-capacity` scopes, including empty queues and slot counts +- agent/context identity for live servers and tasks when queue metadata is available, with process inspection as a fallback +- connected ADB devices so emulator queue plans can be compared with reality It is intentionally read-only. There is no new MCP protocol or server surface. @@ -33,4 +37,4 @@ Use a specific queue directory with: ## Notes - `./gradlew` in this directory delegates to the checked-in Gradle wrapper under `../intellij-plugin/` so the sidecar stays lightweight. -- Queue capacities configured with `--queue-capacity` are process-local and are not persisted in `queue.db`, so the app visualizes live tasks and queue layout rather than stored capacity numbers. +- Queue capacities configured with `--queue-capacity` are still process-local and are not persisted in `queue.db`; the app surfaces them by reading the args of live task queue server processes for the selected data dir. diff --git a/desktop-sidecar/build.gradle.kts b/desktop-sidecar/build.gradle.kts index 6a52402..381fa3b 100644 --- a/desktop-sidecar/build.gradle.kts +++ b/desktop-sidecar/build.gradle.kts @@ -20,6 +20,7 @@ kotlin { implementation(compose.foundation) implementation(compose.material3) implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") implementation("org.xerial:sqlite-jdbc:3.53.0.0") } } diff --git a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshot.kt b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshot.kt new file mode 100644 index 0000000..3b02b44 --- /dev/null +++ b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshot.kt @@ -0,0 +1,493 @@ +package com.block.agenttaskqueue.sidecar + +import java.nio.file.Path +import java.nio.file.Paths + +private val DEFAULT_QUEUE_DATA_DIR: Path = Paths.get("/tmp/agent-task-queue").toAbsolutePath().normalize() + +data class QueueConfigurationSnapshot( + val serverProcesses: List, + val configuredScopes: List, + val statusMessage: String? = null, + val errorMessage: String? = null, +) { + val serverCount: Int = serverProcesses.size + val totalSlots: Int = configuredScopes.sumOf { it.capacity ?: 0 } + val configuredEmulatorScopeCount: Int = configuredScopes.count { it.isEmulatorLike } + + companion object { + val EMPTY = QueueConfigurationSnapshot( + serverProcesses = emptyList(), + configuredScopes = emptyList(), + ) + } +} + +data class QueueServerProcess( + val pid: Int, + val parentPid: Int? = null, + val commandLine: String, + val dataDir: Path, + val queueCapacities: Map, + val agentLabel: String = "Task Queue", + val contextLabel: String? = null, +) + +data class ConfiguredQueueScope( + val scopeName: String, + val capacities: Set, + val sourcePids: List, +) { + val capacity: Int? = capacities.singleOrNull() + val hasConflict: Boolean = capacities.size > 1 + val rootScope: String = scopeName.substringBefore('/') + val leafName: String = scopeName.substringAfterLast('/') + val emulatorPort: String? = extractEmulatorPort(leafName) + val isEmulatorLike: Boolean = emulatorPort != null + + val displayCapacityLabel: String = when { + capacity != null -> capacity.toString() + else -> capacities.sorted().joinToString(" / ") + } +} + +data class AdbSnapshot( + val devices: List, + val statusMessage: String? = null, + val errorMessage: String? = null, +) { + val connectedDevices: Int = devices.count { it.isConnected } + val connectedEmulators: Int = devices.count { it.isConnected && it.isEmulator } + + companion object { + val EMPTY = AdbSnapshot(devices = emptyList()) + } +} + +data class AdbDevice( + val serial: String, + val state: String, + val details: Map, +) { + val isConnected: Boolean = state.equals("device", ignoreCase = true) + val emulatorPort: String? = extractEmulatorPort(serial) + val isEmulator: Boolean = emulatorPort != null || + details["device"]?.contains("emu", ignoreCase = true) == true || + details["model"]?.contains("sdk", ignoreCase = true) == true + + val detailLine: String = buildList { + if (!isConnected) add(state) + details["model"]?.takeIf { it.isNotBlank() }?.let(::add) + details["device"]?.takeIf { it.isNotBlank() }?.let(::add) + details["transport_id"]?.takeIf { it.isNotBlank() }?.let { add("transport $it") } + }.joinToString(" · ") +} + +object TaskQueueProcessInspector { + fun loadConfiguration(dataDir: Path): QueueConfigurationSnapshot { + val normalizedDataDir = dataDir.toAbsolutePath().normalize() + val commandResult = runCommand("ps", "-axo", "pid=,ppid=,args=") + + if (commandResult.errorMessage != null) { + return QueueConfigurationSnapshot( + serverProcesses = emptyList(), + configuredScopes = emptyList(), + errorMessage = commandResult.errorMessage, + ) + } + + if (commandResult.exitCode != 0) { + return QueueConfigurationSnapshot( + serverProcesses = emptyList(), + configuredScopes = emptyList(), + errorMessage = commandResult.output.ifBlank { "Failed to inspect task queue processes." }, + ) + } + + val serverProcesses = parseTaskQueueProcesses(commandResult.output) + .filter { it.dataDir == normalizedDataDir } + + if (serverProcesses.isEmpty()) { + return QueueConfigurationSnapshot( + serverProcesses = emptyList(), + configuredScopes = emptyList(), + statusMessage = "No live task queue server detected for $normalizedDataDir. Exact queues default to capacity 1 unless a matching server is running.", + ) + } + + val scopesByName = linkedMapOf>() + val scopePids = linkedMapOf>() + serverProcesses.forEach { process -> + process.queueCapacities.forEach { (scopeName, capacity) -> + scopesByName.getOrPut(scopeName) { linkedSetOf() }.add(capacity) + scopePids.getOrPut(scopeName) { linkedSetOf() }.add(process.pid) + } + } + + val configuredScopes = scopesByName.entries + .sortedBy { it.key } + .map { (scopeName, capacities) -> + ConfiguredQueueScope( + scopeName = scopeName, + capacities = capacities.toSortedSet(), + sourcePids = scopePids[scopeName].orEmpty().sorted(), + ) + } + + val conflictingScopes = configuredScopes.filter { it.hasConflict } + val statusMessage = when { + configuredScopes.isEmpty() -> "Detected ${serverProcesses.size} live task queue server(s), but none advertise --queue-capacity overrides." + conflictingScopes.isEmpty() -> "Detected ${serverProcesses.size} live task queue server(s) with ${configuredScopes.size} configured scope(s) for this data dir." + else -> null + } + val errorMessage = conflictingScopes + .takeIf { it.isNotEmpty() } + ?.joinToString( + prefix = "Conflicting queue-capacity values detected for: ", + separator = ", ", + ) { "${it.scopeName} (${it.displayCapacityLabel})" } + + return QueueConfigurationSnapshot( + serverProcesses = serverProcesses, + configuredScopes = configuredScopes, + statusMessage = statusMessage, + errorMessage = errorMessage, + ) + } +} + +object AdbInspector { + fun loadSnapshot(): AdbSnapshot { + val commandResult = runCommand("adb", "devices", "-l") + if (commandResult.errorMessage != null) { + return AdbSnapshot( + devices = emptyList(), + statusMessage = commandResult.errorMessage, + ) + } + + if (commandResult.exitCode != 0) { + return AdbSnapshot( + devices = emptyList(), + errorMessage = commandResult.output.ifBlank { "`adb devices -l` failed." }, + ) + } + + return parseAdbSnapshot(commandResult.output) + } +} + +internal data class CommandResult( + val output: String, + val exitCode: Int, + val errorMessage: String? = null, +) + +internal fun parseTaskQueueProcesses(output: String): List { + val processEntries = output.lineSequence() + .mapNotNull(::parseProcessLine) + .toList() + val processesByPid = processEntries.associateBy { it.pid } + + return processEntries + .filter { looksLikeTaskQueueServer(it.tokens) } + .map { processEntry -> + val dataDir = resolveTaskQueueDataDir(processEntry.tokens) + QueueServerProcess( + pid = processEntry.pid, + parentPid = processEntry.parentPid, + commandLine = processEntry.commandLine, + dataDir = dataDir, + queueCapacities = parseQueueCapacities(processEntry.tokens), + agentLabel = inferProcessAgentLabel(processEntry, processesByPid), + contextLabel = inferProcessContextLabel(processEntry, processesByPid), + ) + } + .sortedBy { it.pid } + .toList() +} + +internal fun parseAdbSnapshot(output: String): AdbSnapshot { + val cleanedLines = output.lineSequence() + .map { it.trim() } + .filter { it.isNotBlank() } + .filterNot { it.startsWith("*") } + .toList() + + val headerIndex = cleanedLines.indexOfFirst { it.startsWith("List of devices attached") } + if (headerIndex == -1) { + return AdbSnapshot( + devices = emptyList(), + errorMessage = output.ifBlank { "Unexpected output from `adb devices -l`." }, + ) + } + + val devices = cleanedLines.drop(headerIndex + 1) + .mapNotNull(::parseAdbDevice) + .sortedWith(compareBy({ !it.isConnected }, { it.serial })) + + return AdbSnapshot( + devices = devices, + statusMessage = if (devices.isEmpty()) "No ADB devices detected." else null, + ) +} + +internal fun extractEmulatorPort(value: String): String? { + val trimmed = value.trim() + Regex("^(?:emu|emulator)-(\\d+)$", RegexOption.IGNORE_CASE).matchEntire(trimmed)?.let { + return it.groupValues[1] + } + Regex("^emulator-(\\d+)$", RegexOption.IGNORE_CASE).matchEntire(trimmed)?.let { + return it.groupValues[1] + } + Regex("^(?:127\\.0\\.0\\.1|localhost):(\\d+)$", RegexOption.IGNORE_CASE).matchEntire(trimmed)?.let { + return it.groupValues[1] + } + return null +} + +private data class ProcessEntry( + val pid: Int, + val parentPid: Int?, + val tokens: List, + val commandLine: String, +) + +private fun parseProcessLine(line: String): ProcessEntry? { + val trimmed = line.trim() + if (trimmed.isEmpty()) return null + + val firstSpace = trimmed.indexOfFirst { it.isWhitespace() } + if (firstSpace <= 0) return null + + val pid = trimmed.substring(0, firstSpace).toIntOrNull() ?: return null + val remainder = trimmed.substring(firstSpace).trimStart() + if (remainder.isEmpty()) return null + + val secondSpace = remainder.indexOfFirst { it.isWhitespace() } + val secondToken = if (secondSpace > 0) remainder.substring(0, secondSpace) else null + val parentPid = secondToken?.toIntOrNull() + val commandLine = if (parentPid != null && secondSpace > 0) { + remainder.substring(secondSpace).trim() + } else { + remainder.trim() + } + if (commandLine.isEmpty()) return null + + return ProcessEntry( + pid = pid, + parentPid = parentPid, + tokens = shellSplit(commandLine), + commandLine = commandLine, + ) +} + +private fun looksLikeTaskQueueServer(tokens: List): Boolean { + if (tokens.isEmpty()) return false + + val executable = tokens.first().substringAfterLast('/') + if (executable.startsWith("python")) { + return tokens.any { it.endsWith("task_queue.py") } + } + + return executable == "agent-task-queue" || + executable == "task_queue" || + executable == "task_queue.py" +} + +private fun inferProcessAgentLabel( + processEntry: ProcessEntry, + processesByPid: Map, +): String { + return (listOf(processEntry) + ancestorChain(processEntry, processesByPid)) + .mapNotNull(::knownAgentLabel) + .firstOrNull() + ?: "Task Queue" +} + +private fun inferProcessContextLabel( + processEntry: ProcessEntry, + processesByPid: Map, +): String? { + return (listOf(processEntry) + ancestorChain(processEntry, processesByPid)) + .mapNotNull { entry -> findProcessDirectory(entry.tokens) } + .map(::compactPathLabel) + .firstOrNull() +} + +private fun ancestorChain( + processEntry: ProcessEntry, + processesByPid: Map, +): List { + val ancestors = mutableListOf() + val visited = mutableSetOf() + var currentPid = processEntry.parentPid + while (currentPid != null && visited.add(currentPid)) { + val ancestor = processesByPid[currentPid] ?: break + ancestors += ancestor + currentPid = ancestor.parentPid + } + return ancestors +} + +private fun knownAgentLabel(processEntry: ProcessEntry): String? { + val executable = processEntry.tokens.firstOrNull()?.substringAfterLast('/') ?: return null + return when { + executable == "amp" -> buildAmpLabel(processEntry.tokens) + executable.startsWith("claude") -> "Claude" + executable.startsWith("codex") -> "Codex" + executable.startsWith("cursor") -> "Cursor" + executable == "zed" -> "Zed" + executable == "windsurf" -> "Windsurf" + else -> null + } +} + +private fun buildAmpLabel(tokens: List): String { + val mode = tokens.zipWithNext().firstOrNull { (current, _) -> + current == "-m" || current == "--mode" + }?.second ?: tokens.firstOrNull { it.startsWith("--mode=") }?.substringAfter('=') + + return if (mode.isNullOrBlank()) { + "Amp" + } else { + "Amp ${mode.trim()}" + } +} + +private fun findProcessDirectory(tokens: List): String? { + var index = 0 + while (index < tokens.size) { + val token = tokens[index] + when { + token.startsWith("--directory=") -> return token.substringAfter('=') + token == "--directory" && index + 1 < tokens.size -> return tokens[index + 1] + token.startsWith("--cwd=") -> return token.substringAfter('=') + token == "--cwd" && index + 1 < tokens.size -> return tokens[index + 1] + } + index += 1 + } + return null +} + +private fun resolveTaskQueueDataDir(tokens: List): Path { + var index = 0 + while (index < tokens.size) { + val token = tokens[index] + when { + token.startsWith("--data-dir=") -> { + return Paths.get(token.substringAfter('=')) + .toAbsolutePath() + .normalize() + } + token == "--data-dir" && index + 1 < tokens.size -> { + return Paths.get(tokens[index + 1]) + .toAbsolutePath() + .normalize() + } + } + index += 1 + } + + return DEFAULT_QUEUE_DATA_DIR +} + +private fun parseQueueCapacities(tokens: List): Map { + val capacities = linkedMapOf() + var index = 0 + while (index < tokens.size) { + val token = tokens[index] + val rawSpec = when { + token.startsWith("--queue-capacity=") -> token.substringAfter("--queue-capacity=") + token == "--queue-capacity" && index + 1 < tokens.size -> { + index += 1 + tokens[index] + } + else -> null + } + + if (rawSpec != null) { + val separator = rawSpec.indexOf('=') + if (separator > 0 && separator < rawSpec.lastIndex) { + val scopeName = rawSpec.substring(0, separator) + val capacity = rawSpec.substring(separator + 1).toIntOrNull() + if (capacity != null) { + capacities[scopeName] = capacity + } + } + } + + index += 1 + } + + return capacities +} + +private fun parseAdbDevice(line: String): AdbDevice? { + val parts = line.split(Regex("\\s+")) + if (parts.size < 2) return null + + val details = parts.drop(2) + .mapNotNull { segment -> + val separator = segment.indexOf(':') + if (separator <= 0) { + null + } else { + segment.substring(0, separator) to segment.substring(separator + 1) + } + } + .toMap() + + return AdbDevice( + serial = parts[0], + state = parts[1], + details = details, + ) +} + +private fun runCommand(vararg command: String): CommandResult { + return try { + val process = ProcessBuilder(*command) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().use { it.readText() } + CommandResult(output = output.trim(), exitCode = process.waitFor()) + } catch (error: Exception) { + CommandResult( + output = "", + exitCode = -1, + errorMessage = error.message ?: "Failed to run `${command.joinToString(" ")}`.", + ) + } +} + +private fun shellSplit(commandLine: String): List { + val tokens = mutableListOf() + val current = StringBuilder() + var quote: Char? = null + var escaping = false + + fun flush() { + if (current.isNotEmpty()) { + tokens += current.toString() + current.clear() + } + } + + commandLine.forEach { ch -> + when { + escaping -> { + current.append(ch) + escaping = false + } + ch == '\\' && quote != '\'' -> escaping = true + quote != null && ch == quote -> quote = null + quote == null && (ch == '"' || ch == '\'') -> quote = ch + quote == null && ch.isWhitespace() -> flush() + else -> current.append(ch) + } + } + flush() + + return tokens +} diff --git a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/Main.kt b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/Main.kt index 01513dd..9e9ef53 100644 --- a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/Main.kt +++ b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/Main.kt @@ -3,6 +3,8 @@ package com.block.agenttaskqueue.sidecar import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.TooltipArea import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -12,6 +14,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState @@ -28,8 +31,8 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -56,6 +59,15 @@ import kotlin.system.exitProcess private const val ACTIVE_INTERVAL_MS = 1000L private const val IDLE_INTERVAL_MS = 3000L +private val ScopeAccent = Color(0xFF305B78) +private val RunningAccent = Color(0xFFC96A3D) +private val WaitingAccent = Color(0xFF3F7698) +private val LaneAccent = Color(0xFF46705C) +private val WarningAccent = Color(0xFFB35C33) +private val ScopeCardColor = Color(0xFFFFFBF6) +private val LaneCardColor = Color(0xFFFFFDF9) +private val TooltipColor = Color(0xFF2B2F35) + private val DashboardColors = lightColorScheme( primary = Color(0xFF305B78), secondary = Color(0xFFB35C33), @@ -144,36 +156,419 @@ private fun QueueDashboard(dataDir: Path) { ) .verticalScroll(rememberScrollState()) .padding(innerPadding) - .padding(horizontal = 24.dp, vertical = 20.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), + .padding(horizontal = 22.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - SummaryRow(snapshot) - ScopeOverview(snapshot.scopeGroups) - snapshot.errorMessage?.let { ErrorBanner(it) } - snapshot.statusMessage?.let { InfoBanner(it) } + snapshot.configuration.errorMessage?.let { ErrorBanner(it) } + snapshot.adb.errorMessage?.let { ErrorBanner(it) } + QueueFlowSection(snapshot) + EnvironmentDetailsSection(snapshot) + + Text( + text = "Scopes own shared slots. Exact queues stay FIFO. Inline chips keep run vs wait local, while diagnostics below show which agent context owns each live server.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f), + ) + } + } +} - TaskSection( - title = "Running Now", - subtitle = "Tasks currently holding queue slots.", - tasks = snapshot.runningTasks, - emptyLabel = "No running tasks.", +@Composable +private fun QueueFlowSection(snapshot: QueueSnapshot) { + SectionCard( + title = "Queue Flow", + subtitle = "Scopes own shared capacity; exact queues keep FIFO order inside each lane.", + ) { + if (snapshot.scopeGroups.isEmpty()) { + Text( + text = snapshot.statusMessage ?: "No queues are visible yet.", + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f), ) + return@SectionCard + } + + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + snapshot.scopeGroups.forEach { scope -> + ScopeFlowCard(scope = scope, snapshot = snapshot) + } + } + } +} + +@Composable +private fun ScopeFlowCard( + scope: ScopeGroup, + snapshot: QueueSnapshot, +) { + val rootUsage = snapshot.configuredScopeUsage.firstOrNull { it.scopeName == scope.scopeName } + val emulatorLanes = scope.lanes.filter { it.isEmulatorLike || it.configuredScope?.isEmulatorLike == true } + val matchedEmulators = emulatorLanes.count { lane -> lane.emulatorPort != null && lane.emulatorPort in snapshot.emulatorAlignment.matchedPorts } + + Card(colors = CardDefaults.cardColors(containerColor = ScopeCardColor)) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(scope.scopeName, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) + Text( + text = "${scope.lanes.size} lane(s) · ${scope.runningCount} running · ${scope.waitingCount} waiting", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.72f), + ) + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MetaBadge( + label = rootUsage?.let { "shared ${it.displayCapacityLabel}" } ?: "default per-lane", + accent = ScopeAccent, + filled = true, + ) + val configuredDescendants = snapshot.configuredScopeUsage.count { + it.scopeName != scope.scopeName && it.scopeName.startsWith("${scope.scopeName}/") + } + if (configuredDescendants > 0) { + MetaBadge( + label = "$configuredDescendants configured descendant lane(s)", + accent = LaneAccent, + ) + } + if (emulatorLanes.isNotEmpty()) { + MetaBadge( + label = "ADB $matchedEmulators/${emulatorLanes.size} matched", + accent = WarningAccent, + tooltip = "Matches configured emulator queue lanes against connected `adb devices -l` emulator serials. A lane is matched when both advertise the same emulator port.", + ) + } + } + } + + Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) { + SlotStrip( + capacity = rootUsage?.capacity, + usedSlots = rootUsage?.usedSlots ?: scope.runningCount, + accent = ScopeAccent, + fallbackLabel = if (rootUsage == null) "No shared cap configured" else null, + ) + } + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.28f)) + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + scope.lanes + .sortedWith( + compareByDescending { it.runningCount > 0 } + .thenByDescending { it.waitingCount > 0 } + .thenByDescending { it.configuredScope != null } + .thenBy { it.queueName } + ) + .forEach { lane -> + LaneFlowRow( + lane = lane, + rootUsage = rootUsage, + emulatorMatched = lane.emulatorPort != null && lane.emulatorPort in snapshot.emulatorAlignment.matchedPorts, + ) + } + } + } + } +} + +@Composable +private fun LaneFlowRow( + lane: QueueLane, + rootUsage: ConfiguredScopeUsage?, + emulatorMatched: Boolean, +) { + val runningTasks = lane.tasks.filter { it.status.equals("running", ignoreCase = true) } + val waitingTasks = lane.tasks.filter { it.status.equals("waiting", ignoreCase = true) } + val laneLabel = lane.queueName.substringAfterLast('/') + val fullPathNeeded = laneLabel != lane.queueName + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = LaneCardColor, + tonalElevation = 1.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.14f), RoundedCornerShape(16.dp)) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(laneLabel, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + if (fullPathNeeded) { + Text( + text = lane.queueName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.62f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Text( + text = "${runningTasks.size} running · ${waitingTasks.size} waiting", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MetaBadge( + label = when { + lane.hasCapacityConflict -> "exact ${lane.configuredScope?.displayCapacityLabel}" + lane.configuredCapacity != null -> "exact ${lane.configuredCapacity}" + else -> "default 1" + }, + accent = LaneAccent, + filled = lane.configuredCapacity != null || lane.hasCapacityConflict, + ) + rootUsage?.let { + MetaBadge( + label = "shares ${scopeLabel(it)}=${it.displayCapacityLabel}", + accent = ScopeAccent, + ) + } + if (lane.isEmulatorLike) { + MetaBadge( + label = if (emulatorMatched) "ADB ${lane.emulatorPort} matched" else "ADB ${lane.emulatorPort ?: "missing"} missing", + accent = if (emulatorMatched) LaneAccent else WarningAccent, + tooltip = lane.emulatorPort?.let { emulatorPort -> + if (emulatorMatched) { + "Queue lane `$emulatorPort` has a connected ADB emulator on the same port." + } else { + "Queue lane `$emulatorPort` is configured, but `adb devices -l` does not currently show a connected emulator on that port." + } + }, + ) + } + } + + if (lane.tasks.isEmpty()) { + Text( + text = if (lane.configuredScope != null) "Idle configured lane." else "Idle lane.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.62f), + ) + } else { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + runningTasks.forEach { task -> + TaskChip(task = task, running = true) + } + if (runningTasks.isNotEmpty() && waitingTasks.isNotEmpty()) { + Text( + text = "queue", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.45f), + ) + } + waitingTasks.forEach { task -> + TaskChip(task = task, running = false) + } + } + } + } + } +} + +@Composable +private fun TaskChip( + task: QueueTask, + running: Boolean, +) { + val accent = if (running) RunningAccent else WaitingAccent + val background = if (running) accent.copy(alpha = 0.12f) else accent.copy(alpha = 0.05f) + val identityLabel = task.displayIdentityLabel + val diagnosticLabel = identityLabel ?: buildString { + task.pid?.let { append("server $it") } + task.childPid?.let { + if (isNotEmpty()) append(" · ") + append("child $it") + } + }.takeIf { it.isNotBlank() } + + Surface( + shape = RoundedCornerShape(14.dp), + color = background, + tonalElevation = 0.dp, + ) { + Column( + modifier = Modifier + .widthIn(min = 220.dp, max = 300.dp) + .border(1.dp, accent.copy(alpha = 0.34f), RoundedCornerShape(14.dp)) + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + StatusBadge(if (running) "run" else "wait", accent) + Text("#${task.id}", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) + } + Text( + task.statusAge(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f), + ) + } - TaskSection( - title = "Queued / Waiting", - subtitle = "Tasks blocked behind older work in their exact queue.", - tasks = snapshot.waitingTasks, - emptyLabel = "No waiting tasks.", + Text( + text = task.displayCommand, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) - ScopeDetails(snapshot.scopeGroups) + diagnosticLabel?.let { + Text( + text = it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.62f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun EnvironmentDetailsSection(snapshot: QueueSnapshot) { + SectionCard( + title = "Environment Details", + subtitle = "Secondary diagnostics for live server config and connected ADB devices.", + ) { + snapshot.statusMessage?.let { InfoBanner(it) } + snapshot.configuration.statusMessage?.let { InfoBanner(it) } + snapshot.adb.statusMessage?.let { InfoBanner(it) } + Text( + text = "Live queue servers", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + if (snapshot.configuration.serverProcesses.isEmpty()) { Text( - text = "Live view from queue.db. Queue capacities set with --queue-capacity are process-local and not persisted in SQLite.", - style = MaterialTheme.typography.bodySmall, + text = "No matching task queue server processes detected for this data dir.", + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f), ) + } else { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + snapshot.configuration.serverProcesses.forEach { process -> + val identity = snapshot.serverIdentityByPid[process.pid] + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.14f), RoundedCornerShape(12.dp)) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = identity?.displayLabel ?: process.agentLabel, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + identity?.launchContextLabel + ?.takeIf { identity.contextLabel == null } + ?.let { launchContextLabel -> + Text( + text = "server launched from $launchContextLabel", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.58f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MetaBadge(label = "pid ${process.pid}", accent = ScopeAccent) + MetaBadge( + label = if (process.queueCapacities.isEmpty()) { + "no scope overrides" + } else { + "${process.queueCapacities.size} scope override(s)" + }, + accent = LaneAccent, + filled = process.queueCapacities.isNotEmpty(), + ) + identity?.detailLabel?.let { detailLabel -> + MetaBadge(label = detailLabel, accent = WaitingAccent) + } + } + Text( + text = process.commandLine, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } + + Text( + text = "ADB devices", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + if (snapshot.adb.devices.isEmpty()) { + Text( + text = "No ADB devices detected.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f), + ) + } else { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + snapshot.adb.devices.forEach { device -> + AdbDeviceRow(device) + } + } } } } @@ -199,22 +594,79 @@ private fun SummaryRow(snapshot: QueueSnapshot) { modifier = Modifier.weight(1f), ) SummaryCard( - title = "Exact Queues", + title = "Visible Queues", value = snapshot.queueLanes.size.toString(), - caption = "Distinct queue_name values", + caption = "Observed + configured queue names", accent = Color(0xFF5B8A67), modifier = Modifier.weight(1f), ) SummaryCard( - title = "Root Scopes", - value = snapshot.scopeGroups.size.toString(), - caption = "Top-level queue groups", + title = "Emulators", + value = "${snapshot.configuration.configuredEmulatorScopeCount} / ${snapshot.adb.connectedEmulators}", + caption = "Configured emulator queues / connected emulators", accent = Color(0xFF8B5F8C), modifier = Modifier.weight(1f), ) } } +@Composable +private fun CapacityOverview(configuredScopes: List) { + SectionCard( + title = "Capacity Map", + subtitle = "Configured scopes stay visible even when empty, so you can see where parallel slots actually exist.", + ) { + if (configuredScopes.isEmpty()) { + Text( + "No live --queue-capacity scopes detected. Exact queues still default to capacity 1.", + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f), + ) + return@SectionCard + } + + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + configuredScopes.forEach { usage -> + Card( + modifier = Modifier.widthIn(min = 260.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFFF9F3EA)), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(usage.scopeName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text( + text = if (usage.capacity != null) { + "${usage.runningCount} running · ${usage.waitingCount} waiting · ${usage.displayCapacityLabel} slot(s)" + } else { + "${usage.runningCount} running · ${usage.waitingCount} waiting · conflicting cap ${usage.displayCapacityLabel}" + }, + style = MaterialTheme.typography.bodyMedium, + ) + SlotStrip( + capacity = usage.capacity, + usedSlots = usage.usedSlots, + accent = Color(0xFFB35C33), + ) + Text( + text = "${usage.descendantQueueCount} visible queue(s) in scope · from ${usage.sourceServerLabels.joinToString()}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.68f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } +} + @Composable private fun SummaryCard( title: String, @@ -283,6 +735,14 @@ private fun ScopeOverview(scopeGroups: List) { style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.68f), ) + val configuredLanes = scope.lanes.count { it.configuredScope != null } + if (configuredLanes > 0) { + Text( + text = "$configuredLanes configured lane(s) visible even when idle", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.68f), + ) + } } } } @@ -351,14 +811,38 @@ private fun QueueLaneCard(lane: QueueLane) { style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), ) + Text( + text = when { + lane.hasCapacityConflict -> "Exact cap conflict: ${lane.configuredScope?.displayCapacityLabel}" + lane.configuredCapacity != null -> "Exact cap ${lane.configuredCapacity} configured" + else -> "Exact cap 1 default" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.68f), + ) + if (lane.configuredCapacity != null || lane.hasCapacityConflict) { + SlotStrip( + capacity = lane.configuredCapacity, + usedSlots = lane.runningCount, + accent = Color(0xFF46705C), + ) + } } } HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - lane.tasks.forEach { task -> - TaskRow(task = task, showQueue = false) + if (lane.tasks.isEmpty()) { + Text( + text = "No live tasks in this queue right now.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f), + ) + } else { + lane.tasks.forEach { task -> + TaskRow(task = task, showQueue = false) + } } } } @@ -417,7 +901,8 @@ private fun TaskRow(task: QueueTask, showQueue: Boolean) { ) val processLine = buildString { - task.pid?.let { append("server pid $it") } + task.displayIdentityLabel?.let { append(it) } + task.pid?.takeIf { task.displayIdentityLabel == null }?.let { append("server pid $it") } task.childPid?.let { if (isNotEmpty()) append(" · ") append("child pid $it") @@ -434,23 +919,244 @@ private fun TaskRow(task: QueueTask, showQueue: Boolean) { } } +@Composable +private fun AdbSection(snapshot: QueueSnapshot) { + SectionCard( + title = "ADB Devices", + subtitle = "Compare connected emulators with queue scopes so emulator fan-out stays aligned with real devices.", + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + SummaryCard( + title = "Connected", + value = snapshot.adb.connectedDevices.toString(), + caption = "ADB devices in state=device", + accent = Color(0xFF305B78), + modifier = Modifier.weight(1f), + ) + SummaryCard( + title = "Emulators", + value = snapshot.adb.connectedEmulators.toString(), + caption = "Connected emulator serials", + accent = Color(0xFFB35C33), + modifier = Modifier.weight(1f), + ) + SummaryCard( + title = "Configured", + value = snapshot.emulatorAlignment.configuredQueues.size.toString(), + caption = "Configured emulator queues", + accent = Color(0xFF46705C), + modifier = Modifier.weight(1f), + ) + SummaryCard( + title = "Matched", + value = snapshot.emulatorAlignment.matchedPorts.size.toString(), + caption = "Queue/device port matches", + accent = Color(0xFF8B5F8C), + modifier = Modifier.weight(1f), + ) + } + + val alignment = snapshot.emulatorAlignment + if (alignment.unmatchedConfiguredQueues.isNotEmpty() || alignment.unmatchedDevices.isNotEmpty()) { + Text( + text = buildString { + if (alignment.unmatchedConfiguredQueues.isNotEmpty()) { + append("Unmatched configured queues: ") + append(alignment.unmatchedConfiguredQueues.joinToString { it.queueName }) + } + if (alignment.unmatchedDevices.isNotEmpty()) { + if (isNotEmpty()) append(". ") + append("Unmatched ADB devices: ") + append(alignment.unmatchedDevices.joinToString { it.serial }) + } + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.72f), + ) + } + + if (snapshot.adb.devices.isEmpty()) { + Text( + text = "No ADB devices to display.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f), + ) + return@SectionCard + } + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + snapshot.adb.devices.forEach { device -> + AdbDeviceRow(device) + } + } + } +} + @Composable private fun StatusBadge(text: String, accent: Color) { Box( modifier = Modifier .clip(RoundedCornerShape(999.dp)) .background(accent.copy(alpha = 0.16f)) - .padding(horizontal = 10.dp, vertical = 4.dp), + .padding(horizontal = 8.dp, vertical = 3.dp), ) { Text( text = text.uppercase(), color = accent, - style = MaterialTheme.typography.labelMedium, + style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold, ) } } +@Composable +@OptIn(ExperimentalFoundationApi::class) +private fun MetaBadge( + label: String, + accent: Color, + filled: Boolean = false, + tooltip: String? = null, +) { + val background = if (filled) accent.copy(alpha = 0.14f) else Color.Transparent + val badgeContent: @Composable () -> Unit = { + Surface( + shape = RoundedCornerShape(999.dp), + color = background, + ) { + Text( + text = label, + modifier = Modifier + .border(1.dp, accent.copy(alpha = 0.38f), RoundedCornerShape(999.dp)) + .padding(horizontal = 9.dp, vertical = 4.dp), + color = accent, + style = MaterialTheme.typography.labelSmall, + ) + } + } + + if (tooltip != null) { + TooltipArea( + tooltip = { + TooltipBubble(tooltip) + }, + delayMillis = 150, + ) { + badgeContent() + } + } else { + badgeContent() + } +} + +@Composable +private fun TooltipBubble(text: String) { + Surface( + shape = RoundedCornerShape(10.dp), + color = TooltipColor, + shadowElevation = 8.dp, + ) { + Text( + text = text, + modifier = Modifier + .widthIn(max = 280.dp) + .padding(horizontal = 10.dp, vertical = 8.dp), + color = Color.White, + style = MaterialTheme.typography.bodySmall, + ) + } +} + +@Composable +private fun SlotStrip( + capacity: Int?, + usedSlots: Int, + accent: Color, + fallbackLabel: String? = null, +) { + if (capacity == null) { + fallbackLabel?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f), + ) + } + return + } + + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + repeat(capacity) { index -> + val filled = index < usedSlots + Box( + modifier = Modifier + .size(width = 16.dp, height = 9.dp) + .clip(RoundedCornerShape(4.dp)) + .background(if (filled) accent else accent.copy(alpha = 0.12f)) + .border(1.dp, accent.copy(alpha = 0.4f), RoundedCornerShape(4.dp)), + ) + } + Text( + text = "$usedSlots/$capacity used", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + } +} + +private fun scopeLabel(usage: ConfiguredScopeUsage): String = usage.scopeName.substringAfterLast('/') + +@Composable +private fun AdbDeviceRow(device: AdbDevice) { + val accent = when { + device.isConnected && device.isEmulator -> Color(0xFF46705C) + device.isConnected -> Color(0xFF305B78) + else -> Color(0xFFB35C33) + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 1.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, accent.copy(alpha = 0.18f), RoundedCornerShape(14.dp)) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + StatusBadge(device.state, accent) + Text(device.serial, fontWeight = FontWeight.Medium) + } + if (device.emulatorPort != null) { + Text( + text = "port ${device.emulatorPort}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + } + } + if (device.detailLine.isNotBlank()) { + Text( + text = device.detailLine, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.68f), + ) + } + } + } +} + @Composable private fun SectionCard( title: String, @@ -459,8 +1165,8 @@ private fun SectionCard( ) { Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { Column( - modifier = Modifier.fillMaxWidth().padding(20.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), content = { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) diff --git a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/MetricsSnapshot.kt b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/MetricsSnapshot.kt new file mode 100644 index 0000000..8a78ccf --- /dev/null +++ b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/MetricsSnapshot.kt @@ -0,0 +1,96 @@ +package com.block.agenttaskqueue.sidecar + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.nio.file.Path + +data class HistoricalTaskUsage( + val pid: Int, + val timestamp: String, + val workingDirectory: String? = null, + val worktreeRoot: String? = null, + val repoName: String? = null, + val gitBranch: String? = null, + val agentName: String? = null, +) { + val displayAgentLabel: String? + get() = agentName + ?.trim() + ?.takeIf { it.isNotBlank() } + ?.let(::normalizeAgentLabel) + + val displayContextLabel: String? + get() = buildList { + repoName?.trim()?.takeIf { it.isNotBlank() }?.let(::add) + gitBranch?.trim()?.takeIf { it.isNotBlank() }?.let(::add) + + if (gitBranch.isNullOrBlank()) { + worktreeRoot?.trim()?.takeIf { it.isNotBlank() }?.let(::compactPathLabel)?.let(::add) + } + if (isEmpty()) { + workingDirectory?.trim()?.takeIf { it.isNotBlank() }?.let(::compactPathLabel)?.let(::add) + } + } + .distinct() + .joinToString(" · ") + .takeIf { it.isNotBlank() } +} + +data class QueueMetricsSnapshot( + val latestUsageByPid: Map, +) { + companion object { + val EMPTY = QueueMetricsSnapshot(latestUsageByPid = emptyMap()) + } +} + +object TaskQueueMetrics { + fun loadSnapshot(dataDir: Path): QueueMetricsSnapshot { + val metricsPath = dataDir.resolve("agent-task-queue-logs.json") + if (!metricsPath.toFile().exists()) { + return QueueMetricsSnapshot.EMPTY + } + + return runCatching { + parseMetricsSnapshot(metricsPath.toFile().readText()) + }.getOrElse { + QueueMetricsSnapshot.EMPTY + } + } +} + +internal fun parseMetricsSnapshot(output: String): QueueMetricsSnapshot { + val latestUsageByPid = linkedMapOf() + + output.lineSequence() + .map(String::trim) + .filter(String::isNotBlank) + .forEach { line -> + val entry = runCatching { Json.parseToJsonElement(line).jsonObject }.getOrNull() ?: return@forEach + val pid = entry["pid"]?.jsonPrimitive?.content?.toIntOrNull() ?: return@forEach + val timestamp = entry["timestamp"]?.jsonPrimitive?.contentOrNull ?: return@forEach + + val usage = HistoricalTaskUsage( + pid = pid, + timestamp = timestamp, + workingDirectory = entry["working_directory"]?.jsonPrimitive?.contentOrNull, + worktreeRoot = entry["worktree_root"]?.jsonPrimitive?.contentOrNull, + repoName = entry["repo_name"]?.jsonPrimitive?.contentOrNull, + gitBranch = entry["git_branch"]?.jsonPrimitive?.contentOrNull, + agentName = entry["agent_name"]?.jsonPrimitive?.contentOrNull, + ) + + if (usage.displayAgentLabel == null && usage.displayContextLabel == null) { + return@forEach + } + + val existing = latestUsageByPid[pid] + if (existing == null || usage.timestamp > existing.timestamp) { + latestUsageByPid[pid] = usage + } + } + + return QueueMetricsSnapshot(latestUsageByPid = latestUsageByPid) +} diff --git a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/QueueSnapshot.kt b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/QueueSnapshot.kt index 75c9faf..5c62ab6 100644 --- a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/QueueSnapshot.kt +++ b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/QueueSnapshot.kt @@ -1,6 +1,7 @@ package com.block.agenttaskqueue.sidecar import java.nio.file.Path +import java.nio.file.Paths import java.time.Duration import java.time.Instant import java.time.LocalDateTime @@ -16,10 +17,42 @@ data class QueueTask( val childPid: Int?, val createdAt: String?, val updatedAt: String?, + val workingDirectory: String? = null, + val worktreeRoot: String? = null, + val repoName: String? = null, + val gitBranch: String? = null, + val agentName: String? = null, ) { val displayCommand: String get() = (command ?: "unknown").replace(Regex("^(\\w+=\\S+\\s+)+"), "") + val displayAgentLabel: String? + get() = agentName + ?.trim() + ?.takeIf { it.isNotBlank() } + ?.let(::normalizeAgentLabel) + + val displayContextLabel: String? + get() = buildList { + repoName?.trim()?.takeIf { it.isNotBlank() }?.let(::add) + gitBranch?.trim()?.takeIf { it.isNotBlank() }?.let(::add) + + if (gitBranch.isNullOrBlank()) { + worktreeRoot?.trim()?.takeIf { it.isNotBlank() }?.let(::compactPathLabel)?.let(::add) + } + if (isEmpty()) { + workingDirectory?.trim()?.takeIf { it.isNotBlank() }?.let(::compactPathLabel)?.let(::add) + } + } + .distinct() + .joinToString(" · ") + .takeIf { it.isNotBlank() } + + val displayIdentityLabel: String? + get() = listOfNotNull(displayAgentLabel, displayContextLabel) + .joinToString(" · ") + .takeIf { it.isNotBlank() } + fun statusAge(now: Instant = Instant.now()): String { val reference = when (status.lowercase()) { "running" -> parseQueueInstant(updatedAt, ZoneId.systemDefault()) ?: parseQueueInstant(createdAt) @@ -50,9 +83,15 @@ data class QueueSummary( data class QueueLane( val queueName: String, val tasks: List, + val configuredScope: ConfiguredQueueScope? = null, ) { val runningCount: Int = tasks.count { it.status.equals("running", ignoreCase = true) } val waitingCount: Int = tasks.count { it.status.equals("waiting", ignoreCase = true) } + val configuredCapacity: Int? = configuredScope?.capacity + val hasCapacityConflict: Boolean = configuredScope?.hasConflict == true + val exactCapacity: Int = configuredCapacity ?: 1 + val emulatorPort: String? = extractEmulatorPort(queueName.substringAfterLast('/')) + val isEmulatorLike: Boolean = emulatorPort != null } data class ScopeGroup( @@ -64,28 +103,97 @@ data class ScopeGroup( val waitingCount: Int = lanes.sumOf { it.waitingCount } } +data class ConfiguredScopeUsage( + val configuredScope: ConfiguredQueueScope, + val runningCount: Int, + val waitingCount: Int, + val descendantQueueCount: Int, + val sourceServerLabels: List = emptyList(), +) { + val scopeName: String = configuredScope.scopeName + val capacity: Int? = configuredScope.capacity + val displayCapacityLabel: String = configuredScope.displayCapacityLabel + val usedSlots: Int = capacity?.let { runningCount.coerceAtMost(it) } ?: runningCount +} + +data class ServerIdentity( + val primaryLabel: String, + val contextLabel: String? = null, + val launchContextLabel: String? = null, + val detailLabel: String? = null, +) { + val displayLabel: String = listOfNotNull(primaryLabel, contextLabel) + .joinToString(" · ") + .takeIf { it.isNotBlank() } + ?: primaryLabel +} + +data class EmulatorAlignment( + val configuredQueues: List, + val connectedDevices: List, + val matchedPorts: Set, +) { + val unmatchedConfiguredQueues: List = configuredQueues.filter { lane -> + lane.emulatorPort == null || lane.emulatorPort !in matchedPorts + } + val unmatchedDevices: List = connectedDevices.filter { device -> + device.emulatorPort == null || device.emulatorPort !in matchedPorts + } +} + data class QueueSnapshot( val dataDir: Path, val tasks: List, val refreshedAt: Instant, + val configuration: QueueConfigurationSnapshot = QueueConfigurationSnapshot.EMPTY, + val adb: AdbSnapshot = AdbSnapshot.EMPTY, + val metrics: QueueMetricsSnapshot = QueueMetricsSnapshot.EMPTY, val statusMessage: String? = null, val errorMessage: String? = null, ) { val summary: QueueSummary = QueueSummary.fromTasks(tasks) val runningTasks: List = tasks.filter { it.status.equals("running", ignoreCase = true) } val waitingTasks: List = tasks.filter { it.status.equals("waiting", ignoreCase = true) } - val queueLanes: List = tasks - .groupBy { it.queueName } - .toSortedMap() - .map { (queueName, queuedTasks) -> QueueLane(queueName, queuedTasks.sortedBy { it.id }) } + val queueLanes: List = buildQueueLanes(tasks, configuration) + private val tasksByServerPid: Map> = tasks + .mapNotNull { task -> task.pid?.let { pid -> pid to task } } + .groupBy(keySelector = { it.first }, valueTransform = { it.second }) + val serverIdentityByPid: Map = configuration.serverProcesses.associate { process -> + process.pid to buildServerIdentity( + process = process, + tasks = tasksByServerPid[process.pid].orEmpty(), + historicalUsage = metrics.latestUsageByPid[process.pid], + ) + } val scopeGroups: List = queueLanes .groupBy { rootScope(it.queueName) } .toSortedMap() .map { (scopeName, lanes) -> ScopeGroup(scopeName, lanes) } + val configuredScopeUsage: List = configuration.configuredScopes + .map { configuredScope -> + ConfiguredScopeUsage( + configuredScope = configuredScope, + runningCount = tasks.count { task -> + task.status.equals("running", ignoreCase = true) && inScope(task.queueName, configuredScope.scopeName) + }, + waitingCount = tasks.count { task -> + task.status.equals("waiting", ignoreCase = true) && inScope(task.queueName, configuredScope.scopeName) + }, + descendantQueueCount = queueLanes.count { lane -> inScope(lane.queueName, configuredScope.scopeName) }, + sourceServerLabels = configuredScope.sourcePids.map { sourcePid -> + serverIdentityByPid[sourcePid]?.displayLabel ?: "pid $sourcePid" + }, + ) + } + .sortedBy { it.scopeName } + val emulatorAlignment: EmulatorAlignment = buildEmulatorAlignment(queueLanes, adb) companion object { fun empty( dataDir: Path, + configuration: QueueConfigurationSnapshot = QueueConfigurationSnapshot.EMPTY, + adb: AdbSnapshot = AdbSnapshot.EMPTY, + metrics: QueueMetricsSnapshot = QueueMetricsSnapshot.EMPTY, statusMessage: String? = null, errorMessage: String? = null, ): QueueSnapshot { @@ -93,6 +201,9 @@ data class QueueSnapshot( dataDir = dataDir, tasks = emptyList(), refreshedAt = Instant.now(), + configuration = configuration, + adb = adb, + metrics = metrics, statusMessage = statusMessage, errorMessage = errorMessage, ) @@ -101,12 +212,18 @@ data class QueueSnapshot( fun fromTasks( dataDir: Path, tasks: List, + configuration: QueueConfigurationSnapshot = QueueConfigurationSnapshot.EMPTY, + adb: AdbSnapshot = AdbSnapshot.EMPTY, + metrics: QueueMetricsSnapshot = QueueMetricsSnapshot.EMPTY, statusMessage: String? = null, ): QueueSnapshot { return QueueSnapshot( dataDir = dataDir, tasks = tasks.sortedWith(compareBy({ it.queueName }, { it.id })), refreshedAt = Instant.now(), + configuration = configuration, + adb = adb, + metrics = metrics, statusMessage = statusMessage, ) } @@ -129,8 +246,91 @@ fun formatRefreshTime(instant: Instant): String { return localTime.truncatedTo(java.time.temporal.ChronoUnit.SECONDS).toString() } +internal fun normalizeAgentLabel(raw: String): String { + return when (raw.trim().lowercase()) { + "amp" -> "Amp" + "claude" -> "Claude" + "codex" -> "Codex" + "cursor" -> "Cursor" + else -> raw.trim() + } +} + +internal fun compactPathLabel(raw: String): String { + return runCatching { + Paths.get(raw).normalize().fileName?.toString() ?: raw + }.getOrElse { raw } +} + private fun rootScope(queueName: String): String = queueName.substringBefore('/') +private fun buildServerIdentity( + process: QueueServerProcess, + tasks: List, + historicalUsage: HistoricalTaskUsage?, +): ServerIdentity { + val agentLabels = tasks.mapNotNull { it.displayAgentLabel }.distinct() + val contextLabels = tasks.mapNotNull { it.displayContextLabel }.distinct() + val hasVisibleUsage = tasks.isNotEmpty() + return ServerIdentity( + primaryLabel = summarizeIdentityLabels(agentLabels) + ?: historicalUsage?.displayAgentLabel + ?: process.agentLabel, + contextLabel = summarizeIdentityLabels(contextLabels) + ?: historicalUsage?.displayContextLabel, + launchContextLabel = process.contextLabel, + detailLabel = if (hasVisibleUsage) { + "${tasks.size} visible task${if (tasks.size == 1) "" else "s"}" + } else { + "idle server" + }, + ) +} + +private fun summarizeIdentityLabels(labels: List): String? { + return when { + labels.isEmpty() -> null + labels.size <= 2 -> labels.joinToString(" + ") + else -> "${labels.first()} +${labels.size - 1} more" + } +} + +private fun buildQueueLanes( + tasks: List, + configuration: QueueConfigurationSnapshot, +): List { + val tasksByQueue = tasks.groupBy { it.queueName } + val configuredScopes = configuration.configuredScopes.associateBy { it.scopeName } + return (tasksByQueue.keys + configuredScopes.keys) + .toSortedSet() + .map { queueName -> + QueueLane( + queueName = queueName, + tasks = tasksByQueue[queueName].orEmpty().sortedBy { it.id }, + configuredScope = configuredScopes[queueName], + ) + } +} + +private fun buildEmulatorAlignment( + queueLanes: List, + adb: AdbSnapshot, +): EmulatorAlignment { + val configuredQueues = queueLanes.filter { it.configuredScope?.isEmulatorLike == true } + val connectedDevices = adb.devices.filter { it.isConnected && it.isEmulator } + val matchedPorts = configuredQueues.mapNotNull { it.emulatorPort }.toSet() + .intersect(connectedDevices.mapNotNull { it.emulatorPort }.toSet()) + return EmulatorAlignment( + configuredQueues = configuredQueues, + connectedDevices = connectedDevices, + matchedPorts = matchedPorts, + ) +} + +private fun inScope(queueName: String, scopeName: String): Boolean { + return queueName == scopeName || queueName.startsWith("$scopeName/") +} + private fun relativeDuration(now: Instant, then: Instant): String { val elapsed = Duration.between(then, now).seconds.coerceAtLeast(0) return when { diff --git a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/TaskQueueDatabase.kt b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/TaskQueueDatabase.kt index b5e475c..4f9e273 100644 --- a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/TaskQueueDatabase.kt +++ b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/TaskQueueDatabase.kt @@ -10,10 +10,16 @@ object TaskQueueDatabase { } fun loadSnapshot(dataDir: Path): QueueSnapshot { + val configuration = TaskQueueProcessInspector.loadConfiguration(dataDir) + val adb = AdbInspector.loadSnapshot() + val metrics = TaskQueueMetrics.loadSnapshot(dataDir) val dbPath = dataDir.resolve("queue.db") if (!dbPath.toFile().exists()) { return QueueSnapshot.empty( dataDir = dataDir, + configuration = configuration, + adb = adb, + metrics = metrics, statusMessage = "Waiting for queue database at $dbPath", ) } @@ -27,6 +33,7 @@ object TaskQueueDatabase { connection.createStatement().use { statement -> statement.executeQuery("SELECT * FROM queue ORDER BY queue_name, id").use { rs -> + val availableColumns = rs.columnNames() val tasks = mutableListOf() while (rs.next()) { tasks += QueueTask( @@ -38,12 +45,20 @@ object TaskQueueDatabase { childPid = rs.getNullableInt("child_pid"), createdAt = rs.getString("created_at"), updatedAt = rs.getString("updated_at"), + workingDirectory = rs.getOptionalString(availableColumns, "working_directory"), + worktreeRoot = rs.getOptionalString(availableColumns, "worktree_root"), + repoName = rs.getOptionalString(availableColumns, "repo_name"), + gitBranch = rs.getOptionalString(availableColumns, "git_branch"), + agentName = rs.getOptionalString(availableColumns, "agent_name"), ) } QueueSnapshot.fromTasks( dataDir = dataDir, tasks = tasks, + configuration = configuration, + adb = adb, + metrics = metrics, statusMessage = if (tasks.isEmpty()) "Queue is empty" else null, ) } @@ -52,6 +67,9 @@ object TaskQueueDatabase { }.getOrElse { error -> QueueSnapshot.empty( dataDir = dataDir, + configuration = configuration, + adb = adb, + metrics = metrics, errorMessage = error.message ?: "Failed to read $dbPath", ) } @@ -67,3 +85,21 @@ private fun ResultSet.getNullableInt(columnName: String): Int? { else -> value.toString().toIntOrNull() } } + +private fun ResultSet.columnNames(): Set { + val metadata = metaData + return (1..metadata.columnCount) + .map { index -> metadata.getColumnLabel(index).lowercase() } + .toSet() +} + +private fun ResultSet.getOptionalString( + availableColumns: Set, + columnName: String, +): String? { + if (columnName.lowercase() !in availableColumns) { + return null + } + + return getString(columnName)?.takeIf { it.isNotBlank() } +} diff --git a/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshotTest.kt b/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshotTest.kt new file mode 100644 index 0000000..8fb0668 --- /dev/null +++ b/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshotTest.kt @@ -0,0 +1,70 @@ +package com.block.agenttaskqueue.sidecar + +import java.nio.file.Paths +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class EnvironmentSnapshotTest { + @Test + fun parsesLiveTaskQueueServerCapacitiesFromPsOutput() { + val processes = parseTaskQueueProcesses( + """ + 73781 /Users/me/.venv/bin/python3 task_queue.py --data-dir /tmp/agent-task-queue --queue-capacity=gradle=2 --queue-capacity=gradle/emulator-5554=1 + 73782 uv run --directory /repo python task_queue.py --queue-capacity=gradle=2 + 73783 /Users/me/.venv/bin/python3 task_queue.py --data-dir=/tmp/other-queue --queue-capacity=web=3 + """.trimIndent() + ) + + assertEquals(2, processes.size) + assertEquals(Paths.get("/tmp/agent-task-queue"), processes.first().dataDir) + assertEquals(2, processes.first().queueCapacities["gradle"]) + assertEquals(1, processes.first().queueCapacities["gradle/emulator-5554"]) + assertEquals(Paths.get("/tmp/other-queue"), processes.last().dataDir) + } + + @Test + fun parsesAdbDevicesAndMatchesEmulatorPorts() { + val adb = parseAdbSnapshot( + """ + List of devices attached + emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1 + 127.0.0.1:5557 device transport_id:2 + R58N12345AB unauthorized transport_id:3 + """.trimIndent() + ) + + assertEquals(3, adb.devices.size) + assertEquals(2, adb.connectedDevices) + assertEquals(2, adb.connectedEmulators) + val devicesBySerial = adb.devices.associateBy { it.serial } + assertEquals("5554", devicesBySerial.getValue("emulator-5554").emulatorPort) + assertTrue(devicesBySerial.getValue("127.0.0.1:5557").isEmulator) + assertNull(devicesBySerial.getValue("R58N12345AB").emulatorPort) + } + + @Test + fun extractsEmulatorPortFromQueueAndAdbLabels() { + assertEquals("5554", extractEmulatorPort("emulator-5554")) + assertEquals("5557", extractEmulatorPort("emu-5557")) + assertEquals("5559", extractEmulatorPort("127.0.0.1:5559")) + assertNull(extractEmulatorPort("pixel-9-pro")) + } + + @Test + fun infersAgentAndContextFromServerParentProcesses() { + val process = parseTaskQueueProcesses( + """ + 900 1 amp -m deep + 901 900 uv run --directory /Users/me/Development/agent-task-queue-worktrees/queue-visibility python task_queue.py --data-dir /tmp/agent-task-queue --queue-capacity=gradle=2 + 902 901 /Users/me/.venv/bin/python3 task_queue.py --data-dir /tmp/agent-task-queue --queue-capacity=gradle=2 + """.trimIndent() + ).single() + + assertEquals(902, process.pid) + assertEquals(901, process.parentPid) + assertEquals("Amp deep", process.agentLabel) + assertEquals("queue-visibility", process.contextLabel) + } +} diff --git a/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/MetricsSnapshotTest.kt b/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/MetricsSnapshotTest.kt new file mode 100644 index 0000000..4d7e2b7 --- /dev/null +++ b/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/MetricsSnapshotTest.kt @@ -0,0 +1,25 @@ +package com.block.agenttaskqueue.sidecar + +import kotlin.test.Test +import kotlin.test.assertEquals + +class MetricsSnapshotTest { + @Test + fun parseMetricsSnapshotKeepsLatestUsagePerPid() { + val snapshot = parseMetricsSnapshot( + """ + {"event":"task_queued","timestamp":"2026-04-24T11:00:01.389560","task_id":74,"queue_name":"global","pid":88226,"agent_name":"amp","repo_name":"agent-task-queue","git_branch":"sedwards/no-ticket/queue-visibility"} + {"event":"task_completed","timestamp":"2026-04-24T11:10:13.561185","task_id":77,"queue_name":"global","pid":88226,"agent_name":"amp","repo_name":"android-register","git_branch":"sedwards/no-ticket/real-work"} + {"event":"task_completed","timestamp":"2026-04-24T11:10:33.478135","task_id":78,"queue_name":"gradle","pid":74067,"agent_name":"claude","repo_name":"cash-android","git_branch":"feature/payments"} + """.trimIndent() + ) + + assertEquals(2, snapshot.latestUsageByPid.size) + assertEquals( + "android-register · sedwards/no-ticket/real-work", + snapshot.latestUsageByPid.getValue(88226).displayContextLabel, + ) + assertEquals("Amp", snapshot.latestUsageByPid.getValue(88226).displayAgentLabel) + assertEquals("Claude", snapshot.latestUsageByPid.getValue(74067).displayAgentLabel) + } +} diff --git a/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/QueueSnapshotTest.kt b/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/QueueSnapshotTest.kt index 66a62fa..e7cff30 100644 --- a/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/QueueSnapshotTest.kt +++ b/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/QueueSnapshotTest.kt @@ -1,9 +1,11 @@ package com.block.agenttaskqueue.sidecar import java.time.Instant +import java.nio.file.Paths import java.util.TimeZone import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue class QueueSnapshotTest { @Test @@ -42,6 +44,161 @@ class QueueSnapshotTest { } } + @Test + fun configuredQueuesStayVisibleWhenIdle() { + val snapshot = QueueSnapshot.fromTasks( + dataDir = Paths.get("/tmp/agent-task-queue"), + tasks = emptyList(), + configuration = QueueConfigurationSnapshot( + serverProcesses = emptyList(), + configuredScopes = listOf( + ConfiguredQueueScope( + scopeName = "gradle", + capacities = setOf(2), + sourcePids = listOf(1234), + ), + ConfiguredQueueScope( + scopeName = "gradle/emulator-5554", + capacities = setOf(1), + sourcePids = listOf(1234), + ), + ), + ), + ) + + assertEquals(listOf("gradle", "gradle/emulator-5554"), snapshot.queueLanes.map { it.queueName }) + assertEquals(1, snapshot.scopeGroups.size) + assertEquals(listOf(2, 1), snapshot.configuredScopeUsage.mapNotNull { it.capacity }) + assertTrue(snapshot.queueLanes.all { it.tasks.isEmpty() }) + } + + @Test + fun taskIdentityPrefersAgentRepoAndBranchMetadata() { + val task = QueueTask( + id = 7, + queueName = "gradle/emulator-5554", + status = "running", + command = "./gradlew connectedDebugAndroidTest", + pid = 902, + childPid = 8112, + createdAt = null, + updatedAt = null, + workingDirectory = "/Users/me/Development/agent-task-queue", + worktreeRoot = "/Users/me/Development/agent-task-queue-worktrees/queue-visibility", + repoName = "agent-task-queue", + gitBranch = "sedwards/no-ticket/queue-visibility", + agentName = "amp", + ) + + assertEquals("Amp", task.displayAgentLabel) + assertEquals("agent-task-queue · sedwards/no-ticket/queue-visibility", task.displayContextLabel) + assertEquals("Amp · agent-task-queue · sedwards/no-ticket/queue-visibility", task.displayIdentityLabel) + } + + @Test + fun serverIdentityUsesVisibleTaskContextInsteadOfLaunchBranch() { + val snapshot = QueueSnapshot.fromTasks( + dataDir = Paths.get("/tmp/agent-task-queue"), + tasks = listOf( + QueueTask( + id = 7, + queueName = "gradle", + status = "running", + command = "./gradlew test", + pid = 902, + childPid = null, + createdAt = null, + updatedAt = null, + repoName = "android-register", + gitBranch = "sedwards/no-ticket/real-work", + agentName = "amp", + ) + ), + configuration = QueueConfigurationSnapshot( + serverProcesses = listOf( + QueueServerProcess( + pid = 902, + commandLine = "python task_queue.py", + dataDir = Paths.get("/tmp/agent-task-queue"), + queueCapacities = emptyMap(), + agentLabel = "Amp deep", + contextLabel = "desktop-sidecar", + ) + ), + configuredScopes = emptyList(), + ), + ) + + val identity = snapshot.serverIdentityByPid.getValue(902) + assertEquals("Amp", identity.primaryLabel) + assertEquals("android-register · sedwards/no-ticket/real-work", identity.contextLabel) + assertEquals("desktop-sidecar", identity.launchContextLabel) + } + + @Test + fun idleServerDoesNotPretendLaunchBranchIsQueueUsage() { + val snapshot = QueueSnapshot.fromTasks( + dataDir = Paths.get("/tmp/agent-task-queue"), + tasks = emptyList(), + configuration = QueueConfigurationSnapshot( + serverProcesses = listOf( + QueueServerProcess( + pid = 902, + commandLine = "python task_queue.py", + dataDir = Paths.get("/tmp/agent-task-queue"), + queueCapacities = emptyMap(), + agentLabel = "Amp deep", + contextLabel = "desktop-sidecar", + ) + ), + configuredScopes = emptyList(), + ), + ) + + val identity = snapshot.serverIdentityByPid.getValue(902) + assertEquals("Amp deep", identity.displayLabel) + assertEquals(null, identity.contextLabel) + assertEquals("desktop-sidecar", identity.launchContextLabel) + assertEquals("idle server", identity.detailLabel) + } + + @Test + fun idleServerFallsBackToHistoricalMetricsUsage() { + val snapshot = QueueSnapshot.fromTasks( + dataDir = Paths.get("/tmp/agent-task-queue"), + tasks = emptyList(), + configuration = QueueConfigurationSnapshot( + serverProcesses = listOf( + QueueServerProcess( + pid = 902, + commandLine = "python task_queue.py", + dataDir = Paths.get("/tmp/agent-task-queue"), + queueCapacities = emptyMap(), + agentLabel = "Amp deep", + contextLabel = "desktop-sidecar", + ) + ), + configuredScopes = emptyList(), + ), + metrics = QueueMetricsSnapshot( + latestUsageByPid = mapOf( + 902 to HistoricalTaskUsage( + pid = 902, + timestamp = "2026-04-24T11:10:13.561185", + repoName = "android-register", + gitBranch = "sedwards/no-ticket/real-work", + agentName = "amp", + ) + ) + ), + ) + + val identity = snapshot.serverIdentityByPid.getValue(902) + assertEquals("Amp · android-register · sedwards/no-ticket/real-work", identity.displayLabel) + assertEquals("desktop-sidecar", identity.launchContextLabel) + assertEquals("idle server", identity.detailLabel) + } + private fun withDefaultTimeZone(timeZoneId: String, block: () -> Unit) { val original = TimeZone.getDefault() try { diff --git a/queue_core.py b/queue_core.py index f671903..90305cd 100644 --- a/queue_core.py +++ b/queue_core.py @@ -11,6 +11,7 @@ import signal import shlex import sqlite3 +import subprocess from contextlib import contextmanager from dataclasses import dataclass from datetime import datetime, timedelta @@ -44,6 +45,17 @@ def from_data_dir(cls, data_dir: Path) -> "QueuePaths": ) +@dataclass(frozen=True) +class TaskOrigin: + """Optional metadata about where a queued task came from.""" + + working_directory: str + worktree_root: str | None = None + repo_name: str | None = None + git_branch: str | None = None + agent_name: str | None = None + + # --- Database Schema --- QUEUE_SCHEMA = """ CREATE TABLE IF NOT EXISTS queue ( @@ -54,6 +66,11 @@ def from_data_dir(cls, data_dir: Path) -> "QueuePaths": server_id TEXT, child_pid INTEGER, command TEXT, + working_directory TEXT, + worktree_root TEXT, + repo_name TEXT, + git_branch TEXT, + agent_name TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) @@ -69,6 +86,26 @@ def from_data_dir(cls, data_dir: Path) -> "QueuePaths": ALTER TABLE queue ADD COLUMN command TEXT """ +QUEUE_MIGRATION_WORKING_DIRECTORY = """ +ALTER TABLE queue ADD COLUMN working_directory TEXT +""" + +QUEUE_MIGRATION_WORKTREE_ROOT = """ +ALTER TABLE queue ADD COLUMN worktree_root TEXT +""" + +QUEUE_MIGRATION_REPO_NAME = """ +ALTER TABLE queue ADD COLUMN repo_name TEXT +""" + +QUEUE_MIGRATION_GIT_BRANCH = """ +ALTER TABLE queue ADD COLUMN git_branch TEXT +""" + +QUEUE_MIGRATION_AGENT_NAME = """ +ALTER TABLE queue ADD COLUMN agent_name TEXT +""" + QUEUE_INDEX = """ CREATE INDEX IF NOT EXISTS idx_queue_status ON queue(queue_name, status) """ @@ -99,13 +136,70 @@ def init_db(paths: QueuePaths): conn.execute(QUEUE_SCHEMA) conn.execute(QUEUE_INDEX) # Run migrations for existing databases - for migration in [QUEUE_MIGRATION_SERVER_ID, QUEUE_MIGRATION_COMMAND]: + for migration in [ + QUEUE_MIGRATION_SERVER_ID, + QUEUE_MIGRATION_COMMAND, + QUEUE_MIGRATION_WORKING_DIRECTORY, + QUEUE_MIGRATION_WORKTREE_ROOT, + QUEUE_MIGRATION_REPO_NAME, + QUEUE_MIGRATION_GIT_BRANCH, + QUEUE_MIGRATION_AGENT_NAME, + ]: try: conn.execute(migration) except sqlite3.OperationalError: pass # Column already exists +def collect_task_origin(working_directory: str, agent_name: str | None = None) -> TaskOrigin: + """Capture stable repo/worktree metadata for a queued task.""" + resolved_working_directory = str(Path(working_directory).resolve()) + worktree_root = _git_output(resolved_working_directory, "rev-parse", "--show-toplevel") + repo_name = _git_repo_name(resolved_working_directory) + git_branch = _git_output(resolved_working_directory, "branch", "--show-current") + + if not git_branch: + git_branch = _git_output(resolved_working_directory, "rev-parse", "--short", "HEAD") + + return TaskOrigin( + working_directory=resolved_working_directory, + worktree_root=worktree_root, + repo_name=repo_name, + git_branch=git_branch, + agent_name=agent_name or None, + ) + + +def _git_repo_name(working_directory: str) -> str | None: + remote = _git_output(working_directory, "remote", "get-url", "origin") + if remote: + return remote.rstrip("/").rsplit("/", 1)[-1].removesuffix(".git") + + worktree_root = _git_output(working_directory, "rev-parse", "--show-toplevel") + if worktree_root: + return Path(worktree_root).name + + return None + + +def _git_output(working_directory: str, *args: str) -> str | None: + try: + result = subprocess.run( + ["git", "-C", working_directory, *args], + capture_output=True, + text=True, + timeout=5, + ) + except (OSError, subprocess.SubprocessError): + return None + + if result.returncode != 0: + return None + + value = result.stdout.strip() + return value or None + + def normalize_queue_name(queue_name: str) -> str: """Collapse redundant separators and whitespace in queue names.""" parts = [part.strip() for part in queue_name.split(QUEUE_SCOPE_SEPARATOR) if part.strip()] diff --git a/task_queue.py b/task_queue.py index e44076a..b3cf4d8 100644 --- a/task_queue.py +++ b/task_queue.py @@ -28,11 +28,13 @@ # Import shared queue infrastructure from queue_core import ( QueuePaths, + TaskOrigin, get_db as _get_db, init_db as _init_db, ensure_db as _ensure_db, cleanup_queue as _cleanup_queue, cleanup_targets_for_queue, + collect_task_origin, log_metric as _log_metric, log_fmt, is_process_alive, @@ -156,6 +158,23 @@ def log_metric(event: str, **kwargs): _log_metric(PATHS.metrics_path, event, MAX_METRICS_SIZE_MB, **kwargs) +def task_origin_kwargs(task_origin: TaskOrigin | None) -> dict[str, str]: + if task_origin is None: + return {} + + return { + key: value + for key, value in { + "working_directory": task_origin.working_directory, + "worktree_root": task_origin.worktree_root, + "repo_name": task_origin.repo_name, + "git_branch": task_origin.git_branch, + "agent_name": task_origin.agent_name, + }.items() + if value + } + + def cleanup_queue(conn, queue_name: str, queue_capacities: dict[str, int] | None = None): """Clean up queue using configured paths and detect orphaned tasks.""" if queue_capacities is None: @@ -269,7 +288,11 @@ def get_memory_mb() -> float: # --- Core Queue Logic --- -async def wait_for_turn(queue_name: str, command: str | None = None) -> int: +async def wait_for_turn( + queue_name: str, + command: str | None = None, + task_origin: TaskOrigin | None = None, +) -> int: """Register task, wait for turn, return task ID when acquired.""" queue_name = normalize_queue_name(queue_name) @@ -290,8 +313,22 @@ async def wait_for_turn(queue_name: str, command: str | None = None) -> int: with get_db() as conn: cursor = conn.execute( - "INSERT INTO queue (queue_name, status, pid, server_id, command) VALUES (?, ?, ?, ?, ?)", - (queue_name, "waiting", my_pid, SERVER_INSTANCE_ID, command), + """INSERT INTO queue ( + queue_name, status, pid, server_id, command, + working_directory, worktree_root, repo_name, git_branch, agent_name + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + queue_name, + "waiting", + my_pid, + SERVER_INSTANCE_ID, + command, + task_origin.working_directory if task_origin else None, + task_origin.worktree_root if task_origin else None, + task_origin.repo_name if task_origin else None, + task_origin.git_branch if task_origin else None, + task_origin.agent_name if task_origin else None, + ), ) task_id = cursor.lastrowid @@ -299,7 +336,13 @@ async def wait_for_turn(queue_name: str, command: str | None = None) -> int: with _active_task_ids_lock: _active_task_ids.add(task_id) - log_metric("task_queued", task_id=task_id, queue_name=queue_name, pid=my_pid) + log_metric( + "task_queued", + task_id=task_id, + queue_name=queue_name, + pid=my_pid, + **task_origin_kwargs(task_origin), + ) queued_at = time.time() if ctx: @@ -330,7 +373,9 @@ async def wait_for_turn(queue_name: str, command: str | None = None) -> int: "task_started", task_id=task_id, queue_name=queue_name, + pid=my_pid, wait_time_seconds=round(wait_time, 2), + **task_origin_kwargs(task_origin), ) if ctx: await ctx.info(log_fmt("Lock ACQUIRED. Starting execution.")) @@ -361,7 +406,9 @@ async def wait_for_turn(queue_name: str, command: str | None = None) -> int: "task_cancelled", task_id=task_id, queue_name=queue_name, + pid=my_pid, reason="client_disconnected", + **task_origin_kwargs(task_origin), ) with get_db() as conn: conn.execute("DELETE FROM queue WHERE id = ?", (task_id,)) @@ -406,6 +453,7 @@ async def run_task( queue_name: str = "global", timeout_seconds: int = 1200, env_vars: str = "", + agent_name: str = "", ): """ Execute a command through the task queue for sequential processing. @@ -458,6 +506,7 @@ async def run_task( timeout_seconds: Max **execution** time before killing the task (default: 1200 = 20 mins). Queue wait time does NOT count against this timeout. env_vars: Environment variables to set, format: "KEY1=value1,KEY2=value2" + agent_name: Optional friendly caller label (for example `amp` or `claude-code`). Returns: Command output including stdout, stderr, and exit code. @@ -481,7 +530,16 @@ async def run_task( key, value = pair.split("=", 1) env[key.strip()] = value.strip() - task_id = await wait_for_turn(queue_name, command) + ctx = None + try: + ctx = get_context() + except LookupError: + pass + + caller_name = agent_name.strip() or (ctx.client_id if ctx and ctx.client_id else None) + task_origin = collect_task_origin(working_directory, caller_name) + + task_id = await wait_for_turn(queue_name, command, task_origin=task_origin) mem_before = get_memory_mb() start = time.time() @@ -580,9 +638,11 @@ async def stream_to_file(stream, tail_buffer: deque, label: str): "task_timeout", task_id=task_id, queue_name=queue_name, + pid=os.getpid(), command=command, timeout_seconds=timeout_seconds, memory_mb=round(get_memory_mb(), 1), + **task_origin_kwargs(task_origin), ) cleanup_output_files() @@ -607,6 +667,7 @@ async def stream_to_file(stream, tail_buffer: deque, label: str): "task_completed", task_id=task_id, queue_name=queue_name, + pid=os.getpid(), command=command, exit_code=proc.returncode, duration_seconds=round(duration, 2), @@ -614,6 +675,7 @@ async def stream_to_file(stream, tail_buffer: deque, label: str): stderr_lines=stderr_count, memory_before_mb=round(mem_before, 1), memory_after_mb=round(mem_after, 1), + **task_origin_kwargs(task_origin), ) cleanup_output_files() @@ -654,8 +716,10 @@ async def stream_to_file(stream, tail_buffer: deque, label: str): "task_cancelled", task_id=task_id, queue_name=queue_name, + pid=os.getpid(), command=command, reason="client_disconnected_during_execution", + **task_origin_kwargs(task_origin), ) try: os.killpg(proc.pid, signal.SIGTERM) @@ -672,8 +736,10 @@ async def stream_to_file(stream, tail_buffer: deque, label: str): "task_error", task_id=task_id, queue_name=queue_name, + pid=os.getpid(), command=command, error=str(e), + **task_origin_kwargs(task_origin), ) return f"ERROR: {str(e)}" diff --git a/tests/test_queue.py b/tests/test_queue.py index d2f4339..4c81c3b 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -5,6 +5,7 @@ import pytest import asyncio +import json import os import subprocess import time @@ -43,6 +44,8 @@ def clean_db(): """Clean database before each test.""" if DB_PATH.exists(): DB_PATH.unlink() + if PATHS.metrics_path.exists(): + PATHS.metrics_path.unlink() # Also remove WAL files if present wal_path = Path(str(DB_PATH) + "-wal") shm_path = Path(str(DB_PATH) + "-shm") @@ -81,6 +84,85 @@ def read_output_file(result_str: str) -> str: return "" +def create_git_repo(tmp_path: Path, name: str) -> Path: + repo_dir = tmp_path / name + repo_dir.mkdir() + subprocess.run(["git", "init", "-b", "main"], cwd=repo_dir, check=True, capture_output=True) + (repo_dir / "README.md").write_text("test repo\n") + subprocess.run(["git", "add", "README.md"], cwd=repo_dir, check=True, capture_output=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Test User", + "-c", + "user.email=test@example.com", + "commit", + "-m", + "init", + ], + cwd=repo_dir, + check=True, + capture_output=True, + ) + return repo_dir + + +@pytest.mark.asyncio +async def test_task_origin_is_persisted_in_queue_and_metrics(client, tmp_path): + repo_dir = create_git_repo(tmp_path, "metadata-repo") + expected_origin = queue_core.collect_task_origin(str(repo_dir), "amp") + + async with client: + result_task = asyncio.create_task( + client.call_tool( + "run_task", + { + "command": "sleep 1", + "working_directory": str(repo_dir), + "queue_name": "metadata_test", + "agent_name": "amp", + }, + ) + ) + + await asyncio.sleep(0.2) + + with get_db() as conn: + row = conn.execute( + """SELECT id, pid, working_directory, worktree_root, repo_name, git_branch, agent_name + FROM queue + WHERE queue_name = ? AND status = 'running'""", + ("metadata_test",), + ).fetchone() + + assert row is not None + assert row["working_directory"] == expected_origin.working_directory + assert row["worktree_root"] == expected_origin.worktree_root + assert row["repo_name"] == expected_origin.repo_name + assert row["git_branch"] == expected_origin.git_branch + assert row["agent_name"] == expected_origin.agent_name + + task_id = row["id"] + pid = row["pid"] + result = await result_task + + assert "SUCCESS" in str(result) + + entries = [json.loads(line) for line in PATHS.metrics_path.read_text().splitlines() if line.strip()] + task_entries = [entry for entry in entries if entry.get("task_id") == task_id] + + assert {entry["event"] for entry in task_entries} >= {"task_queued", "task_started", "task_completed"} + for entry in task_entries: + if entry["event"] in {"task_queued", "task_started", "task_completed"}: + assert entry["pid"] == pid + assert entry["working_directory"] == expected_origin.working_directory + assert entry["worktree_root"] == expected_origin.worktree_root + assert entry["repo_name"] == expected_origin.repo_name + assert entry["git_branch"] == expected_origin.git_branch + assert entry["agent_name"] == expected_origin.agent_name + + @pytest.mark.asyncio async def test_single_task_execution(client): """Test that a single task executes successfully.""" diff --git a/tests/test_tq_cli.py b/tests/test_tq_cli.py index c04f203..31bb869 100644 --- a/tests/test_tq_cli.py +++ b/tests/test_tq_cli.py @@ -3,6 +3,7 @@ Tests the command-line interface for running tasks and inspecting the queue. """ +import argparse import json import os import signal @@ -16,6 +17,7 @@ from typing import TypeVar import pytest +import tq # Path to tq.py TQ_PATH = Path(__file__).parent.parent / "tq.py" @@ -588,6 +590,115 @@ def test_run_help(self): assert "--dir" in result.stdout +class TestAmpRestart: + def test_parse_amp_sessions_filters_to_interactive_invocations(self): + ps_output = """ +86296 amp -m deep PWD=/Users/sedwards/Development/block-invert-config AGENT_SESSION_ID=20260420_6 +88150 amp threads continue T-019dbffa-53be-708c-b468-b62fff98a27d PWD=/Users/sedwards/Development/agent-task-queue +90000 amp threads search --json repo:block/agent-task-queue PWD=/tmp +91000 /Users/sedwards/.amp/bin/amp --mode=smart PWD=/Users/sedwards/Development/agents AGENT_SESSION_ID=20260422_11 + """ + + sessions = tq.parse_amp_sessions_from_ps_output(ps_output) + + assert [(session.pid, session.mode) for session in sessions] == [ + (86296, "deep"), + (88150, None), + (91000, "smart"), + ] + assert sessions[0].cwd == "/Users/sedwards/Development/block-invert-config" + assert sessions[0].agent_session_id == "20260420_6" + assert sessions[1].cwd == "/Users/sedwards/Development/agent-task-queue" + + def test_parse_amp_thread_ids_uses_latest_entry_per_pid(self): + log_text = "\n".join( + [ + json.dumps( + { + "pid": 86296, + "timestamp": "2026-04-24T15:00:00.000Z", + "threadId": "T-019dbffa-53be-708c-b468-b62fff98a27d", + } + ), + json.dumps( + { + "pid": 86296, + "timestamp": "2026-04-24T15:05:00.000Z", + "message": "[switchToExistingThread] Switching to thread: T-019dc029-f25d-767c-8005-e2996169f6f8", + } + ), + json.dumps( + { + "pid": 88150, + "timestamp": "2026-04-24T15:10:00.000Z", + "newThreadID": "T-019dbfd5-6e1d-7548-b824-f87378e25a8e", + } + ), + ] + ) + + assert tq.parse_amp_thread_ids_from_log(log_text, {86296, 88150}) == { + 86296: "T-019dc029-f25d-767c-8005-e2996169f6f8", + 88150: "T-019dbfd5-6e1d-7548-b824-f87378e25a8e", + } + + def test_amp_restart_shell_output_for_targeted_pids(self, monkeypatch, capsys): + monkeypatch.setattr( + tq, + "discover_amp_sessions", + lambda: [ + tq.AmpSession( + pid=86296, + cwd="/Users/sedwards/Development/block-invert-config", + thread_id="T-019dc029-f25d-767c-8005-e2996169f6f8", + agent_session_id="20260420_6", + mode="deep", + ), + tq.AmpSession( + pid=88150, + cwd="/Users/sedwards/Development/agent-task-queue", + thread_id="T-019dbffa-53be-708c-b468-b62fff98a27d", + mode="deep", + ), + ], + ) + + exit_code = tq.cmd_amp_restart( + argparse.Namespace(pid=[86296], json=False, shell=True) + ) + + captured = capsys.readouterr() + assert exit_code == 0 + assert "kill -TERM 86296" in captured.out + assert "amp threads continue T-019dc029-f25d-767c-8005-e2996169f6f8" in captured.out + assert "88150" not in captured.out + + def test_amp_restart_json_fails_for_unresolved_targeted_pid(self, monkeypatch, capsys): + monkeypatch.setattr( + tq, + "discover_amp_sessions", + lambda: [ + tq.AmpSession( + pid=86296, + cwd="/Users/sedwards/Development/block-invert-config", + thread_id=None, + agent_session_id="20260420_6", + mode="deep", + ) + ], + ) + + exit_code = tq.cmd_amp_restart( + argparse.Namespace(pid=[86296], json=True, shell=False) + ) + + captured = capsys.readouterr() + payload = json.loads(captured.out) + assert exit_code == 1 + assert payload["summary"] == {"total": 1, "resolved": 0, "unresolved": 1} + assert payload["sessions"][0]["thread_id"] is None + + class TestQueueIntegration: """Tests for queue behavior with CLI.""" diff --git a/tq.py b/tq.py index caef9d7..5502083 100644 --- a/tq.py +++ b/tq.py @@ -15,12 +15,16 @@ import sys import time import uuid +import re +from dataclasses import dataclass from datetime import datetime from pathlib import Path # Import shared queue infrastructure from queue_core import ( QueuePaths, + TaskOrigin, + collect_task_origin, get_db, init_db, ensure_db, @@ -41,6 +45,52 @@ # Unique identifier for this CLI instance - used to detect orphaned tasks # from previous CLI instances even if the PID is reused CLI_INSTANCE_ID = str(uuid.uuid4())[:8] +AMP_CLI_LOG_PATH = Path.home() / ".cache" / "amp" / "logs" / "cli.log" +AMP_THREAD_ID_PATTERN = re.compile(r"T-[0-9a-f-]{36}") +AMP_ENV_ASSIGNMENT_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") +AMP_GLOBAL_FLAGS_WITH_VALUE = { + "--visibility", + "--settings-file", + "--log-level", + "--log-file", + "--mcp-config", + "-l", + "--label", +} +AMP_GLOBAL_BOOLEAN_FLAGS = { + "--notifications", + "--no-notifications", + "--color", + "--no-color", + "--dangerously-allow-all", + "--jetbrains", + "--no-jetbrains", + "--ide", + "--no-ide", + "--stream-json", + "--stream-json-thinking", + "--stream-json-input", + "--archive", +} + + +@dataclass +class AmpSession: + pid: int + cwd: str | None + thread_id: str | None = None + agent_session_id: str | None = None + mode: str | None = None + + @property + def stop_command(self) -> str: + return f"kill -TERM {self.pid}" + + @property + def continue_command(self) -> str | None: + if not self.cwd or not self.thread_id: + return None + return f"(cd {shlex.quote(self.cwd)} && amp threads continue {self.thread_id})" def get_paths(args) -> QueuePaths: @@ -251,6 +301,260 @@ def cmd_logs(args): print(line) +def _extract_env_value(process_line: str, env_name: str) -> str | None: + match = re.search(rf"(?:^|\s){re.escape(env_name)}=([^\s]+)", process_line) + if not match: + return None + value = match.group(1).strip() + return value or None + + +def _amp_process_prefix_tokens(process_line: str) -> tuple[int, list[str]] | None: + line = process_line.strip() + if not line: + return None + + try: + pid_text, command = line.split(None, 1) + pid = int(pid_text) + except ValueError: + return None + + argv = [] + for token in command.split(): + if AMP_ENV_ASSIGNMENT_PATTERN.match(token): + break + argv.append(token) + + if not argv: + return None + + return pid, argv + + +def _is_interactive_amp_invocation(argv: list[str]) -> tuple[bool, str | None]: + if not argv or Path(argv[0]).name != "amp": + return False, None + + remaining: list[str] = [] + mode: str | None = None + i = 1 + while i < len(argv): + token = argv[i] + if token in {"-x", "--execute"} or token.startswith("--execute="): + return False, mode + if token in {"-m", "--mode"}: + if i + 1 < len(argv): + mode = argv[i + 1] + i += 2 + continue + if token.startswith("--mode="): + mode = token.split("=", 1)[1] or None + i += 1 + continue + if token in AMP_GLOBAL_FLAGS_WITH_VALUE: + i += 2 + continue + if any(token.startswith(flag + "=") for flag in AMP_GLOBAL_FLAGS_WITH_VALUE if flag.startswith("--")): + i += 1 + continue + if token in AMP_GLOBAL_BOOLEAN_FLAGS: + i += 1 + continue + remaining = argv[i:] + break + + interactive = not remaining or ( + len(remaining) >= 2 + and remaining[0] in {"threads", "thread", "t"} + and remaining[1] in {"continue", "c", "new", "n"} + ) + return interactive, mode + + +def parse_amp_sessions_from_ps_output(ps_output: str) -> list[AmpSession]: + """Parse `ps eww` output and return live interactive Amp sessions.""" + sessions: list[AmpSession] = [] + for line in ps_output.splitlines(): + prefix = _amp_process_prefix_tokens(line) + if prefix is None: + continue + + pid, argv = prefix + interactive, mode = _is_interactive_amp_invocation(argv) + if not interactive: + continue + + sessions.append( + AmpSession( + pid=pid, + cwd=_extract_env_value(line, "PWD"), + agent_session_id=_extract_env_value(line, "AGENT_SESSION_ID"), + mode=mode, + ) + ) + + return sessions + + +def parse_amp_thread_ids_from_log( + log_text: str, + candidate_pids: set[int] | None = None, +) -> dict[int, str]: + """Return the latest known Amp thread ID for each PID in the CLI log.""" + latest_thread_by_pid: dict[int, tuple[str, str]] = {} + + for raw_line in log_text.splitlines(): + line = raw_line.strip() + if not line: + continue + + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + + try: + pid = int(entry["pid"]) + except (KeyError, TypeError, ValueError): + continue + + if candidate_pids is not None and pid not in candidate_pids: + continue + + timestamp = entry.get("timestamp") + if not isinstance(timestamp, str) or not timestamp: + continue + + thread_id = None + for key in ("threadId", "threadID", "newThreadID"): + value = entry.get(key) + if isinstance(value, str) and AMP_THREAD_ID_PATTERN.fullmatch(value): + thread_id = value + + if thread_id is None: + message = entry.get("message") + if isinstance(message, str) and "Switching to thread:" in message: + match = AMP_THREAD_ID_PATTERN.search(message) + if match: + thread_id = match.group(0) + + if thread_id is None: + continue + + current = latest_thread_by_pid.get(pid) + if current is None or timestamp >= current[0]: + latest_thread_by_pid[pid] = (timestamp, thread_id) + + return {pid: thread_id for pid, (_, thread_id) in latest_thread_by_pid.items()} + + +def discover_amp_sessions(cli_log_path: Path = AMP_CLI_LOG_PATH) -> list[AmpSession]: + """Discover live interactive Amp sessions and resolve their current thread IDs.""" + result = subprocess.run( + ["ps", "eww", "-axo", "pid=,command="], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode != 0: + raise RuntimeError("Failed to enumerate running processes with ps") + + sessions = parse_amp_sessions_from_ps_output(result.stdout) + if not sessions or not cli_log_path.exists(): + return sessions + + thread_ids = parse_amp_thread_ids_from_log( + cli_log_path.read_text(), + candidate_pids={session.pid for session in sessions}, + ) + for session in sessions: + session.thread_id = thread_ids.get(session.pid) + + return sessions + + +def _amp_session_payload(session: AmpSession) -> dict[str, str | int | None]: + return { + "pid": session.pid, + "mode": session.mode, + "agent_session_id": session.agent_session_id, + "cwd": session.cwd, + "thread_id": session.thread_id, + "stop_command": session.stop_command, + "continue_command": session.continue_command, + } + + +def cmd_amp_restart(args) -> int: + """Resolve live interactive Amp sessions to thread IDs and print restart commands.""" + pid_filter = set(args.pid or []) + + try: + sessions = discover_amp_sessions() + except Exception as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + if pid_filter: + sessions = [session for session in sessions if session.pid in pid_filter] + found_pids = {session.pid for session in sessions} + missing_pids = sorted(pid_filter - found_pids) + if missing_pids: + joined = ", ".join(str(pid) for pid in missing_pids) + print(f"Error: No live interactive Amp session found for PID(s): {joined}", file=sys.stderr) + return 1 + + unresolved = [session for session in sessions if not session.cwd or not session.thread_id] + + if getattr(args, "json", False): + output = { + "sessions": [_amp_session_payload(session) for session in sessions], + "summary": { + "total": len(sessions), + "resolved": len(sessions) - len(unresolved), + "unresolved": len(unresolved), + }, + } + print(json.dumps(output)) + return 1 if pid_filter and unresolved else 0 + + if getattr(args, "shell", False): + for index, session in enumerate(sessions): + if index: + print() + if session.continue_command: + print(f"# PID {session.pid} thread={session.thread_id} cwd={session.cwd}") + print(session.stop_command) + print(session.continue_command) + else: + reason = "missing thread ID" if not session.thread_id else "missing cwd" + print(f"# PID {session.pid} unresolved ({reason})", file=sys.stderr) + return 1 if pid_filter and unresolved else 0 + + if not sessions: + print("No live interactive Amp sessions found") + return 0 + + for session in sessions: + session_label = session.agent_session_id or "-" + mode_label = session.mode or "-" + print(f"PID {session.pid} session={session_label} mode={mode_label}") + print(f" cwd: {session.cwd or '(unresolved)'}") + print(f" thread: {session.thread_id or '(unresolved)'}") + print(f" stop: {session.stop_command}") + print(f" continue: {session.continue_command or '(unresolved)'}") + print() + + if unresolved: + print( + f"Unresolved sessions: {len(unresolved)} (missing cwd or thread ID in {AMP_CLI_LOG_PATH})", + file=sys.stderr, + ) + + return 1 if pid_filter and unresolved else 0 + + # --- Run Command Implementation --- def log_metric(paths: QueuePaths, event: str, **kwargs): @@ -258,6 +562,23 @@ def log_metric(paths: QueuePaths, event: str, **kwargs): _log_metric(paths.metrics_path, event, DEFAULT_MAX_METRICS_SIZE_MB, **kwargs) +def task_origin_kwargs(task_origin: TaskOrigin | None) -> dict[str, str]: + if task_origin is None: + return {} + + return { + key: value + for key, value in { + "working_directory": task_origin.working_directory, + "worktree_root": task_origin.worktree_root, + "repo_name": task_origin.repo_name, + "git_branch": task_origin.git_branch, + "agent_name": task_origin.agent_name, + }.items() + if value + } + + def cleanup_queue( conn, queue_name: str, @@ -297,23 +618,57 @@ def cleanup_queue( conn.commit() -def register_task(conn, queue_name: str, paths: QueuePaths, command: str = None) -> int: +def register_task( + conn, + queue_name: str, + paths: QueuePaths, + command: str = None, + task_origin: TaskOrigin | None = None, +) -> int: """Register a task in the queue. Returns task_id immediately.""" my_pid = os.getpid() cursor = conn.execute( - "INSERT INTO queue (queue_name, status, pid, server_id, command) VALUES (?, ?, ?, ?, ?)", - (queue_name, "waiting", my_pid, CLI_INSTANCE_ID, command), + """INSERT INTO queue ( + queue_name, status, pid, server_id, command, + working_directory, worktree_root, repo_name, git_branch, agent_name + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + queue_name, + "waiting", + my_pid, + CLI_INSTANCE_ID, + command, + task_origin.working_directory if task_origin else None, + task_origin.worktree_root if task_origin else None, + task_origin.repo_name if task_origin else None, + task_origin.git_branch if task_origin else None, + task_origin.agent_name if task_origin else None, + ), ) conn.commit() task_id = cursor.lastrowid - log_metric(paths, "task_queued", task_id=task_id, queue_name=queue_name, pid=my_pid) + log_metric( + paths, + "task_queued", + task_id=task_id, + queue_name=queue_name, + pid=my_pid, + **task_origin_kwargs(task_origin), + ) print(f"[tq] Task #{task_id} queued in '{queue_name}'") return task_id -def wait_for_turn(conn, queue_name: str, task_id: int, paths: QueuePaths, queue_capacities: dict[str, int]) -> None: +def wait_for_turn( + conn, + queue_name: str, + task_id: int, + paths: QueuePaths, + queue_capacities: dict[str, int], + task_origin: TaskOrigin | None = None, +) -> None: """Wait for the task's turn to run. Task must already be registered.""" my_pid = os.getpid() queued_at = time.time() @@ -347,7 +702,9 @@ def wait_for_turn(conn, queue_name: str, task_id: int, paths: QueuePaths, queue_ "task_started", task_id=task_id, queue_name=queue_name, + pid=my_pid, wait_time_seconds=round(wait_time, 2), + **task_origin_kwargs(task_origin), ) if wait_time > 1: print(f"[tq] Lock acquired after {wait_time:.1f}s wait") @@ -385,6 +742,7 @@ def cmd_run(args): paths = get_paths(args) paths.data_dir.mkdir(parents=True, exist_ok=True) + task_origin = collect_task_origin(working_dir) # Ensure database exists and is valid (recover if corrupted) ensure_db(paths) @@ -441,8 +799,8 @@ def cleanup_handler(signum, frame): cleanup_queue(conn, queue_name, paths, queue_capacities) # Register task first so task_id is available for cleanup if interrupted - task_id = register_task(conn, queue_name, paths, command=command) - wait_for_turn(conn, queue_name, task_id, paths, queue_capacities) + task_id = register_task(conn, queue_name, paths, command=command, task_origin=task_origin) + wait_for_turn(conn, queue_name, task_id, paths, queue_capacities, task_origin=task_origin) print(f"[tq] Running: {command}") print(f"[tq] Directory: {working_dir}") @@ -483,8 +841,10 @@ def cleanup_handler(signum, frame): "task_timeout", task_id=task_id, queue_name=queue_name, + pid=os.getpid(), command=command, timeout_seconds=timeout, + **task_origin_kwargs(task_origin), ) return 124 # Standard timeout exit code @@ -502,9 +862,11 @@ def cleanup_handler(signum, frame): "task_completed", task_id=task_id, queue_name=queue_name, + pid=os.getpid(), command=command, exit_code=exit_code, duration_seconds=round(duration, 2), + **task_origin_kwargs(task_origin), ) return exit_code @@ -517,7 +879,9 @@ def cleanup_handler(signum, frame): "task_error", task_id=task_id, queue_name=queue_name, + pid=os.getpid(), error=str(e), + **task_origin_kwargs(task_origin), ) return 1 @@ -576,9 +940,28 @@ def main(): logs_parser.add_argument("-n", type=int, default=20, help="Number of entries (default: 20)") logs_parser.add_argument("--json", action="store_true", help="Output in JSON format") + # amp-restart + amp_restart_parser = subparsers.add_parser( + "amp-restart", + help="Resolve live interactive Amp sessions to thread IDs and print restart commands", + ) + amp_restart_parser.add_argument( + "--pid", + action="append", + type=int, + default=[], + help="Target a specific live Amp PID. Repeatable. Defaults to all live interactive Amp sessions.", + ) + amp_restart_parser.add_argument("--json", action="store_true", help="Output in JSON format") + amp_restart_parser.add_argument( + "--shell", + action="store_true", + help="Print shell commands only (kill + amp threads continue)", + ) + # Handle implicit run: tq ./gradlew build -> tq run ./gradlew build # Pre-process argv to insert 'run' if needed - known_subcommands = {"run", "list", "clear", "logs"} + known_subcommands = {"run", "list", "clear", "logs", "amp-restart"} args_list = sys.argv[1:] # Find the first non-option argument (skip --data-dir and its value) @@ -614,6 +997,8 @@ def main(): cmd_clear(args) elif args.command == "logs": cmd_logs(args) + elif args.command == "amp-restart": + sys.exit(cmd_amp_restart(args)) else: parser.print_help() From d1aeb1359bcea7a4e5628eb12a09baa3ccb401b9 Mon Sep 17 00:00:00 2001 From: Stephen Edwards Date: Fri, 24 Apr 2026 14:33:57 -0400 Subject: [PATCH 02/16] Fix tq amp-restart portability issues Amp-Thread-ID: https://ampcode.com/threads/T-019dc029-f25d-767c-8005-e2996169f6f8 Co-authored-by: Amp --- tests/test_tq_cli.py | 32 ++++++++++++++++++++++++++++++++ tq.py | 34 +++++++++++++++++++++++++--------- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/tests/test_tq_cli.py b/tests/test_tq_cli.py index 31bb869..c249800 100644 --- a/tests/test_tq_cli.py +++ b/tests/test_tq_cli.py @@ -14,6 +14,7 @@ import time from collections.abc import Callable from pathlib import Path +from types import SimpleNamespace from typing import TypeVar import pytest @@ -591,6 +592,15 @@ def test_run_help(self): class TestAmpRestart: + def test_extract_env_value_preserves_paths_with_spaces(self): + process_line = ( + "86296 amp -m deep PWD=/Users/sedwards/Development/Repo With Spaces " + "AGENT_SESSION_ID=20260420_6 SHELL=/bin/zsh" + ) + + assert tq._extract_env_value(process_line, "PWD") == "/Users/sedwards/Development/Repo With Spaces" + assert tq._extract_env_value(process_line, "AGENT_SESSION_ID") == "20260420_6" + def test_parse_amp_sessions_filters_to_interactive_invocations(self): ps_output = """ 86296 amp -m deep PWD=/Users/sedwards/Development/block-invert-config AGENT_SESSION_ID=20260420_6 @@ -610,6 +620,28 @@ def test_parse_amp_sessions_filters_to_interactive_invocations(self): assert sessions[0].agent_session_id == "20260420_6" assert sessions[1].cwd == "/Users/sedwards/Development/agent-task-queue" + def test_discover_amp_sessions_falls_back_to_linux_ps_flags(self, monkeypatch, tmp_path): + cli_log_path = tmp_path / "cli.log" + cli_log_path.write_text("") + calls = [] + + def fake_run(command, capture_output, text, timeout): + calls.append(command) + if command == tq.AMP_PS_COMMAND_CANDIDATES[0]: + return SimpleNamespace(returncode=1, stdout="", stderr="must set personality to get -x option") + return SimpleNamespace( + returncode=0, + stdout="86296 amp -m deep PWD=/tmp AGENT_SESSION_ID=20260420_6\n", + stderr="", + ) + + monkeypatch.setattr(tq.subprocess, "run", fake_run) + + sessions = tq.discover_amp_sessions(cli_log_path=cli_log_path) + + assert [session.pid for session in sessions] == [86296] + assert calls == tq.AMP_PS_COMMAND_CANDIDATES[:2] + def test_parse_amp_thread_ids_uses_latest_entry_per_pid(self): log_text = "\n".join( [ diff --git a/tq.py b/tq.py index 5502083..de6ce67 100644 --- a/tq.py +++ b/tq.py @@ -48,6 +48,11 @@ AMP_CLI_LOG_PATH = Path.home() / ".cache" / "amp" / "logs" / "cli.log" AMP_THREAD_ID_PATTERN = re.compile(r"T-[0-9a-f-]{36}") AMP_ENV_ASSIGNMENT_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") +AMP_ENV_VALUE_PATTERN_TEMPLATE = r"(?:^|\s){name}=(.*?)(?=\s+[A-Za-z_][A-Za-z0-9_]*=|$)" +AMP_PS_COMMAND_CANDIDATES = [ + ["ps", "eww", "-axo", "pid=,command="], + ["ps", "eww", "axo", "pid=,command="], +] AMP_GLOBAL_FLAGS_WITH_VALUE = { "--visibility", "--settings-file", @@ -302,7 +307,8 @@ def cmd_logs(args): def _extract_env_value(process_line: str, env_name: str) -> str | None: - match = re.search(rf"(?:^|\s){re.escape(env_name)}=([^\s]+)", process_line) + pattern = AMP_ENV_VALUE_PATTERN_TEMPLATE.format(name=re.escape(env_name)) + match = re.search(pattern, process_line) if not match: return None value = match.group(1).strip() @@ -451,14 +457,24 @@ def parse_amp_thread_ids_from_log( def discover_amp_sessions(cli_log_path: Path = AMP_CLI_LOG_PATH) -> list[AmpSession]: """Discover live interactive Amp sessions and resolve their current thread IDs.""" - result = subprocess.run( - ["ps", "eww", "-axo", "pid=,command="], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode != 0: - raise RuntimeError("Failed to enumerate running processes with ps") + last_error = "Failed to enumerate running processes with ps" + result = None + for command in AMP_PS_COMMAND_CANDIDATES: + result = subprocess.run( + command, + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + break + stderr = result.stderr.strip() + stdout = result.stdout.strip() + details = stderr or stdout + if details: + last_error = details + else: + raise RuntimeError(last_error) sessions = parse_amp_sessions_from_ps_output(result.stdout) if not sessions or not cli_log_path.exists(): From 2597dcf7fcaf8651e5d8fa86d79e59df67c7d959 Mon Sep 17 00:00:00 2001 From: Stephen Edwards Date: Fri, 24 Apr 2026 16:39:09 -0400 Subject: [PATCH 03/16] Overhaul desktop sidecar UI for at-a-glance queue visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the dashboard into a two-column layout: queue activity on the left, agents + emulators + servers on the right. Adds a hero strip of Running / Waiting / Agents / Emulators stats that shifts to warning / danger tints when queues back up or configured emulator lanes lack devices. Scopes now sort by pressure (waiters first, then utilization) and surface BACKUP / AT CAPACITY chips plus a slot meter in the header. Each lane renders as a timeline: running slots with agent-colored TaskPills (Amp / Claude / Codex / Cursor / Zed / Windsurf), then a divider and the waiting queue as de-emphasized pills with hover tooltips showing full command, agent, branch, pids, and age. The right pane groups live tasks by agent with per-context run/wait breakdowns, pairs each configured emulator lane side-by-side with its matching ADB device (or a red "missing" placeholder), and keeps a compact server list agent-colored. Deletes the orphaned SummaryRow / CapacityOverview / ScopeOverview / TaskSection / ScopeDetails / QueueLaneCard / TaskRow / AdbSection / SlotStrip / MetaBadge / StatusBadge / AdbDeviceRow / SectionCard / InfoBanner helpers that were left over from the earlier layout. No changes to the snapshot / data layer — existing desktopTest suites still pass. --- .../com/block/agenttaskqueue/sidecar/Main.kt | 1984 ++++++++++------- 1 file changed, 1120 insertions(+), 864 deletions(-) diff --git a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/Main.kt b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/Main.kt index 9e9ef53..6ab82d4 100644 --- a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/Main.kt +++ b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/Main.kt @@ -1,38 +1,41 @@ package com.block.agenttaskqueue.sidecar +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.TooltipArea import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.TooltipArea import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -42,9 +45,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState @@ -54,31 +60,65 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import java.nio.file.Path import java.nio.file.Paths +import kotlin.math.max import kotlin.system.exitProcess private const val ACTIVE_INTERVAL_MS = 1000L private const val IDLE_INTERVAL_MS = 3000L -private val ScopeAccent = Color(0xFF305B78) -private val RunningAccent = Color(0xFFC96A3D) -private val WaitingAccent = Color(0xFF3F7698) -private val LaneAccent = Color(0xFF46705C) -private val WarningAccent = Color(0xFFB35C33) -private val ScopeCardColor = Color(0xFFFFFBF6) -private val LaneCardColor = Color(0xFFFFFDF9) +// Neutral surfaces +private val Background = Color(0xFFF4EEE5) +private val BackgroundGradientEnd = Color(0xFFEDE3D4) +private val SurfaceCard = Color(0xFFFFFCF7) +private val SurfaceElevated = Color(0xFFFFFFFF) +private val DividerColor = Color(0x14000000) + +// Text +private val TextPrimary = Color(0xFF1F262D) +private val TextSecondary = Color(0xFF5E6670) +private val TextMuted = Color(0xFF8A9099) + +// Status accents +private val AccentRunning = Color(0xFFC96A3D) +private val AccentWaiting = Color(0xFF3F7698) +private val AccentSuccess = Color(0xFF4E8A5A) +private val AccentWarning = Color(0xFFD08A2E) +private val AccentDanger = Color(0xFFB8472E) +private val AccentIdle = Color(0xFFB0A99E) + private val TooltipColor = Color(0xFF2B2F35) +// Stable per-agent color palette so a developer running multiple agents can +// visually pick out "which agent is that" at a glance. +private val AgentPalette = linkedMapOf( + "Amp" to Color(0xFF2A7E76), + "Claude" to Color(0xFFB8742E), + "Codex" to Color(0xFF6B4AA8), + "Cursor" to Color(0xFFA83F6C), + "Zed" to Color(0xFF2E6BA8), + "Windsurf" to Color(0xFF5E8A2E), +) +private val UnknownAgentColor = Color(0xFF5A6370) + +private fun agentColor(label: String?): Color { + if (label.isNullOrBlank()) return UnknownAgentColor + AgentPalette.forEach { (name, color) -> + if (label.startsWith(name, ignoreCase = true)) return color + } + return UnknownAgentColor +} + private val DashboardColors = lightColorScheme( primary = Color(0xFF305B78), - secondary = Color(0xFFB35C33), - tertiary = Color(0xFF46705C), - background = Color(0xFFF7F1E8), - surface = Color(0xFFFFFCF8), - surfaceVariant = Color(0xFFE9DFCf), - onBackground = Color(0xFF1F262D), - onSurface = Color(0xFF1F262D), + secondary = AccentRunning, + tertiary = AccentSuccess, + background = Background, + surface = SurfaceCard, + surfaceVariant = Color(0xFFE9DFCF), + onBackground = TextPrimary, + onSurface = TextPrimary, outline = Color(0xFF877F74), - error = Color(0xFF8D2C2C), + error = AccentDanger, ) fun main(args: Array) = application { @@ -87,7 +127,7 @@ fun main(args: Array) = application { Window( onCloseRequest = ::exitApplication, title = "Agent Task Queue Sidecar", - state = rememberWindowState(width = 1320.dp, height = 900.dp), + state = rememberWindowState(width = 1440.dp, height = 920.dp), ) { MaterialTheme(colorScheme = DashboardColors) { QueueDashboard(dataDir = dataDir) @@ -95,7 +135,6 @@ fun main(args: Array) = application { } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun QueueDashboard(dataDir: Path) { val refreshRequests = remember(dataDir) { Channel(Channel.CONFLATED) } @@ -114,287 +153,288 @@ private fun QueueDashboard(dataDir: Path) { } Scaffold( - topBar = { - TopAppBar( - title = { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text("Agent Task Queue", fontWeight = FontWeight.SemiBold) - Text( - text = snapshot.dataDir.toString(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - }, - actions = { - Text( - text = "Updated ${formatRefreshTime(snapshot.refreshedAt)}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), - ) - Spacer(Modifier.width(12.dp)) - Button(onClick = { refreshRequests.trySend(Unit) }) { - Text("Refresh") - } - Spacer(Modifier.width(16.dp)) - }, - ) - }, + containerColor = MaterialTheme.colorScheme.background, + topBar = { DashboardTopBar(snapshot) { refreshRequests.trySend(Unit) } }, ) { innerPadding -> Column( modifier = Modifier .fillMaxSize() .background( - Brush.verticalGradient( - listOf( - MaterialTheme.colorScheme.background, - Color(0xFFF3ECE2), - ) - ) + Brush.verticalGradient(listOf(Background, BackgroundGradientEnd)) ) - .verticalScroll(rememberScrollState()) .padding(innerPadding) - .padding(horizontal = 22.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - snapshot.errorMessage?.let { ErrorBanner(it) } - snapshot.configuration.errorMessage?.let { ErrorBanner(it) } - snapshot.adb.errorMessage?.let { ErrorBanner(it) } - QueueFlowSection(snapshot) - EnvironmentDetailsSection(snapshot) - - Text( - text = "Scopes own shared slots. Exact queues stay FIFO. Inline chips keep run vs wait local, while diagnostics below show which agent context owns each live server.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f), - ) + HeroStatStrip(snapshot) + BannerStack(snapshot) + Row( + modifier = Modifier.fillMaxWidth().weight(1f, fill = true), + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + QueueActivityPane(snapshot) + LegendFooter() + } + Column( + modifier = Modifier + .width(400.dp) + .fillMaxHeight() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + AgentsPanel(snapshot) + EmulatorsPanel(snapshot) + ServersPanel(snapshot) + } + } } } } +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun QueueFlowSection(snapshot: QueueSnapshot) { - SectionCard( - title = "Queue Flow", - subtitle = "Scopes own shared capacity; exact queues keep FIFO order inside each lane.", - ) { - if (snapshot.scopeGroups.isEmpty()) { +private fun DashboardTopBar(snapshot: QueueSnapshot, onRefresh: () -> Unit) { + TopAppBar( + title = { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Agent Task Queue", fontWeight = FontWeight.SemiBold) + Text( + text = snapshot.dataDir.toString(), + style = MaterialTheme.typography.bodySmall, + color = TextSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + actions = { Text( - text = snapshot.statusMessage ?: "No queues are visible yet.", - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f), + text = "Updated ${formatRefreshTime(snapshot.refreshedAt)}", + style = MaterialTheme.typography.bodySmall, + color = TextSecondary, ) - return@SectionCard - } + Spacer(Modifier.width(12.dp)) + Button(onClick = onRefresh) { Text("Refresh") } + Spacer(Modifier.width(16.dp)) + }, + ) +} - Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { - snapshot.scopeGroups.forEach { scope -> - ScopeFlowCard(scope = scope, snapshot = snapshot) - } - } +// ---------- Hero strip ---------- + +@Composable +private fun HeroStatStrip(snapshot: QueueSnapshot) { + val running = snapshot.summary.running + val waiting = snapshot.summary.waiting + val activeAgents = snapshot.runningTasks + .mapNotNull { it.displayAgentLabel } + .distinct() + .size + val configuredEmu = snapshot.emulatorAlignment.configuredQueues.size + val matchedEmu = snapshot.emulatorAlignment.matchedPorts.size + val connectedEmu = snapshot.adb.connectedEmulators + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + StatTile( + modifier = Modifier.weight(1f), + label = "RUNNING", + value = running.toString(), + caption = if (running == 0) "Nothing active" else if (running == 1) "Active task" else "Active tasks", + accent = if (running > 0) AccentRunning else AccentIdle, + ) + StatTile( + modifier = Modifier.weight(1f), + label = "WAITING", + value = waiting.toString(), + caption = when { + waiting == 0 -> "Queue clear" + waiting >= 5 -> "Queue backed up" + waiting == 1 -> "Queued task" + else -> "Queued tasks" + }, + accent = when { + waiting >= 5 -> AccentDanger + waiting > 0 -> AccentWarning + else -> AccentIdle + }, + ) + StatTile( + modifier = Modifier.weight(1f), + label = "AGENTS", + value = activeAgents.toString(), + caption = if (activeAgents == 0) "No agents running" else "With running tasks", + accent = if (activeAgents > 0) AccentSuccess else AccentIdle, + ) + StatTile( + modifier = Modifier.weight(1f), + label = "EMULATORS", + value = if (configuredEmu == 0) connectedEmu.toString() else "$matchedEmu/$configuredEmu", + caption = when { + configuredEmu == 0 && connectedEmu == 0 -> "None connected" + configuredEmu == 0 -> "Connected, no lanes" + matchedEmu < configuredEmu -> "Lane missing device" + else -> "Lanes matched" + }, + accent = when { + configuredEmu > 0 && matchedEmu < configuredEmu -> AccentDanger + configuredEmu == 0 && connectedEmu == 0 -> AccentIdle + else -> AccentSuccess + }, + ) } } @Composable -private fun ScopeFlowCard( - scope: ScopeGroup, - snapshot: QueueSnapshot, +private fun StatTile( + modifier: Modifier, + label: String, + value: String, + caption: String, + accent: Color, ) { - val rootUsage = snapshot.configuredScopeUsage.firstOrNull { it.scopeName == scope.scopeName } - val emulatorLanes = scope.lanes.filter { it.isEmulatorLike || it.configuredScope?.isEmulatorLike == true } - val matchedEmulators = emulatorLanes.count { lane -> lane.emulatorPort != null && lane.emulatorPort in snapshot.emulatorAlignment.matchedPorts } - - Card(colors = CardDefaults.cardColors(containerColor = ScopeCardColor)) { - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + Card( + modifier = modifier, + colors = CardDefaults.cardColors(containerColor = SurfaceElevated), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top, - ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(scope.scopeName, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) - Text( - text = "${scope.lanes.size} lane(s) · ${scope.runningCount} running · ${scope.waitingCount} waiting", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.72f), - ) - Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - MetaBadge( - label = rootUsage?.let { "shared ${it.displayCapacityLabel}" } ?: "default per-lane", - accent = ScopeAccent, - filled = true, - ) - val configuredDescendants = snapshot.configuredScopeUsage.count { - it.scopeName != scope.scopeName && it.scopeName.startsWith("${scope.scopeName}/") - } - if (configuredDescendants > 0) { - MetaBadge( - label = "$configuredDescendants configured descendant lane(s)", - accent = LaneAccent, - ) - } - if (emulatorLanes.isNotEmpty()) { - MetaBadge( - label = "ADB $matchedEmulators/${emulatorLanes.size} matched", - accent = WarningAccent, - tooltip = "Matches configured emulator queue lanes against connected `adb devices -l` emulator serials. A lane is matched when both advertise the same emulator port.", - ) - } - } - } - - Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) { - SlotStrip( - capacity = rootUsage?.capacity, - usedSlots = rootUsage?.usedSlots ?: scope.runningCount, - accent = ScopeAccent, - fallbackLabel = if (rootUsage == null) "No shared cap configured" else null, - ) - } + Box( + modifier = Modifier + .width(4.dp) + .height(44.dp) + .clip(RoundedCornerShape(2.dp)) + .background(accent), + ) + Spacer(Modifier.width(12.dp)) + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = accent, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.2.sp, + ) + Text( + text = value, + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + color = TextPrimary, + ) + Text( + text = caption, + style = MaterialTheme.typography.bodySmall, + color = TextSecondary, + ) } + } + } +} - HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.28f)) +// ---------- Queue activity pane ---------- - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - scope.lanes - .sortedWith( - compareByDescending { it.runningCount > 0 } - .thenByDescending { it.waitingCount > 0 } - .thenByDescending { it.configuredScope != null } - .thenBy { it.queueName } - ) - .forEach { lane -> - LaneFlowRow( - lane = lane, - rootUsage = rootUsage, - emulatorMatched = lane.emulatorPort != null && lane.emulatorPort in snapshot.emulatorAlignment.matchedPorts, - ) - } - } +@Composable +private fun QueueActivityPane(snapshot: QueueSnapshot) { + if (snapshot.scopeGroups.isEmpty()) { + EmptyState( + title = "No queue activity", + message = snapshot.statusMessage ?: "No queues are visible yet.", + ) + return + } + + val sortedScopes = snapshot.scopeGroups.sortedWith( + compareByDescending { it.waitingCount > 0 } + .thenByDescending { scopePressure(it, snapshot) } + .thenByDescending { it.runningCount } + .thenBy { it.scopeName } + ) + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + sortedScopes.forEach { scope -> + ScopeCard(scope = scope, snapshot = snapshot) } } } +private fun scopePressure(scope: ScopeGroup, snapshot: QueueSnapshot): Double { + val usage = snapshot.configuredScopeUsage.firstOrNull { it.scopeName == scope.scopeName } + val cap = usage?.capacity + if (cap == null || cap == 0) { + return if (scope.runningCount == 0) 0.0 else 1.0 + } + return scope.runningCount.toDouble() / cap +} + @Composable -private fun LaneFlowRow( - lane: QueueLane, - rootUsage: ConfiguredScopeUsage?, - emulatorMatched: Boolean, -) { - val runningTasks = lane.tasks.filter { it.status.equals("running", ignoreCase = true) } - val waitingTasks = lane.tasks.filter { it.status.equals("waiting", ignoreCase = true) } - val laneLabel = lane.queueName.substringAfterLast('/') - val fullPathNeeded = laneLabel != lane.queueName +private fun ScopeCard(scope: ScopeGroup, snapshot: QueueSnapshot) { + val usage = snapshot.configuredScopeUsage.firstOrNull { it.scopeName == scope.scopeName } + val capacity = usage?.capacity + val used = usage?.usedSlots ?: scope.runningCount + val hasBackup = scope.waitingCount > 0 + val capFull = capacity != null && used >= capacity && capacity > 0 - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - color = LaneCardColor, - tonalElevation = 1.dp, + val accent = when { + hasBackup && capFull -> AccentDanger + hasBackup -> AccentWarning + capFull -> AccentWarning + used > 0 -> AccentRunning + else -> AccentIdle + } + + Card( + colors = CardDefaults.cardColors(containerColor = SurfaceCard), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), ) { - Column( + Row( modifier = Modifier .fillMaxWidth() - .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.14f), RoundedCornerShape(16.dp)) - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + .height(IntrinsicSize.Min), ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top, - ) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(laneLabel, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) - if (fullPathNeeded) { - Text( - text = lane.queueName, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.62f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - - Text( - text = "${runningTasks.size} running · ${waitingTasks.size} waiting", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), - ) - } - - Row( + Box( + modifier = Modifier + .width(5.dp) + .fillMaxHeight() + .background(accent), + ) + Column( modifier = Modifier .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - MetaBadge( - label = when { - lane.hasCapacityConflict -> "exact ${lane.configuredScope?.displayCapacityLabel}" - lane.configuredCapacity != null -> "exact ${lane.configuredCapacity}" - else -> "default 1" - }, - accent = LaneAccent, - filled = lane.configuredCapacity != null || lane.hasCapacityConflict, + ScopeHeader( + scope = scope, + usage = usage, + accent = accent, + hasBackup = hasBackup, + capFull = capFull, ) - rootUsage?.let { - MetaBadge( - label = "shares ${scopeLabel(it)}=${it.displayCapacityLabel}", - accent = ScopeAccent, - ) - } - if (lane.isEmulatorLike) { - MetaBadge( - label = if (emulatorMatched) "ADB ${lane.emulatorPort} matched" else "ADB ${lane.emulatorPort ?: "missing"} missing", - accent = if (emulatorMatched) LaneAccent else WarningAccent, - tooltip = lane.emulatorPort?.let { emulatorPort -> - if (emulatorMatched) { - "Queue lane `$emulatorPort` has a connected ADB emulator on the same port." - } else { - "Queue lane `$emulatorPort` is configured, but `adb devices -l` does not currently show a connected emulator on that port." - } - }, - ) - } - } - - if (lane.tasks.isEmpty()) { - Text( - text = if (lane.configuredScope != null) "Idle configured lane." else "Idle lane.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.62f), + val sortedLanes = scope.lanes.sortedWith( + compareByDescending { it.waitingCount > 0 } + .thenByDescending { it.runningCount > 0 } + .thenByDescending { it.configuredScope != null } + .thenBy { it.queueName } ) - } else { - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - runningTasks.forEach { task -> - TaskChip(task = task, running = true) - } - if (runningTasks.isNotEmpty() && waitingTasks.isNotEmpty()) { - Text( - text = "queue", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.45f), - ) - } - waitingTasks.forEach { task -> - TaskChip(task = task, running = false) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + sortedLanes.forEach { lane -> + val matched = lane.emulatorPort != null && + lane.emulatorPort in snapshot.emulatorAlignment.matchedPorts + LaneRow(lane = lane, emulatorMatched = matched) } } } @@ -403,777 +443,906 @@ private fun LaneFlowRow( } @Composable -private fun TaskChip( - task: QueueTask, - running: Boolean, +private fun ScopeHeader( + scope: ScopeGroup, + usage: ConfiguredScopeUsage?, + accent: Color, + hasBackup: Boolean, + capFull: Boolean, ) { - val accent = if (running) RunningAccent else WaitingAccent - val background = if (running) accent.copy(alpha = 0.12f) else accent.copy(alpha = 0.05f) - val identityLabel = task.displayIdentityLabel - val diagnosticLabel = identityLabel ?: buildString { - task.pid?.let { append("server $it") } - task.childPid?.let { - if (isNotEmpty()) append(" · ") - append("child $it") - } - }.takeIf { it.isNotBlank() } - - Surface( - shape = RoundedCornerShape(14.dp), - color = background, - tonalElevation = 0.dp, + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, ) { Column( - modifier = Modifier - .widthIn(min = 220.dp, max = 300.dp) - .border(1.dp, accent.copy(alpha = 0.34f), RoundedCornerShape(14.dp)) - .padding(horizontal = 10.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(3.dp), + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { - StatusBadge(if (running) "run" else "wait", accent) - Text("#${task.id}", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) - } Text( - task.statusAge(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f), + text = scope.scopeName, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, ) + if (hasBackup) { + PressureChip("BACKUP", AccentDanger) + } + if (capFull && !hasBackup) { + PressureChip("AT CAPACITY", AccentWarning) + } } - Text( - text = task.displayCommand, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, + text = "${scope.lanes.size} lane${if (scope.lanes.size == 1) "" else "s"} · " + + "${scope.runningCount} running · ${scope.waitingCount} waiting", + style = MaterialTheme.typography.bodySmall, + color = TextSecondary, ) - - diagnosticLabel?.let { - Text( - text = it, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.62f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } } + CapacityMeter( + capacity = usage?.capacity, + used = usage?.usedSlots ?: scope.runningCount, + accent = accent, + configured = usage != null, + ) } } @Composable -private fun EnvironmentDetailsSection(snapshot: QueueSnapshot) { - SectionCard( - title = "Environment Details", - subtitle = "Secondary diagnostics for live server config and connected ADB devices.", +private fun CapacityMeter( + capacity: Int?, + used: Int, + accent: Color, + configured: Boolean, +) { + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(4.dp), ) { - snapshot.statusMessage?.let { InfoBanner(it) } - snapshot.configuration.statusMessage?.let { InfoBanner(it) } - snapshot.adb.statusMessage?.let { InfoBanner(it) } - + if (capacity == null) { + Text( + text = if (configured) "cap conflict" else "default per-lane", + style = MaterialTheme.typography.labelSmall, + color = TextMuted, + ) + return + } Text( - text = "Live queue servers", - style = MaterialTheme.typography.titleMedium, + text = "$used / $capacity slots", + style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, + color = accent, ) - if (snapshot.configuration.serverProcesses.isEmpty()) { - Text( - text = "No matching task queue server processes detected for this data dir.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f), + Row(horizontalArrangement = Arrangement.spacedBy(3.dp)) { + repeat(capacity) { i -> + Box( + modifier = Modifier + .size(width = 18.dp, height = 10.dp) + .clip(RoundedCornerShape(3.dp)) + .background(if (i < used) accent else accent.copy(alpha = 0.15f)) + .border(1.dp, accent.copy(alpha = 0.35f), RoundedCornerShape(3.dp)), + ) + } + } + } +} + +@Composable +private fun LaneRow(lane: QueueLane, emulatorMatched: Boolean) { + val leaf = lane.queueName.substringAfterLast('/') + val showFullPath = leaf != lane.queueName + val cap = lane.exactCapacity + val running = lane.tasks.filter { it.status.equals("running", ignoreCase = true) } + val waiting = lane.tasks.filter { it.status.equals("waiting", ignoreCase = true) } + val hasBackup = waiting.isNotEmpty() + val accent = when { + hasBackup -> AccentWarning + running.isNotEmpty() -> AccentRunning + else -> AccentIdle + } + + Surface( + shape = RoundedCornerShape(14.dp), + color = SurfaceElevated, + tonalElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .border(1.dp, accent.copy(alpha = 0.2f), RoundedCornerShape(14.dp)), + ) { + Box( + modifier = Modifier + .width(3.dp) + .fillMaxHeight() + .background(accent), ) - } else { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - snapshot.configuration.serverProcesses.forEach { process -> - val identity = snapshot.serverIdentityByPid[process.pid] - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surface, + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(1.dp), ) { - Column( - modifier = Modifier - .fillMaxWidth() - .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.14f), RoundedCornerShape(12.dp)) - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = identity?.displayLabel ?: process.agentLabel, + text = leaf, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, ) - identity?.launchContextLabel - ?.takeIf { identity.contextLabel == null } - ?.let { launchContextLabel -> - Text( - text = "server launched from $launchContextLabel", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.58f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, + if (lane.isEmulatorLike) { + EmulatorDot( + matched = emulatorMatched, + port = lane.emulatorPort, ) } - Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - MetaBadge(label = "pid ${process.pid}", accent = ScopeAccent) - MetaBadge( - label = if (process.queueCapacities.isEmpty()) { - "no scope overrides" - } else { - "${process.queueCapacities.size} scope override(s)" - }, - accent = LaneAccent, - filled = process.queueCapacities.isNotEmpty(), - ) - identity?.detailLabel?.let { detailLabel -> - MetaBadge(label = detailLabel, accent = WaitingAccent) - } + if (lane.hasCapacityConflict) { + PressureChip("CAP CONFLICT", AccentDanger) } + } + if (showFullPath) { Text( - text = process.commandLine, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + text = lane.queueName, + style = MaterialTheme.typography.labelSmall, + color = TextMuted, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } + LaneCountBadge(running = running.size, waiting = waiting.size, cap = cap) } - } - } - Text( - text = "ADB devices", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - if (snapshot.adb.devices.isEmpty()) { - Text( - text = "No ADB devices detected.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f), - ) - } else { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - snapshot.adb.devices.forEach { device -> - AdbDeviceRow(device) - } + LaneTimeline(cap = cap, running = running, waiting = waiting) } } } } @Composable -private fun SummaryRow(snapshot: QueueSnapshot) { +private fun LaneCountBadge(running: Int, waiting: Int, cap: Int) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - SummaryCard( - title = "Running", - value = snapshot.summary.running.toString(), - caption = "Active commands", - accent = Color(0xFFD06A3A), - modifier = Modifier.weight(1f), - ) - SummaryCard( - title = "Waiting", - value = snapshot.summary.waiting.toString(), - caption = "Queued tasks", - accent = Color(0xFF3D7EA6), - modifier = Modifier.weight(1f), - ) - SummaryCard( - title = "Visible Queues", - value = snapshot.queueLanes.size.toString(), - caption = "Observed + configured queue names", - accent = Color(0xFF5B8A67), - modifier = Modifier.weight(1f), - ) - SummaryCard( - title = "Emulators", - value = "${snapshot.configuration.configuredEmulatorScopeCount} / ${snapshot.adb.connectedEmulators}", - caption = "Configured emulator queues / connected emulators", - accent = Color(0xFF8B5F8C), - modifier = Modifier.weight(1f), - ) - } -} - -@Composable -private fun CapacityOverview(configuredScopes: List) { - SectionCard( - title = "Capacity Map", - subtitle = "Configured scopes stay visible even when empty, so you can see where parallel slots actually exist.", + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - if (configuredScopes.isEmpty()) { - Text( - "No live --queue-capacity scopes detected. Exact queues still default to capacity 1.", - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f), - ) - return@SectionCard - } - - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - configuredScopes.forEach { usage -> - Card( - modifier = Modifier.widthIn(min = 260.dp), - colors = CardDefaults.cardColors(containerColor = Color(0xFFF9F3EA)), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text(usage.scopeName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - Text( - text = if (usage.capacity != null) { - "${usage.runningCount} running · ${usage.waitingCount} waiting · ${usage.displayCapacityLabel} slot(s)" - } else { - "${usage.runningCount} running · ${usage.waitingCount} waiting · conflicting cap ${usage.displayCapacityLabel}" - }, - style = MaterialTheme.typography.bodyMedium, - ) - SlotStrip( - capacity = usage.capacity, - usedSlots = usage.usedSlots, - accent = Color(0xFFB35C33), - ) - Text( - text = "${usage.descendantQueueCount} visible queue(s) in scope · from ${usage.sourceServerLabels.joinToString()}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.68f), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - } + if (running > 0) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Box( + Modifier + .size(7.dp) + .clip(CircleShape) + .background(AccentRunning), + ) + Text( + text = "$running/$cap", + style = MaterialTheme.typography.labelSmall, + color = TextSecondary, + fontWeight = FontWeight.Medium, + ) } } - } -} - -@Composable -private fun SummaryCard( - title: String, - value: String, - caption: String, - accent: Color, - modifier: Modifier = Modifier, -) { - Card( - modifier = modifier, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(18.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(999.dp)) - .background(accent.copy(alpha = 0.16f)) - .padding(horizontal = 10.dp, vertical = 5.dp), + if (waiting > 0) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - Text(title, color = accent, style = MaterialTheme.typography.labelLarge) + Box( + Modifier + .size(7.dp) + .clip(CircleShape) + .background(AccentWarning), + ) + Text( + text = "+$waiting", + style = MaterialTheme.typography.labelSmall, + color = TextSecondary, + fontWeight = FontWeight.Medium, + ) } - Text(value, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + } + if (running == 0 && waiting == 0) { Text( - caption, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + text = "idle", + style = MaterialTheme.typography.labelSmall, + color = TextMuted, ) } } } @Composable -private fun ScopeOverview(scopeGroups: List) { - if (scopeGroups.isEmpty()) { +private fun LaneTimeline(cap: Int, running: List, waiting: List) { + if (running.isEmpty() && waiting.isEmpty()) { + Text( + text = "Lane idle", + style = MaterialTheme.typography.bodySmall, + color = TextMuted, + fontStyle = FontStyle.Italic, + ) return } - - SectionCard(title = "Scope Activity", subtitle = "Each card rolls up descendant exact queues under a shared root scope.") { - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - scopeGroups.forEach { scope -> - Card( - modifier = Modifier.widthIn(min = 220.dp), - colors = CardDefaults.cardColors(containerColor = Color(0xFFF7F0E4)), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text(scope.scopeName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - Text( - text = "${scope.runningCount} running · ${scope.waitingCount} waiting", - style = MaterialTheme.typography.bodyMedium, - ) - Text( - text = "${scope.lanes.size} exact queues · ${scope.taskCount} total tasks", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.68f), - ) - val configuredLanes = scope.lanes.count { it.configuredScope != null } - if (configuredLanes > 0) { - Text( - text = "$configuredLanes configured lane(s) visible even when idle", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.68f), - ) - } - } - } + val slotCount = max(cap, running.size).coerceAtLeast(1) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(slotCount) { i -> + val task = running.getOrNull(i) + if (task != null) { + TaskPill(task = task, running = true) + } else { + EmptySlotPill() } } - } -} - -@Composable -private fun TaskSection( - title: String, - subtitle: String, - tasks: List, - emptyLabel: String, -) { - SectionCard(title = title, subtitle = subtitle) { - if (tasks.isEmpty()) { - Text(emptyLabel, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f)) - return@SectionCard - } - - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - tasks.forEach { task -> - TaskRow(task = task, showQueue = true) + if (waiting.isNotEmpty()) { + Box( + modifier = Modifier + .width(1.dp) + .height(44.dp) + .background(DividerColor), + ) + waiting.forEach { task -> + TaskPill(task = task, running = false) } } } } @Composable -private fun ScopeDetails(scopeGroups: List) { - SectionCard( - title = "Queues By Scope", - subtitle = "Exact queues stay FIFO; grouping them here makes hierarchical queue families easier to scan.", +private fun EmptySlotPill() { + Box( + modifier = Modifier + .size(width = 200.dp, height = 62.dp) + .clip(RoundedCornerShape(10.dp)) + .background(Color(0xFFF2ECE0)) + .border(1.dp, DividerColor, RoundedCornerShape(10.dp)), + contentAlignment = Alignment.Center, ) { - if (scopeGroups.isEmpty()) { - Text("No active queues.", color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f)) - return@SectionCard - } - - Column(verticalArrangement = Arrangement.spacedBy(18.dp)) { - scopeGroups.forEach { scope -> - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text(scope.scopeName, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) - scope.lanes.forEach { lane -> - QueueLaneCard(lane) - } - } - } - } - } -} - -@Composable -private fun QueueLaneCard(lane: QueueLane) { - Card(colors = CardDefaults.cardColors(containerColor = Color(0xFFFFFBF5))) { - Column(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(lane.queueName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - Text( - text = "${lane.runningCount} running · ${lane.waitingCount} waiting · ${lane.tasks.size} task(s)", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), - ) - Text( - text = when { - lane.hasCapacityConflict -> "Exact cap conflict: ${lane.configuredScope?.displayCapacityLabel}" - lane.configuredCapacity != null -> "Exact cap ${lane.configuredCapacity} configured" - else -> "Exact cap 1 default" - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.68f), - ) - if (lane.configuredCapacity != null || lane.hasCapacityConflict) { - SlotStrip( - capacity = lane.configuredCapacity, - usedSlots = lane.runningCount, - accent = Color(0xFF46705C), - ) - } - } - } - - HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)) - - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - if (lane.tasks.isEmpty()) { - Text( - text = "No live tasks in this queue right now.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f), - ) - } else { - lane.tasks.forEach { task -> - TaskRow(task = task, showQueue = false) - } - } - } - } + Text( + text = "open slot", + style = MaterialTheme.typography.labelSmall, + color = TextMuted, + fontStyle = FontStyle.Italic, + ) } } +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun TaskRow(task: QueueTask, showQueue: Boolean) { - val accent = if (task.status.equals("running", ignoreCase = true)) { - Color(0xFFD06A3A) - } else { - Color(0xFF3D7EA6) - } - - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(18.dp), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 1.dp, +private fun TaskPill(task: QueueTask, running: Boolean) { + val accent = agentColor(task.displayAgentLabel) + val bg = if (running) accent.copy(alpha = 0.14f) else accent.copy(alpha = 0.06f) + val border = if (running) accent.copy(alpha = 0.55f) else accent.copy(alpha = 0.3f) + + TooltipArea( + tooltip = { TooltipBubble(buildTaskTooltip(task)) }, + delayMillis = 200, ) { Column( modifier = Modifier - .fillMaxWidth() - .border(1.dp, accent.copy(alpha = 0.18f), RoundedCornerShape(18.dp)) - .padding(14.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), + .widthIn(min = 200.dp, max = 260.dp) + .clip(RoundedCornerShape(10.dp)) + .background(bg) + .border(1.dp, border, RoundedCornerShape(10.dp)) + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), ) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - StatusBadge(task.status, accent) - Text("#${task.id}", fontWeight = FontWeight.Medium) - if (showQueue) { - Text( - task.queueName, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), - ) - } - } + Box( + Modifier + .size(8.dp) + .clip(CircleShape) + .background(accent), + ) Text( - task.statusAge(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + text = task.displayAgentLabel ?: "unknown agent", + style = MaterialTheme.typography.labelSmall, + color = accent, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + Spacer(Modifier.weight(1f)) + Text( + text = "#${task.id}", + style = MaterialTheme.typography.labelSmall, + color = TextSecondary, ) } - Text( text = task.displayCommand, - style = MaterialTheme.typography.bodyLarge, - maxLines = 2, + style = MaterialTheme.typography.bodySmall, + fontWeight = if (running) FontWeight.Medium else FontWeight.Normal, + color = TextPrimary, + maxLines = 1, overflow = TextOverflow.Ellipsis, ) - - val processLine = buildString { - task.displayIdentityLabel?.let { append(it) } - task.pid?.takeIf { task.displayIdentityLabel == null }?.let { append("server pid $it") } - task.childPid?.let { - if (isNotEmpty()) append(" · ") - append("child pid $it") + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + task.displayContextLabel?.let { + Text( + text = it, + style = MaterialTheme.typography.labelSmall, + color = TextMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) } - } - if (processLine.isNotEmpty()) { + Spacer(Modifier.weight(1f)) Text( - processLine, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + text = task.statusAge(), + style = MaterialTheme.typography.labelSmall, + color = TextMuted, ) } } } } +private fun buildTaskTooltip(task: QueueTask): String = buildString { + append("#${task.id} ") + append(task.status.uppercase()) + append('\n') + append(task.displayCommand) + task.displayIdentityLabel?.let { + append("\n\n") + append(it) + } + append("\n\nQueue: ") + append(task.queueName) + append('\n') + append(task.statusAge()) + val pidParts = buildList { + task.pid?.let { add("server pid $it") } + task.childPid?.let { add("child pid $it") } + } + if (pidParts.isNotEmpty()) { + append('\n') + append(pidParts.joinToString(" · ")) + } +} + +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun AdbSection(snapshot: QueueSnapshot) { - SectionCard( - title = "ADB Devices", - subtitle = "Compare connected emulators with queue scopes so emulator fan-out stays aligned with real devices.", +private fun EmulatorDot(matched: Boolean, port: String?) { + val color = if (matched) AccentSuccess else AccentDanger + val tooltip = if (matched) { + "Queue lane maps to ADB emulator on port ${port ?: "?"}." + } else { + "Lane expects emulator on port ${port ?: "?"}, but `adb devices -l` does not show one." + } + TooltipArea( + tooltip = { TooltipBubble(tooltip) }, + delayMillis = 200, ) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .clip(RoundedCornerShape(999.dp)) + .background(color.copy(alpha = 0.12f)) + .padding(horizontal = 6.dp, vertical = 2.dp), ) { - SummaryCard( - title = "Connected", - value = snapshot.adb.connectedDevices.toString(), - caption = "ADB devices in state=device", - accent = Color(0xFF305B78), - modifier = Modifier.weight(1f), - ) - SummaryCard( - title = "Emulators", - value = snapshot.adb.connectedEmulators.toString(), - caption = "Connected emulator serials", - accent = Color(0xFFB35C33), - modifier = Modifier.weight(1f), - ) - SummaryCard( - title = "Configured", - value = snapshot.emulatorAlignment.configuredQueues.size.toString(), - caption = "Configured emulator queues", - accent = Color(0xFF46705C), - modifier = Modifier.weight(1f), - ) - SummaryCard( - title = "Matched", - value = snapshot.emulatorAlignment.matchedPorts.size.toString(), - caption = "Queue/device port matches", - accent = Color(0xFF8B5F8C), - modifier = Modifier.weight(1f), - ) - } - - val alignment = snapshot.emulatorAlignment - if (alignment.unmatchedConfiguredQueues.isNotEmpty() || alignment.unmatchedDevices.isNotEmpty()) { - Text( - text = buildString { - if (alignment.unmatchedConfiguredQueues.isNotEmpty()) { - append("Unmatched configured queues: ") - append(alignment.unmatchedConfiguredQueues.joinToString { it.queueName }) - } - if (alignment.unmatchedDevices.isNotEmpty()) { - if (isNotEmpty()) append(". ") - append("Unmatched ADB devices: ") - append(alignment.unmatchedDevices.joinToString { it.serial }) - } - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.72f), + Box( + Modifier + .size(6.dp) + .clip(CircleShape) + .background(color), ) - } - - if (snapshot.adb.devices.isEmpty()) { Text( - text = "No ADB devices to display.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f), + text = if (matched) "ADB :${port ?: "?"}" else "ADB missing", + style = MaterialTheme.typography.labelSmall, + color = color, + fontWeight = FontWeight.Medium, ) - return@SectionCard - } - - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - snapshot.adb.devices.forEach { device -> - AdbDeviceRow(device) - } } } } @Composable -private fun StatusBadge(text: String, accent: Color) { +private fun PressureChip(text: String, accent: Color) { Box( modifier = Modifier .clip(RoundedCornerShape(999.dp)) - .background(accent.copy(alpha = 0.16f)) + .background(accent.copy(alpha = 0.15f)) + .border(1.dp, accent.copy(alpha = 0.55f), RoundedCornerShape(999.dp)) .padding(horizontal = 8.dp, vertical = 3.dp), ) { Text( - text = text.uppercase(), + text = text, color = accent, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold, + letterSpacing = 0.8.sp, ) } } +// ---------- Environment pane: agents ---------- + +private data class AgentContextSummary( + val context: String, + val runningCount: Int, + val waitingCount: Int, +) + +private data class AgentSummary( + val agentLabel: String, + val contexts: List, + val runningTotal: Int, + val waitingTotal: Int, +) + +private fun buildAgentSummaries(snapshot: QueueSnapshot): List { + return snapshot.tasks + .groupBy { it.displayAgentLabel ?: "Unknown agent" } + .map { (agentLabel, tasks) -> + val contexts = tasks + .groupBy { it.displayContextLabel ?: "no context" } + .map { (ctx, ctxTasks) -> + AgentContextSummary( + context = ctx, + runningCount = ctxTasks.count { it.status.equals("running", ignoreCase = true) }, + waitingCount = ctxTasks.count { it.status.equals("waiting", ignoreCase = true) }, + ) + } + .sortedWith( + compareByDescending { it.runningCount } + .thenByDescending { it.waitingCount } + .thenBy { it.context } + ) + AgentSummary( + agentLabel = agentLabel, + contexts = contexts, + runningTotal = tasks.count { it.status.equals("running", ignoreCase = true) }, + waitingTotal = tasks.count { it.status.equals("waiting", ignoreCase = true) }, + ) + } + .sortedWith( + compareByDescending { it.runningTotal } + .thenByDescending { it.waitingTotal } + .thenBy { it.agentLabel } + ) +} + @Composable -@OptIn(ExperimentalFoundationApi::class) -private fun MetaBadge( - label: String, - accent: Color, - filled: Boolean = false, - tooltip: String? = null, -) { - val background = if (filled) accent.copy(alpha = 0.14f) else Color.Transparent - val badgeContent: @Composable () -> Unit = { - Surface( - shape = RoundedCornerShape(999.dp), - color = background, - ) { +private fun AgentsPanel(snapshot: QueueSnapshot) { + val summaries = buildAgentSummaries(snapshot) + PaneSection( + title = "Agents", + subtitle = when { + summaries.isEmpty() -> "No agent activity" + summaries.size == 1 -> "1 agent with live tasks" + else -> "${summaries.size} agents with live tasks" + }, + ) { + if (summaries.isEmpty()) { Text( - text = label, - modifier = Modifier - .border(1.dp, accent.copy(alpha = 0.38f), RoundedCornerShape(999.dp)) - .padding(horizontal = 9.dp, vertical = 4.dp), - color = accent, - style = MaterialTheme.typography.labelSmall, + text = "No running or queued tasks.", + style = MaterialTheme.typography.bodySmall, + color = TextMuted, ) + return@PaneSection } + summaries.forEach { AgentCard(it) } } +} - if (tooltip != null) { - TooltipArea( - tooltip = { - TooltipBubble(tooltip) - }, - delayMillis = 150, +@Composable +private fun AgentCard(summary: AgentSummary) { + val accent = agentColor(summary.agentLabel) + Surface( + shape = RoundedCornerShape(12.dp), + color = SurfaceElevated, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .border(1.dp, accent.copy(alpha = 0.22f), RoundedCornerShape(12.dp)), ) { - badgeContent() + Box( + modifier = Modifier + .width(4.dp) + .fillMaxHeight() + .background(accent), + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier + .size(10.dp) + .clip(CircleShape) + .background(accent), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = summary.agentLabel, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = accent, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + AgentCountBadges( + running = summary.runningTotal, + waiting = summary.waitingTotal, + ) + } + if (summary.contexts.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + summary.contexts.forEach { ctx -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + Modifier + .size(5.dp) + .clip(CircleShape) + .background(accent.copy(alpha = 0.45f)), + ) + Text( + text = ctx.context, + style = MaterialTheme.typography.bodySmall, + color = TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (ctx.runningCount > 0) { + Text( + text = "${ctx.runningCount} run", + style = MaterialTheme.typography.labelSmall, + color = AccentRunning, + fontWeight = FontWeight.Medium, + ) + } + if (ctx.waitingCount > 0) { + Text( + text = "${ctx.waitingCount} wait", + style = MaterialTheme.typography.labelSmall, + color = AccentWarning, + fontWeight = FontWeight.Medium, + ) + } + } + } + } + } + } } - } else { - badgeContent() } } @Composable -private fun TooltipBubble(text: String) { - Surface( - shape = RoundedCornerShape(10.dp), - color = TooltipColor, - shadowElevation = 8.dp, +private fun AgentCountBadges(running: Int, waiting: Int) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + CountBubble(label = running.toString(), accent = AccentRunning, caption = "run") + CountBubble(label = waiting.toString(), accent = AccentWarning, caption = "wait") + } +} + +@Composable +private fun CountBubble(label: String, accent: Color, caption: String) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + modifier = Modifier + .clip(RoundedCornerShape(999.dp)) + .background(accent.copy(alpha = 0.12f)) + .padding(horizontal = 7.dp, vertical = 2.dp), ) { Text( - text = text, - modifier = Modifier - .widthIn(max = 280.dp) - .padding(horizontal = 10.dp, vertical = 8.dp), - color = Color.White, - style = MaterialTheme.typography.bodySmall, + text = label, + style = MaterialTheme.typography.labelMedium, + color = accent, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = caption, + style = MaterialTheme.typography.labelSmall, + color = accent.copy(alpha = 0.75f), ) } } +// ---------- Environment pane: emulators ---------- + @Composable -private fun SlotStrip( - capacity: Int?, - usedSlots: Int, - accent: Color, - fallbackLabel: String? = null, -) { - if (capacity == null) { - fallbackLabel?.let { +private fun EmulatorsPanel(snapshot: QueueSnapshot) { + val alignment = snapshot.emulatorAlignment + val matchedPorts = alignment.matchedPorts + val configured = alignment.configuredQueues + val connected = snapshot.adb.devices.filter { it.isConnected && it.isEmulator } + val devicesByPort = connected.associateBy { it.emulatorPort } + val extraDevices = connected.filter { it.emulatorPort == null || it.emulatorPort !in matchedPorts } + + PaneSection( + title = "Emulators", + subtitle = when { + configured.isEmpty() && connected.isEmpty() -> "No configured lanes · no emulators" + configured.isEmpty() -> "${connected.size} emulator(s) connected · no lanes configured" + else -> "${matchedPorts.size}/${configured.size} lanes matched to devices" + }, + ) { + if (configured.isEmpty() && connected.isEmpty()) { Text( - text = it, + text = "Nothing to show. Start an emulator or configure an emulator queue lane.", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f), + color = TextMuted, ) + return@PaneSection } - return - } - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { - repeat(capacity) { index -> - val filled = index < usedSlots - Box( - modifier = Modifier - .size(width = 16.dp, height = 9.dp) - .clip(RoundedCornerShape(4.dp)) - .background(if (filled) accent else accent.copy(alpha = 0.12f)) - .border(1.dp, accent.copy(alpha = 0.4f), RoundedCornerShape(4.dp)), + val sortedConfigured = configured.sortedWith( + compareByDescending { it.emulatorPort != null && it.emulatorPort !in matchedPorts } + .thenByDescending { it.runningCount } + .thenBy { it.queueName } + ) + sortedConfigured.forEach { lane -> + val port = lane.emulatorPort + val device = port?.let { devicesByPort[it] } + val matched = port != null && port in matchedPorts + EmulatorPairRow(lane = lane, device = device, matched = matched) + } + if (extraDevices.isNotEmpty()) { + Text( + text = "Unbound emulators", + style = MaterialTheme.typography.labelMedium, + color = TextSecondary, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 4.dp), ) + extraDevices.forEach { device -> + EmulatorPairRow(lane = null, device = device, matched = false) + } } - Text( - text = "$usedSlots/$capacity used", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), - ) } } -private fun scopeLabel(usage: ConfiguredScopeUsage): String = usage.scopeName.substringAfterLast('/') - @Composable -private fun AdbDeviceRow(device: AdbDevice) { +private fun EmulatorPairRow(lane: QueueLane?, device: AdbDevice?, matched: Boolean) { val accent = when { - device.isConnected && device.isEmulator -> Color(0xFF46705C) - device.isConnected -> Color(0xFF305B78) - else -> Color(0xFFB35C33) + matched -> AccentSuccess + lane != null -> AccentDanger + else -> AccentWarning } - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 1.dp, + shape = RoundedCornerShape(10.dp), + color = SurfaceElevated, ) { - Column( + Row( modifier = Modifier .fillMaxWidth() - .border(1.dp, accent.copy(alpha = 0.18f), RoundedCornerShape(14.dp)) - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + .border(1.dp, accent.copy(alpha = 0.28f), RoundedCornerShape(10.dp)) + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(1.dp), ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - StatusBadge(device.state, accent) - Text(device.serial, fontWeight = FontWeight.Medium) + if (lane != null) { + Text( + text = lane.queueName.substringAfterLast('/'), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "lane · ${lane.runningCount}/${lane.exactCapacity} run · ${lane.waitingCount} wait", + style = MaterialTheme.typography.labelSmall, + color = TextMuted, + maxLines = 1, + ) + } else { + Text( + text = "no configured lane", + style = MaterialTheme.typography.labelMedium, + color = TextMuted, + fontStyle = FontStyle.Italic, + ) } - if (device.emulatorPort != null) { + } + Text( + text = when { + matched -> "↔" + lane == null -> "→" + else -> "✕" + }, + color = accent, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(1.dp), + ) { + if (device != null) { Text( - text = "port ${device.emulatorPort}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + text = device.serial, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = device.detailLine.ifBlank { "state ${device.state}" }, + style = MaterialTheme.typography.labelSmall, + color = TextMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Text( + text = "no device on :${lane?.emulatorPort ?: "?"}", + style = MaterialTheme.typography.labelMedium, + color = AccentDanger, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "start emulator or remove lane", + style = MaterialTheme.typography.labelSmall, + color = TextMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } - if (device.detailLine.isNotBlank()) { - Text( - text = device.detailLine, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.68f), - ) + } + } +} + +// ---------- Environment pane: servers ---------- + +@Composable +private fun ServersPanel(snapshot: QueueSnapshot) { + val servers = snapshot.configuration.serverProcesses + PaneSection( + title = "Queue Servers", + subtitle = when { + servers.isEmpty() -> "No live servers for this data dir" + servers.size == 1 -> "1 task-queue server" + else -> "${servers.size} task-queue servers" + }, + ) { + if (servers.isEmpty()) { + Text( + text = snapshot.configuration.statusMessage + ?: "No matching task queue server processes.", + style = MaterialTheme.typography.bodySmall, + color = TextMuted, + ) + return@PaneSection + } + servers.forEach { proc -> + val identity = snapshot.serverIdentityByPid[proc.pid] + val accentSource = identity?.primaryLabel ?: proc.agentLabel + val accent = agentColor(accentSource) + Surface(shape = RoundedCornerShape(10.dp), color = SurfaceElevated) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, accent.copy(alpha = 0.2f), RoundedCornerShape(10.dp)) + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + Modifier + .size(8.dp) + .clip(CircleShape) + .background(accent), + ) + Text( + text = identity?.displayLabel ?: proc.agentLabel, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = "pid ${proc.pid}", + style = MaterialTheme.typography.labelSmall, + color = TextMuted, + ) + } + identity?.detailLabel?.let { + Text( + text = it, + style = MaterialTheme.typography.labelSmall, + color = TextSecondary, + ) + } + if (proc.queueCapacities.isNotEmpty()) { + Text( + text = proc.queueCapacities.entries.joinToString(" · ") { "${it.key}=${it.value}" }, + style = MaterialTheme.typography.labelSmall, + color = TextMuted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } } } } } +// ---------- Shared pane/utility composables ---------- + @Composable -private fun SectionCard( +private fun PaneSection( title: String, subtitle: String, content: @Composable ColumnScope.() -> Unit, ) { - Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { + Card( + colors = CardDefaults.cardColors(containerColor = SurfaceCard), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { Column( - modifier = Modifier.fillMaxWidth().padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), content = { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( - subtitle, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = TextSecondary, ) } content() @@ -1183,26 +1352,113 @@ private fun SectionCard( } @Composable -private fun ErrorBanner(message: String) { - Banner(message = message, background = Color(0xFFFBE4E3), foreground = MaterialTheme.colorScheme.error) +private fun BannerStack(snapshot: QueueSnapshot) { + val errors = listOfNotNull( + snapshot.errorMessage, + snapshot.configuration.errorMessage, + snapshot.adb.errorMessage, + ) + if (errors.isEmpty()) return + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + errors.forEach { ErrorBanner(it) } + } +} + +@Composable +private fun EmptyState(title: String, message: String) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = SurfaceCard), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = TextSecondary, + ) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = TextMuted, + textAlign = TextAlign.Center, + ) + } + } +} + +@Composable +private fun LegendFooter() { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LegendDot(color = AccentRunning, label = "running") + LegendDot(color = AccentWarning, label = "waiting / backup") + LegendDot(color = AccentDanger, label = "backup + full") + LegendDot(color = AccentSuccess, label = "emulator matched") + LegendDot(color = AccentIdle, label = "idle") + } } @Composable -private fun InfoBanner(message: String) { - Banner(message = message, background = Color(0xFFEAF1F6), foreground = MaterialTheme.colorScheme.primary) +private fun LegendDot(color: Color, label: String) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Box( + Modifier + .size(8.dp) + .clip(CircleShape) + .background(color), + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = TextMuted, + ) + } } @Composable -private fun Banner(message: String, background: Color, foreground: Color) { - Surface(shape = RoundedCornerShape(16.dp), color = background) { +private fun TooltipBubble(text: String) { + Surface( + shape = RoundedCornerShape(10.dp), + color = TooltipColor, + shadowElevation = 8.dp, + ) { + Text( + text = text, + modifier = Modifier + .widthIn(max = 320.dp) + .padding(horizontal = 10.dp, vertical = 8.dp), + color = Color.White, + style = MaterialTheme.typography.bodySmall, + ) + } +} + +@Composable +private fun ErrorBanner(message: String) { + Surface(shape = RoundedCornerShape(14.dp), color = Color(0xFFFBE4E3)) { Text( text = message, modifier = Modifier.fillMaxWidth().padding(14.dp), - color = foreground, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, ) } } +// ---------- CLI args ---------- + private fun resolveDataDir(args: Array): Path { if (args.any { it == "-h" || it == "--help" }) { println("Usage: ./gradlew run --args=\"[--data-dir PATH]\"") From ce0aed50c0d98dd684eb29706a1bd3c5aacfc5ce Mon Sep 17 00:00:00 2001 From: Stephen Edwards Date: Mon, 27 Apr 2026 10:28:24 -0400 Subject: [PATCH 04/16] Fix Amp session lookup and sidecar data-dir detection Amp-Thread-ID: https://ampcode.com/threads/T-019dcf45-5d79-74e8-9ae4-cde26e8f1971 Co-authored-by: Amp --- .../sidecar/EnvironmentSnapshot.kt | 11 ++- .../sidecar/EnvironmentSnapshotTest.kt | 12 +++ queue_core.py | 18 ++++ task_queue.py | 18 +--- tests/test_tq_cli.py | 25 ++++++ tq.py | 86 +++++++++++-------- 6 files changed, 117 insertions(+), 53 deletions(-) diff --git a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshot.kt b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshot.kt index 3b02b44..3eab6a5 100644 --- a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshot.kt +++ b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshot.kt @@ -86,7 +86,7 @@ data class AdbDevice( object TaskQueueProcessInspector { fun loadConfiguration(dataDir: Path): QueueConfigurationSnapshot { val normalizedDataDir = dataDir.toAbsolutePath().normalize() - val commandResult = runCommand("ps", "-axo", "pid=,ppid=,args=") + val commandResult = runCommand("ps", "eww", "-axo", "pid=,ppid=,command=") if (commandResult.errorMessage != null) { return QueueConfigurationSnapshot( @@ -389,6 +389,15 @@ private fun resolveTaskQueueDataDir(tokens: List): Path { index += 1 } + tokens.firstOrNull { it.startsWith("TASK_QUEUE_DATA_DIR=") } + ?.substringAfter('=') + ?.takeIf { it.isNotBlank() } + ?.let { configuredPath -> + return Paths.get(configuredPath) + .toAbsolutePath() + .normalize() + } + return DEFAULT_QUEUE_DATA_DIR } diff --git a/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshotTest.kt b/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshotTest.kt index 8fb0668..359d50d 100644 --- a/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshotTest.kt +++ b/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshotTest.kt @@ -24,6 +24,18 @@ class EnvironmentSnapshotTest { assertEquals(Paths.get("/tmp/other-queue"), processes.last().dataDir) } + @Test + fun fallsBackToTaskQueueDataDirEnvironmentWhenFlagIsOmitted() { + val process = parseTaskQueueProcesses( + """ + 73781 1 /Users/me/.venv/bin/python3 task_queue.py --queue-capacity=gradle=2 TASK_QUEUE_DATA_DIR=/tmp/custom-queue + """.trimIndent() + ).single() + + assertEquals(Paths.get("/tmp/custom-queue"), process.dataDir) + assertEquals(2, process.queueCapacities["gradle"]) + } + @Test fun parsesAdbDevicesAndMatchesEmulatorPorts() { val adb = parseAdbSnapshot( diff --git a/queue_core.py b/queue_core.py index 90305cd..87e34a5 100644 --- a/queue_core.py +++ b/queue_core.py @@ -56,6 +56,24 @@ class TaskOrigin: agent_name: str | None = None +def task_origin_kwargs(task_origin: "TaskOrigin | None") -> dict[str, str]: + """Return non-empty task origin fields as kwargs suitable for DB/metrics inserts.""" + if task_origin is None: + return {} + + return { + key: value + for key, value in { + "working_directory": task_origin.working_directory, + "worktree_root": task_origin.worktree_root, + "repo_name": task_origin.repo_name, + "git_branch": task_origin.git_branch, + "agent_name": task_origin.agent_name, + }.items() + if value + } + + # --- Database Schema --- QUEUE_SCHEMA = """ CREATE TABLE IF NOT EXISTS queue ( diff --git a/task_queue.py b/task_queue.py index b3cf4d8..02fff18 100644 --- a/task_queue.py +++ b/task_queue.py @@ -42,6 +42,7 @@ normalize_queue_name, parse_queue_capacities, attempt_task_start, + task_origin_kwargs, POLL_INTERVAL_WAITING, ) @@ -158,23 +159,6 @@ def log_metric(event: str, **kwargs): _log_metric(PATHS.metrics_path, event, MAX_METRICS_SIZE_MB, **kwargs) -def task_origin_kwargs(task_origin: TaskOrigin | None) -> dict[str, str]: - if task_origin is None: - return {} - - return { - key: value - for key, value in { - "working_directory": task_origin.working_directory, - "worktree_root": task_origin.worktree_root, - "repo_name": task_origin.repo_name, - "git_branch": task_origin.git_branch, - "agent_name": task_origin.agent_name, - }.items() - if value - } - - def cleanup_queue(conn, queue_name: str, queue_capacities: dict[str, int] | None = None): """Clean up queue using configured paths and detect orphaned tasks.""" if queue_capacities is None: diff --git a/tests/test_tq_cli.py b/tests/test_tq_cli.py index c249800..60e8001 100644 --- a/tests/test_tq_cli.py +++ b/tests/test_tq_cli.py @@ -674,6 +674,31 @@ def test_parse_amp_thread_ids_uses_latest_entry_per_pid(self): 88150: "T-019dbfd5-6e1d-7548-b824-f87378e25a8e", } + def test_parse_amp_thread_ids_resets_when_pid_is_reused(self): + log_text = "\n".join( + [ + json.dumps( + { + "pid": 86296, + "timestamp": "2026-04-24T15:05:00.000Z", + "threadId": "T-019dc029-f25d-767c-8005-e2996169f6f8", + } + ), + json.dumps( + { + "pid": 86296, + "timestamp": "2026-04-24T15:20:00.000Z", + "message": "Loaded session state:", + "lastThreadId": "T-019dcf45-5d79-74e8-9ae4-cde26e8f1971", + } + ), + ] + ) + + assert tq.parse_amp_thread_ids_from_log(log_text, {86296}) == { + 86296: "T-019dcf45-5d79-74e8-9ae4-cde26e8f1971", + } + def test_amp_restart_shell_output_for_targeted_pids(self, monkeypatch, capsys): monkeypatch.setattr( tq, diff --git a/tq.py b/tq.py index de6ce67..e40d639 100644 --- a/tq.py +++ b/tq.py @@ -37,6 +37,7 @@ normalize_queue_name, parse_queue_capacities, attempt_task_start, + task_origin_kwargs, POLL_INTERVAL_WAITING, DEFAULT_MAX_LOCK_AGE_MINUTES, DEFAULT_MAX_METRICS_SIZE_MB, @@ -403,12 +404,33 @@ def parse_amp_sessions_from_ps_output(ps_output: str) -> list[AmpSession]: return sessions +def _extract_thread_id_from_log_entry(entry: dict, *, allow_session_state: bool = False) -> str | None: + for key in ("threadId", "threadID", "newThreadID", "currentThreadID"): + value = entry.get(key) + if isinstance(value, str) and AMP_THREAD_ID_PATTERN.fullmatch(value): + return value + + if allow_session_state: + value = entry.get("lastThreadId") + if isinstance(value, str) and AMP_THREAD_ID_PATTERN.fullmatch(value): + return value + + message = entry.get("message") + if isinstance(message, str) and "Switching to thread:" in message: + match = AMP_THREAD_ID_PATTERN.search(message) + if match: + return match.group(0) + + return None + + def parse_amp_thread_ids_from_log( log_text: str, candidate_pids: set[int] | None = None, ) -> dict[int, str]: - """Return the latest known Amp thread ID for each PID in the CLI log.""" + """Return the latest known Amp thread ID for each live PID in the CLI log.""" latest_thread_by_pid: dict[int, tuple[str, str]] = {} + latest_session_start_by_pid: dict[int, str] = {} for raw_line in log_text.splitlines(): line = raw_line.strip() @@ -432,18 +454,21 @@ def parse_amp_thread_ids_from_log( if not isinstance(timestamp, str) or not timestamp: continue - thread_id = None - for key in ("threadId", "threadID", "newThreadID"): - value = entry.get(key) - if isinstance(value, str) and AMP_THREAD_ID_PATTERN.fullmatch(value): - thread_id = value + message = entry.get("message") + if message == "Loaded session state:": + latest_session_start_by_pid[pid] = timestamp + thread_id = _extract_thread_id_from_log_entry(entry, allow_session_state=True) + if thread_id is None: + latest_thread_by_pid.pop(pid, None) + else: + latest_thread_by_pid[pid] = (timestamp, thread_id) + continue + + session_started_at = latest_session_start_by_pid.get(pid) + if session_started_at is not None and timestamp < session_started_at: + continue - if thread_id is None: - message = entry.get("message") - if isinstance(message, str) and "Switching to thread:" in message: - match = AMP_THREAD_ID_PATTERN.search(message) - if match: - thread_id = match.group(0) + thread_id = _extract_thread_id_from_log_entry(entry) if thread_id is None: continue @@ -460,12 +485,20 @@ def discover_amp_sessions(cli_log_path: Path = AMP_CLI_LOG_PATH) -> list[AmpSess last_error = "Failed to enumerate running processes with ps" result = None for command in AMP_PS_COMMAND_CANDIDATES: - result = subprocess.run( - command, - capture_output=True, - text=True, - timeout=5, - ) + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + timeout=5, + ) + except subprocess.TimeoutExpired: + last_error = f"`{' '.join(command)}` timed out after 5s" + continue + except (FileNotFoundError, PermissionError, OSError) as exc: + last_error = f"`{' '.join(command)}` failed: {exc}" + continue + if result.returncode == 0: break stderr = result.stderr.strip() @@ -578,23 +611,6 @@ def log_metric(paths: QueuePaths, event: str, **kwargs): _log_metric(paths.metrics_path, event, DEFAULT_MAX_METRICS_SIZE_MB, **kwargs) -def task_origin_kwargs(task_origin: TaskOrigin | None) -> dict[str, str]: - if task_origin is None: - return {} - - return { - key: value - for key, value in { - "working_directory": task_origin.working_directory, - "worktree_root": task_origin.worktree_root, - "repo_name": task_origin.repo_name, - "git_branch": task_origin.git_branch, - "agent_name": task_origin.agent_name, - }.items() - if value - } - - def cleanup_queue( conn, queue_name: str, From 7f897337cffb751b4b01c22f42e826b636e589a5 Mon Sep 17 00:00:00 2001 From: Stephen Edwards Date: Mon, 27 Apr 2026 10:32:29 -0400 Subject: [PATCH 05/16] Extract shared queue insert helper Amp-Thread-ID: https://ampcode.com/threads/T-019dcf45-5d79-74e8-9ae4-cde26e8f1971 Co-authored-by: Amp --- queue_core.py | 30 ++++++++++++++++++++++++++++++ task_queue.py | 26 ++++++++------------------ tq.py | 26 ++++++++------------------ 3 files changed, 46 insertions(+), 36 deletions(-) diff --git a/queue_core.py b/queue_core.py index 87e34a5..335791b 100644 --- a/queue_core.py +++ b/queue_core.py @@ -74,6 +74,36 @@ def task_origin_kwargs(task_origin: "TaskOrigin | None") -> dict[str, str]: } +def insert_waiting_task( + conn, + queue_name: str, + pid: int, + server_id: str, + command: str | None = None, + task_origin: "TaskOrigin | None" = None, +) -> int: + """Insert a waiting task row and return its task ID.""" + cursor = conn.execute( + """INSERT INTO queue ( + queue_name, status, pid, server_id, command, + working_directory, worktree_root, repo_name, git_branch, agent_name + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + queue_name, + "waiting", + pid, + server_id, + command, + task_origin.working_directory if task_origin else None, + task_origin.worktree_root if task_origin else None, + task_origin.repo_name if task_origin else None, + task_origin.git_branch if task_origin else None, + task_origin.agent_name if task_origin else None, + ), + ) + return cursor.lastrowid + + # --- Database Schema --- QUEUE_SCHEMA = """ CREATE TABLE IF NOT EXISTS queue ( diff --git a/task_queue.py b/task_queue.py index 02fff18..9d11d10 100644 --- a/task_queue.py +++ b/task_queue.py @@ -39,6 +39,7 @@ log_fmt, is_process_alive, kill_process_tree, + insert_waiting_task, normalize_queue_name, parse_queue_capacities, attempt_task_start, @@ -296,25 +297,14 @@ async def wait_for_turn( pass # Running outside request context (e.g., in tests) with get_db() as conn: - cursor = conn.execute( - """INSERT INTO queue ( - queue_name, status, pid, server_id, command, - working_directory, worktree_root, repo_name, git_branch, agent_name - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", - ( - queue_name, - "waiting", - my_pid, - SERVER_INSTANCE_ID, - command, - task_origin.working_directory if task_origin else None, - task_origin.worktree_root if task_origin else None, - task_origin.repo_name if task_origin else None, - task_origin.git_branch if task_origin else None, - task_origin.agent_name if task_origin else None, - ), + task_id = insert_waiting_task( + conn, + queue_name, + my_pid, + SERVER_INSTANCE_ID, + command=command, + task_origin=task_origin, ) - task_id = cursor.lastrowid # Track this task as active for orphan detection with _active_task_ids_lock: diff --git a/tq.py b/tq.py index e40d639..6288cca 100644 --- a/tq.py +++ b/tq.py @@ -34,6 +34,7 @@ release_lock, is_process_alive, kill_process_tree, + insert_waiting_task, normalize_queue_name, parse_queue_capacities, attempt_task_start, @@ -660,26 +661,15 @@ def register_task( """Register a task in the queue. Returns task_id immediately.""" my_pid = os.getpid() - cursor = conn.execute( - """INSERT INTO queue ( - queue_name, status, pid, server_id, command, - working_directory, worktree_root, repo_name, git_branch, agent_name - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", - ( - queue_name, - "waiting", - my_pid, - CLI_INSTANCE_ID, - command, - task_origin.working_directory if task_origin else None, - task_origin.worktree_root if task_origin else None, - task_origin.repo_name if task_origin else None, - task_origin.git_branch if task_origin else None, - task_origin.agent_name if task_origin else None, - ), + task_id = insert_waiting_task( + conn, + queue_name, + my_pid, + CLI_INSTANCE_ID, + command=command, + task_origin=task_origin, ) conn.commit() - task_id = cursor.lastrowid log_metric( paths, From 793f88cfa2ab6277c1e1a869afc259c67994cb5a Mon Sep 17 00:00:00 2001 From: Stephen Edwards Date: Mon, 27 Apr 2026 10:36:31 -0400 Subject: [PATCH 06/16] Stream Amp CLI logs during session lookup Amp-Thread-ID: https://ampcode.com/threads/T-019dcf45-5d79-74e8-9ae4-cde26e8f1971 Co-authored-by: Amp --- tq.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tq.py b/tq.py index 6288cca..6e4792e 100644 --- a/tq.py +++ b/tq.py @@ -16,6 +16,7 @@ import time import uuid import re +from collections.abc import Iterable from dataclasses import dataclass from datetime import datetime from pathlib import Path @@ -426,14 +427,15 @@ def _extract_thread_id_from_log_entry(entry: dict, *, allow_session_state: bool def parse_amp_thread_ids_from_log( - log_text: str, + log_lines: Iterable[str] | str, candidate_pids: set[int] | None = None, ) -> dict[int, str]: """Return the latest known Amp thread ID for each live PID in the CLI log.""" latest_thread_by_pid: dict[int, tuple[str, str]] = {} latest_session_start_by_pid: dict[int, str] = {} + line_iterator = log_lines.splitlines() if isinstance(log_lines, str) else log_lines - for raw_line in log_text.splitlines(): + for raw_line in line_iterator: line = raw_line.strip() if not line: continue @@ -514,10 +516,11 @@ def discover_amp_sessions(cli_log_path: Path = AMP_CLI_LOG_PATH) -> list[AmpSess if not sessions or not cli_log_path.exists(): return sessions - thread_ids = parse_amp_thread_ids_from_log( - cli_log_path.read_text(), - candidate_pids={session.pid for session in sessions}, - ) + with cli_log_path.open() as cli_log: + thread_ids = parse_amp_thread_ids_from_log( + cli_log, + candidate_pids={session.pid for session in sessions}, + ) for session in sessions: session.thread_id = thread_ids.get(session.pid) From f5abd1e2d66ba092c93dfa4543fa77cbbf134336 Mon Sep 17 00:00:00 2001 From: Stephen Edwards Date: Mon, 27 Apr 2026 10:43:03 -0400 Subject: [PATCH 07/16] Collapse Git metadata lookup for queued tasks Amp-Thread-ID: https://ampcode.com/threads/T-019dcf45-5d79-74e8-9ae4-cde26e8f1971 Co-authored-by: Amp --- queue_core.py | 76 ++++++++++++++++++++++++++++++++++----------------- 1 file changed, 51 insertions(+), 25 deletions(-) diff --git a/queue_core.py b/queue_core.py index 335791b..4e59874 100644 --- a/queue_core.py +++ b/queue_core.py @@ -24,6 +24,7 @@ DEFAULT_MAX_LOCK_AGE_MINUTES = 120 DEFAULT_MAX_METRICS_SIZE_MB = 5 QUEUE_SCOPE_SEPARATOR = "/" +GIT_METADATA_TIMEOUT_SECONDS = 2 @dataclass @@ -202,12 +203,7 @@ def init_db(paths: QueuePaths): def collect_task_origin(working_directory: str, agent_name: str | None = None) -> TaskOrigin: """Capture stable repo/worktree metadata for a queued task.""" resolved_working_directory = str(Path(working_directory).resolve()) - worktree_root = _git_output(resolved_working_directory, "rev-parse", "--show-toplevel") - repo_name = _git_repo_name(resolved_working_directory) - git_branch = _git_output(resolved_working_directory, "branch", "--show-current") - - if not git_branch: - git_branch = _git_output(resolved_working_directory, "rev-parse", "--short", "HEAD") + worktree_root, repo_name, git_branch = _git_context(resolved_working_directory) return TaskOrigin( working_directory=resolved_working_directory, @@ -218,34 +214,64 @@ def collect_task_origin(working_directory: str, agent_name: str | None = None) - ) -def _git_repo_name(working_directory: str) -> str | None: - remote = _git_output(working_directory, "remote", "get-url", "origin") - if remote: - return remote.rstrip("/").rsplit("/", 1)[-1].removesuffix(".git") - - worktree_root = _git_output(working_directory, "rev-parse", "--show-toplevel") - if worktree_root: - return Path(worktree_root).name - - return None - - -def _git_output(working_directory: str, *args: str) -> str | None: +def _git_context(working_directory: str) -> tuple[str | None, str | None, str | None]: try: result = subprocess.run( - ["git", "-C", working_directory, *args], + [ + "git", + "-C", + working_directory, + "rev-parse", + "--show-toplevel", + "--git-common-dir", + "--symbolic-full-name", + "HEAD", + "HEAD", + ], capture_output=True, text=True, - timeout=5, + timeout=GIT_METADATA_TIMEOUT_SECONDS, ) except (OSError, subprocess.SubprocessError): - return None + return None, None, None if result.returncode != 0: - return None + return None, None, None + + lines = [line.strip() for line in result.stdout.splitlines() if line.strip()] + if len(lines) < 4: + return None, None, None - value = result.stdout.strip() - return value or None + worktree_root, git_common_dir, head_oid, head_ref = lines[:4] + repo_name = _git_repo_name_from_common_dir( + working_directory, + worktree_root, + git_common_dir, + ) + git_branch = ( + head_ref.removeprefix("refs/heads/") + if head_ref.startswith("refs/heads/") + else head_oid[:7] or None + ) + return worktree_root or None, repo_name, git_branch + + +def _git_repo_name_from_common_dir( + working_directory: str, + worktree_root: str | None, + git_common_dir: str, +) -> str | None: + common_dir_path = Path(git_common_dir) + if not common_dir_path.is_absolute(): + common_dir_path = (Path(working_directory) / common_dir_path).resolve() + + if common_dir_path.name == ".git": + return common_dir_path.parent.name or None + + if worktree_root: + return Path(worktree_root).name + + return None def normalize_queue_name(queue_name: str) -> str: From b51a11e6e04760ef4370b6c62b22699fe6a7e693 Mon Sep 17 00:00:00 2001 From: Stephen Edwards Date: Mon, 27 Apr 2026 10:46:17 -0400 Subject: [PATCH 08/16] Tighten queue schema migration error handling Amp-Thread-ID: https://ampcode.com/threads/T-019dcf45-5d79-74e8-9ae4-cde26e8f1971 Co-authored-by: Amp --- queue_core.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/queue_core.py b/queue_core.py index 4e59874..b5cfd80 100644 --- a/queue_core.py +++ b/queue_core.py @@ -196,8 +196,9 @@ def init_db(paths: QueuePaths): ]: try: conn.execute(migration) - except sqlite3.OperationalError: - pass # Column already exists + except sqlite3.OperationalError as exc: + if "duplicate column name" not in str(exc).lower(): + raise def collect_task_origin(working_directory: str, agent_name: str | None = None) -> TaskOrigin: From e6635db8aaf585a99019f38a232186711cbe04da Mon Sep 17 00:00:00 2001 From: Stephen Edwards Date: Mon, 27 Apr 2026 10:49:08 -0400 Subject: [PATCH 09/16] Harden sidecar process inspection helpers Amp-Thread-ID: https://ampcode.com/threads/T-019dcf45-5d79-74e8-9ae4-cde26e8f1971 Co-authored-by: Amp --- .../sidecar/EnvironmentSnapshot.kt | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshot.kt b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshot.kt index 3eab6a5..00b77fd 100644 --- a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshot.kt +++ b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshot.kt @@ -2,8 +2,12 @@ package com.block.agenttaskqueue.sidecar import java.nio.file.Path import java.nio.file.Paths +import java.util.concurrent.TimeUnit private val DEFAULT_QUEUE_DATA_DIR: Path = Paths.get("/tmp/agent-task-queue").toAbsolutePath().normalize() +private const val COMMAND_TIMEOUT_SECONDS = 5L +private val EMULATOR_SERIAL_PATTERN = Regex("^(?:emu|emulator)-(\\d+)$", RegexOption.IGNORE_CASE) +private val LOCALHOST_EMULATOR_PATTERN = Regex("^(?:127\\.0\\.0\\.1|localhost):(\\d+)$", RegexOption.IGNORE_CASE) data class QueueConfigurationSnapshot( val serverProcesses: List, @@ -234,13 +238,10 @@ internal fun parseAdbSnapshot(output: String): AdbSnapshot { internal fun extractEmulatorPort(value: String): String? { val trimmed = value.trim() - Regex("^(?:emu|emulator)-(\\d+)$", RegexOption.IGNORE_CASE).matchEntire(trimmed)?.let { + EMULATOR_SERIAL_PATTERN.matchEntire(trimmed)?.let { return it.groupValues[1] } - Regex("^emulator-(\\d+)$", RegexOption.IGNORE_CASE).matchEntire(trimmed)?.let { - return it.groupValues[1] - } - Regex("^(?:127\\.0\\.0\\.1|localhost):(\\d+)$", RegexOption.IGNORE_CASE).matchEntire(trimmed)?.let { + LOCALHOST_EMULATOR_PATTERN.matchEntire(trimmed)?.let { return it.groupValues[1] } return null @@ -459,8 +460,18 @@ private fun runCommand(vararg command: String): CommandResult { val process = ProcessBuilder(*command) .redirectErrorStream(true) .start() + if (!process.waitFor(COMMAND_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + process.destroyForcibly() + process.waitFor() + return CommandResult( + output = "", + exitCode = -1, + errorMessage = "`${command.joinToString(" ")}` timed out after ${COMMAND_TIMEOUT_SECONDS}s.", + ) + } + val output = process.inputStream.bufferedReader().use { it.readText() } - CommandResult(output = output.trim(), exitCode = process.waitFor()) + CommandResult(output = output.trim(), exitCode = process.exitValue()) } catch (error: Exception) { CommandResult( output = "", From 66db04ebef4044609c7ed39e899f211e54c5df1e Mon Sep 17 00:00:00 2001 From: Stephen Edwards Date: Mon, 27 Apr 2026 11:12:18 -0400 Subject: [PATCH 10/16] Extract Amp restart helpers and tighten follow-ups Amp-Thread-ID: https://ampcode.com/threads/T-019dcf45-5d79-74e8-9ae4-cde26e8f1971 Co-authored-by: Amp --- amp_restart.py | 385 ++++++++++++++++++ .../sidecar/TaskQueueDatabase.kt | 22 +- .../sidecar/TaskQueueDatabaseTest.kt | 19 + pyproject.toml | 2 +- queue_core.py | 9 +- task_queue.py | 33 +- tests/test_tq_cli.py | 33 +- tq.py | 375 +---------------- 8 files changed, 464 insertions(+), 414 deletions(-) create mode 100644 amp_restart.py create mode 100644 desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/TaskQueueDatabaseTest.kt diff --git a/amp_restart.py b/amp_restart.py new file mode 100644 index 0000000..51ab84d --- /dev/null +++ b/amp_restart.py @@ -0,0 +1,385 @@ +"""Amp-specific session discovery helpers for the tq CLI.""" + +from __future__ import annotations + +import json +import re +import shlex +import subprocess +import sys +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path + +AMP_CLI_LOG_PATH = Path.home() / ".cache" / "amp" / "logs" / "cli.log" +AMP_THREAD_ID_PATTERN = re.compile(r"T-[0-9a-f-]{36}") +AMP_ENV_ASSIGNMENT_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") +AMP_ENV_VALUE_PATTERN_TEMPLATE = r"(?:^|\s){name}=(.*?)(?=\s+[A-Za-z_][A-Za-z0-9_]*=|$)" +AMP_PS_COMMAND_CANDIDATES = [ + ["ps", "eww", "-axo", "pid=,command="], + ["ps", "eww", "axo", "pid=,command="], +] + +# Mirrors the current `amp --help` global options so the generic queue CLI does not own +# Amp-specific argument parsing details. +AMP_GLOBAL_FLAGS_WITH_VALUE = { + "--visibility", + "--settings-file", + "--log-level", + "--log-file", + "--mcp-config", + "-l", + "--label", +} +AMP_GLOBAL_BOOLEAN_FLAGS = { + "--notifications", + "--no-notifications", + "--color", + "--no-color", + "--dangerously-allow-all", + "--jetbrains", + "--no-jetbrains", + "--ide", + "--no-ide", + "--stream-json", + "--stream-json-thinking", + "--stream-json-input", + "--archive", +} + + +@dataclass +class AmpSession: + pid: int + cwd: str | None + thread_id: str | None = None + agent_session_id: str | None = None + mode: str | None = None + + @property + def stop_command(self) -> str: + return f"kill -TERM {self.pid}" + + @property + def continue_command(self) -> str | None: + if not self.cwd or not self.thread_id: + return None + return f"(cd {shlex.quote(self.cwd)} && amp threads continue {self.thread_id})" + + +def add_amp_restart_subparser(subparsers) -> None: + parser = subparsers.add_parser( + "amp-restart", + help="Resolve live interactive Amp sessions to thread IDs and print restart commands", + ) + parser.add_argument( + "--pid", + action="append", + type=int, + default=[], + help="Target a specific live Amp PID. Repeatable. Defaults to all live interactive Amp sessions.", + ) + parser.add_argument("--json", action="store_true", help="Output in JSON format") + parser.add_argument( + "--shell", + action="store_true", + help="Print shell commands only (kill + amp threads continue)", + ) + + +def _extract_env_value(process_line: str, env_name: str) -> str | None: + pattern = AMP_ENV_VALUE_PATTERN_TEMPLATE.format(name=re.escape(env_name)) + match = re.search(pattern, process_line) + if not match: + return None + value = match.group(1).strip() + return value or None + + +def _amp_process_prefix_tokens(process_line: str) -> tuple[int, list[str]] | None: + line = process_line.strip() + if not line: + return None + + try: + pid_text, command = line.split(None, 1) + pid = int(pid_text) + except ValueError: + return None + + argv = [] + for token in command.split(): + if AMP_ENV_ASSIGNMENT_PATTERN.match(token): + break + argv.append(token) + + if not argv: + return None + + return pid, argv + + +def _is_interactive_amp_invocation(argv: list[str]) -> tuple[bool, str | None]: + if not argv or Path(argv[0]).name != "amp": + return False, None + + remaining: list[str] = [] + mode: str | None = None + i = 1 + while i < len(argv): + token = argv[i] + if token in {"-x", "--execute"} or token.startswith("--execute="): + return False, mode + if token in {"-m", "--mode"}: + if i + 1 < len(argv): + mode = argv[i + 1] + i += 2 + continue + if token.startswith("--mode="): + mode = token.split("=", 1)[1] or None + i += 1 + continue + if token in AMP_GLOBAL_FLAGS_WITH_VALUE: + i += 2 + continue + if any(token.startswith(flag + "=") for flag in AMP_GLOBAL_FLAGS_WITH_VALUE if flag.startswith("--")): + i += 1 + continue + if token in AMP_GLOBAL_BOOLEAN_FLAGS: + i += 1 + continue + remaining = argv[i:] + break + + interactive = not remaining or ( + len(remaining) >= 2 + and remaining[0] in {"threads", "thread", "t"} + and remaining[1] in {"continue", "c", "new", "n"} + ) + return interactive, mode + + +def parse_amp_sessions_from_ps_output(ps_output: str) -> list[AmpSession]: + """Parse `ps eww` output and return live interactive Amp sessions.""" + sessions: list[AmpSession] = [] + for line in ps_output.splitlines(): + prefix = _amp_process_prefix_tokens(line) + if prefix is None: + continue + + pid, argv = prefix + interactive, mode = _is_interactive_amp_invocation(argv) + if not interactive: + continue + + sessions.append( + AmpSession( + pid=pid, + cwd=_extract_env_value(line, "PWD"), + agent_session_id=_extract_env_value(line, "AGENT_SESSION_ID"), + mode=mode, + ) + ) + + return sessions + + +def _extract_thread_id_from_log_entry(entry: dict, *, allow_session_state: bool = False) -> str | None: + for key in ("threadId", "threadID", "newThreadID", "currentThreadID"): + value = entry.get(key) + if isinstance(value, str) and AMP_THREAD_ID_PATTERN.fullmatch(value): + return value + + if allow_session_state: + value = entry.get("lastThreadId") + if isinstance(value, str) and AMP_THREAD_ID_PATTERN.fullmatch(value): + return value + + message = entry.get("message") + if isinstance(message, str) and "Switching to thread:" in message: + match = AMP_THREAD_ID_PATTERN.search(message) + if match: + return match.group(0) + + return None + + +def parse_amp_thread_ids_from_log( + log_lines: Iterable[str] | str, + candidate_pids: set[int] | None = None, +) -> dict[int, str]: + """Return the latest known Amp thread ID for each live PID in the CLI log.""" + latest_thread_by_pid: dict[int, tuple[str, str]] = {} + latest_session_start_by_pid: dict[int, str] = {} + line_iterator = log_lines.splitlines() if isinstance(log_lines, str) else log_lines + + for raw_line in line_iterator: + line = raw_line.strip() + if not line: + continue + + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + + try: + pid = int(entry["pid"]) + except (KeyError, TypeError, ValueError): + continue + + if candidate_pids is not None and pid not in candidate_pids: + continue + + timestamp = entry.get("timestamp") + if not isinstance(timestamp, str) or not timestamp: + continue + + message = entry.get("message") + if message == "Loaded session state:": + latest_session_start_by_pid[pid] = timestamp + thread_id = _extract_thread_id_from_log_entry(entry, allow_session_state=True) + if thread_id is None: + latest_thread_by_pid.pop(pid, None) + else: + latest_thread_by_pid[pid] = (timestamp, thread_id) + continue + + session_started_at = latest_session_start_by_pid.get(pid) + if session_started_at is not None and timestamp < session_started_at: + continue + + thread_id = _extract_thread_id_from_log_entry(entry) + if thread_id is None: + continue + + current = latest_thread_by_pid.get(pid) + if current is None or timestamp >= current[0]: + latest_thread_by_pid[pid] = (timestamp, thread_id) + + return {pid: thread_id for pid, (_, thread_id) in latest_thread_by_pid.items()} + + +def discover_amp_sessions(cli_log_path: Path = AMP_CLI_LOG_PATH) -> list[AmpSession]: + """Discover live interactive Amp sessions and resolve their current thread IDs.""" + last_error = "Failed to enumerate running processes with ps" + result = None + for command in AMP_PS_COMMAND_CANDIDATES: + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + timeout=5, + ) + except subprocess.TimeoutExpired: + last_error = f"`{' '.join(command)}` timed out after 5s" + continue + except (FileNotFoundError, PermissionError, OSError) as exc: + last_error = f"`{' '.join(command)}` failed: {exc}" + continue + + if result.returncode == 0: + break + stderr = result.stderr.strip() + stdout = result.stdout.strip() + details = stderr or stdout + if details: + last_error = details + else: + raise RuntimeError(last_error) + + sessions = parse_amp_sessions_from_ps_output(result.stdout) + if not sessions or not cli_log_path.exists(): + return sessions + + with cli_log_path.open() as cli_log: + thread_ids = parse_amp_thread_ids_from_log( + cli_log, + candidate_pids={session.pid for session in sessions}, + ) + for session in sessions: + session.thread_id = thread_ids.get(session.pid) + + return sessions + + +def _amp_session_payload(session: AmpSession) -> dict[str, str | int | None]: + return { + "pid": session.pid, + "mode": session.mode, + "agent_session_id": session.agent_session_id, + "cwd": session.cwd, + "thread_id": session.thread_id, + "stop_command": session.stop_command, + "continue_command": session.continue_command, + } + + +def cmd_amp_restart(args) -> int: + """Resolve live interactive Amp sessions to thread IDs and print restart commands.""" + pid_filter = set(args.pid or []) + + try: + sessions = discover_amp_sessions() + except Exception as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + if pid_filter: + sessions = [session for session in sessions if session.pid in pid_filter] + found_pids = {session.pid for session in sessions} + missing_pids = sorted(pid_filter - found_pids) + if missing_pids: + joined = ", ".join(str(pid) for pid in missing_pids) + print(f"Error: No live interactive Amp session found for PID(s): {joined}", file=sys.stderr) + return 1 + + unresolved = [session for session in sessions if not session.cwd or not session.thread_id] + + if getattr(args, "json", False): + output = { + "sessions": [_amp_session_payload(session) for session in sessions], + "summary": { + "total": len(sessions), + "resolved": len(sessions) - len(unresolved), + "unresolved": len(unresolved), + }, + } + print(json.dumps(output)) + return 1 if pid_filter and unresolved else 0 + + if getattr(args, "shell", False): + for index, session in enumerate(sessions): + if index: + print() + if session.continue_command: + print(f"# PID {session.pid} thread={session.thread_id} cwd={session.cwd}") + print(session.stop_command) + print(session.continue_command) + else: + reason = "missing thread ID" if not session.thread_id else "missing cwd" + print(f"# PID {session.pid} unresolved ({reason})", file=sys.stderr) + return 1 if pid_filter and unresolved else 0 + + if not sessions: + print("No live interactive Amp sessions found") + return 0 + + for session in sessions: + session_label = session.agent_session_id or "-" + mode_label = session.mode or "-" + print(f"PID {session.pid} session={session_label} mode={mode_label}") + print(f" cwd: {session.cwd or '(unresolved)'}") + print(f" thread: {session.thread_id or '(unresolved)'}") + print(f" stop: {session.stop_command}") + print(f" continue: {session.continue_command or '(unresolved)'}") + print() + + if unresolved: + print( + f"Unresolved sessions: {len(unresolved)} (missing cwd or thread ID in {AMP_CLI_LOG_PATH})", + file=sys.stderr, + ) + + return 1 if pid_filter and unresolved else 0 diff --git a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/TaskQueueDatabase.kt b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/TaskQueueDatabase.kt index 4f9e273..91d0e9b 100644 --- a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/TaskQueueDatabase.kt +++ b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/TaskQueueDatabase.kt @@ -77,13 +77,23 @@ object TaskQueueDatabase { } private fun ResultSet.getNullableInt(columnName: String): Int? { - val value = getObject(columnName) ?: return null - return when (value) { - is Int -> value - is Long -> value.toInt() - is Number -> value.toInt() - else -> value.toString().toIntOrNull() + return coerceNullableInt(getObject(columnName)) +} + +internal fun coerceNullableInt(value: Any?): Int? { + val longValue = when (value) { + null -> return null + is Int -> return value + is Long -> value + is Short -> value.toLong() + is Byte -> value.toLong() + is Number -> value.toLong() + else -> value.toString().toLongOrNull() ?: return null } + + return longValue + .takeIf { it in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong() } + ?.toInt() } private fun ResultSet.columnNames(): Set { diff --git a/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/TaskQueueDatabaseTest.kt b/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/TaskQueueDatabaseTest.kt new file mode 100644 index 0000000..b2a505c --- /dev/null +++ b/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/TaskQueueDatabaseTest.kt @@ -0,0 +1,19 @@ +package com.block.agenttaskqueue.sidecar + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class TaskQueueDatabaseTest { + @Test + fun coerceNullableIntKeepsInRangeValues() { + assertEquals(42, coerceNullableInt(42L)) + assertEquals(7, coerceNullableInt("7")) + } + + @Test + fun coerceNullableIntRejectsOutOfRangeValues() { + assertNull(coerceNullableInt(Long.MAX_VALUE)) + assertNull(coerceNullableInt("2147483648")) + } +} diff --git a/pyproject.toml b/pyproject.toml index 7884bd9..bdd2278 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,4 +30,4 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["."] -only-include = ["task_queue.py", "tq.py", "queue_core.py"] +only-include = ["task_queue.py", "tq.py", "queue_core.py", "amp_restart.py"] diff --git a/queue_core.py b/queue_core.py index b5cfd80..ec4d96a 100644 --- a/queue_core.py +++ b/queue_core.py @@ -211,7 +211,7 @@ def collect_task_origin(working_directory: str, agent_name: str | None = None) - worktree_root=worktree_root, repo_name=repo_name, git_branch=git_branch, - agent_name=agent_name or None, + agent_name=agent_name, ) @@ -233,7 +233,12 @@ def _git_context(working_directory: str) -> tuple[str | None, str | None, str | text=True, timeout=GIT_METADATA_TIMEOUT_SECONDS, ) - except (OSError, subprocess.SubprocessError): + except ( + FileNotFoundError, + NotADirectoryError, + PermissionError, + subprocess.SubprocessError, + ): return None, None, None if result.returncode != 0: diff --git a/task_queue.py b/task_queue.py index 9d11d10..4590efa 100644 --- a/task_queue.py +++ b/task_queue.py @@ -160,6 +160,19 @@ def log_metric(event: str, **kwargs): _log_metric(PATHS.metrics_path, event, MAX_METRICS_SIZE_MB, **kwargs) +def _current_context(): + """Best-effort FastMCP request context; unavailable in tests and background codepaths.""" + try: + return get_context() + except LookupError: + return None + + +def _current_client_id() -> str | None: + ctx = _current_context() + return ctx.client_id if ctx and ctx.client_id else None + + def cleanup_queue(conn, queue_name: str, queue_capacities: dict[str, int] | None = None): """Clean up queue using configured paths and detect orphaned tasks.""" if queue_capacities is None: @@ -290,11 +303,7 @@ async def wait_for_turn( cleanup_queue(conn, queue_name, QUEUE_CAPACITIES) my_pid = os.getpid() - ctx = None - try: - ctx = get_context() - except LookupError: - pass # Running outside request context (e.g., in tests) + ctx = _current_context() with get_db() as conn: task_id = insert_waiting_task( @@ -395,11 +404,7 @@ async def release_lock(task_id: int): with _active_task_ids_lock: _active_task_ids.discard(task_id) - ctx = None - try: - ctx = get_context() - except LookupError: - pass + ctx = _current_context() try: with get_db() as conn: @@ -504,13 +509,7 @@ async def run_task( key, value = pair.split("=", 1) env[key.strip()] = value.strip() - ctx = None - try: - ctx = get_context() - except LookupError: - pass - - caller_name = agent_name.strip() or (ctx.client_id if ctx and ctx.client_id else None) + caller_name = agent_name.strip() or _current_client_id() task_origin = collect_task_origin(working_directory, caller_name) task_id = await wait_for_turn(queue_name, command, task_origin=task_origin) diff --git a/tests/test_tq_cli.py b/tests/test_tq_cli.py index 60e8001..8759923 100644 --- a/tests/test_tq_cli.py +++ b/tests/test_tq_cli.py @@ -4,6 +4,7 @@ """ import argparse +import amp_restart import json import os import signal @@ -598,8 +599,8 @@ def test_extract_env_value_preserves_paths_with_spaces(self): "AGENT_SESSION_ID=20260420_6 SHELL=/bin/zsh" ) - assert tq._extract_env_value(process_line, "PWD") == "/Users/sedwards/Development/Repo With Spaces" - assert tq._extract_env_value(process_line, "AGENT_SESSION_ID") == "20260420_6" + assert amp_restart._extract_env_value(process_line, "PWD") == "/Users/sedwards/Development/Repo With Spaces" + assert amp_restart._extract_env_value(process_line, "AGENT_SESSION_ID") == "20260420_6" def test_parse_amp_sessions_filters_to_interactive_invocations(self): ps_output = """ @@ -609,7 +610,7 @@ def test_parse_amp_sessions_filters_to_interactive_invocations(self): 91000 /Users/sedwards/.amp/bin/amp --mode=smart PWD=/Users/sedwards/Development/agents AGENT_SESSION_ID=20260422_11 """ - sessions = tq.parse_amp_sessions_from_ps_output(ps_output) + sessions = amp_restart.parse_amp_sessions_from_ps_output(ps_output) assert [(session.pid, session.mode) for session in sessions] == [ (86296, "deep"), @@ -627,7 +628,7 @@ def test_discover_amp_sessions_falls_back_to_linux_ps_flags(self, monkeypatch, t def fake_run(command, capture_output, text, timeout): calls.append(command) - if command == tq.AMP_PS_COMMAND_CANDIDATES[0]: + if command == amp_restart.AMP_PS_COMMAND_CANDIDATES[0]: return SimpleNamespace(returncode=1, stdout="", stderr="must set personality to get -x option") return SimpleNamespace( returncode=0, @@ -635,12 +636,12 @@ def fake_run(command, capture_output, text, timeout): stderr="", ) - monkeypatch.setattr(tq.subprocess, "run", fake_run) + monkeypatch.setattr(amp_restart.subprocess, "run", fake_run) - sessions = tq.discover_amp_sessions(cli_log_path=cli_log_path) + sessions = amp_restart.discover_amp_sessions(cli_log_path=cli_log_path) assert [session.pid for session in sessions] == [86296] - assert calls == tq.AMP_PS_COMMAND_CANDIDATES[:2] + assert calls == amp_restart.AMP_PS_COMMAND_CANDIDATES[:2] def test_parse_amp_thread_ids_uses_latest_entry_per_pid(self): log_text = "\n".join( @@ -669,7 +670,7 @@ def test_parse_amp_thread_ids_uses_latest_entry_per_pid(self): ] ) - assert tq.parse_amp_thread_ids_from_log(log_text, {86296, 88150}) == { + assert amp_restart.parse_amp_thread_ids_from_log(log_text, {86296, 88150}) == { 86296: "T-019dc029-f25d-767c-8005-e2996169f6f8", 88150: "T-019dbfd5-6e1d-7548-b824-f87378e25a8e", } @@ -695,23 +696,23 @@ def test_parse_amp_thread_ids_resets_when_pid_is_reused(self): ] ) - assert tq.parse_amp_thread_ids_from_log(log_text, {86296}) == { + assert amp_restart.parse_amp_thread_ids_from_log(log_text, {86296}) == { 86296: "T-019dcf45-5d79-74e8-9ae4-cde26e8f1971", } def test_amp_restart_shell_output_for_targeted_pids(self, monkeypatch, capsys): monkeypatch.setattr( - tq, + amp_restart, "discover_amp_sessions", lambda: [ - tq.AmpSession( + amp_restart.AmpSession( pid=86296, cwd="/Users/sedwards/Development/block-invert-config", thread_id="T-019dc029-f25d-767c-8005-e2996169f6f8", agent_session_id="20260420_6", mode="deep", ), - tq.AmpSession( + amp_restart.AmpSession( pid=88150, cwd="/Users/sedwards/Development/agent-task-queue", thread_id="T-019dbffa-53be-708c-b468-b62fff98a27d", @@ -720,7 +721,7 @@ def test_amp_restart_shell_output_for_targeted_pids(self, monkeypatch, capsys): ], ) - exit_code = tq.cmd_amp_restart( + exit_code = amp_restart.cmd_amp_restart( argparse.Namespace(pid=[86296], json=False, shell=True) ) @@ -732,10 +733,10 @@ def test_amp_restart_shell_output_for_targeted_pids(self, monkeypatch, capsys): def test_amp_restart_json_fails_for_unresolved_targeted_pid(self, monkeypatch, capsys): monkeypatch.setattr( - tq, + amp_restart, "discover_amp_sessions", lambda: [ - tq.AmpSession( + amp_restart.AmpSession( pid=86296, cwd="/Users/sedwards/Development/block-invert-config", thread_id=None, @@ -745,7 +746,7 @@ def test_amp_restart_json_fails_for_unresolved_targeted_pid(self, monkeypatch, c ], ) - exit_code = tq.cmd_amp_restart( + exit_code = amp_restart.cmd_amp_restart( argparse.Namespace(pid=[86296], json=True, shell=False) ) diff --git a/tq.py b/tq.py index 6e4792e..2da67e4 100644 --- a/tq.py +++ b/tq.py @@ -6,6 +6,7 @@ """ import argparse +import amp_restart import json import os import shlex @@ -15,9 +16,6 @@ import sys import time import uuid -import re -from collections.abc import Iterable -from dataclasses import dataclass from datetime import datetime from pathlib import Path @@ -48,57 +46,6 @@ # Unique identifier for this CLI instance - used to detect orphaned tasks # from previous CLI instances even if the PID is reused CLI_INSTANCE_ID = str(uuid.uuid4())[:8] -AMP_CLI_LOG_PATH = Path.home() / ".cache" / "amp" / "logs" / "cli.log" -AMP_THREAD_ID_PATTERN = re.compile(r"T-[0-9a-f-]{36}") -AMP_ENV_ASSIGNMENT_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") -AMP_ENV_VALUE_PATTERN_TEMPLATE = r"(?:^|\s){name}=(.*?)(?=\s+[A-Za-z_][A-Za-z0-9_]*=|$)" -AMP_PS_COMMAND_CANDIDATES = [ - ["ps", "eww", "-axo", "pid=,command="], - ["ps", "eww", "axo", "pid=,command="], -] -AMP_GLOBAL_FLAGS_WITH_VALUE = { - "--visibility", - "--settings-file", - "--log-level", - "--log-file", - "--mcp-config", - "-l", - "--label", -} -AMP_GLOBAL_BOOLEAN_FLAGS = { - "--notifications", - "--no-notifications", - "--color", - "--no-color", - "--dangerously-allow-all", - "--jetbrains", - "--no-jetbrains", - "--ide", - "--no-ide", - "--stream-json", - "--stream-json-thinking", - "--stream-json-input", - "--archive", -} - - -@dataclass -class AmpSession: - pid: int - cwd: str | None - thread_id: str | None = None - agent_session_id: str | None = None - mode: str | None = None - - @property - def stop_command(self) -> str: - return f"kill -TERM {self.pid}" - - @property - def continue_command(self) -> str | None: - if not self.cwd or not self.thread_id: - return None - return f"(cd {shlex.quote(self.cwd)} && amp threads continue {self.thread_id})" def get_paths(args) -> QueuePaths: @@ -309,305 +256,6 @@ def cmd_logs(args): print(line) -def _extract_env_value(process_line: str, env_name: str) -> str | None: - pattern = AMP_ENV_VALUE_PATTERN_TEMPLATE.format(name=re.escape(env_name)) - match = re.search(pattern, process_line) - if not match: - return None - value = match.group(1).strip() - return value or None - - -def _amp_process_prefix_tokens(process_line: str) -> tuple[int, list[str]] | None: - line = process_line.strip() - if not line: - return None - - try: - pid_text, command = line.split(None, 1) - pid = int(pid_text) - except ValueError: - return None - - argv = [] - for token in command.split(): - if AMP_ENV_ASSIGNMENT_PATTERN.match(token): - break - argv.append(token) - - if not argv: - return None - - return pid, argv - - -def _is_interactive_amp_invocation(argv: list[str]) -> tuple[bool, str | None]: - if not argv or Path(argv[0]).name != "amp": - return False, None - - remaining: list[str] = [] - mode: str | None = None - i = 1 - while i < len(argv): - token = argv[i] - if token in {"-x", "--execute"} or token.startswith("--execute="): - return False, mode - if token in {"-m", "--mode"}: - if i + 1 < len(argv): - mode = argv[i + 1] - i += 2 - continue - if token.startswith("--mode="): - mode = token.split("=", 1)[1] or None - i += 1 - continue - if token in AMP_GLOBAL_FLAGS_WITH_VALUE: - i += 2 - continue - if any(token.startswith(flag + "=") for flag in AMP_GLOBAL_FLAGS_WITH_VALUE if flag.startswith("--")): - i += 1 - continue - if token in AMP_GLOBAL_BOOLEAN_FLAGS: - i += 1 - continue - remaining = argv[i:] - break - - interactive = not remaining or ( - len(remaining) >= 2 - and remaining[0] in {"threads", "thread", "t"} - and remaining[1] in {"continue", "c", "new", "n"} - ) - return interactive, mode - - -def parse_amp_sessions_from_ps_output(ps_output: str) -> list[AmpSession]: - """Parse `ps eww` output and return live interactive Amp sessions.""" - sessions: list[AmpSession] = [] - for line in ps_output.splitlines(): - prefix = _amp_process_prefix_tokens(line) - if prefix is None: - continue - - pid, argv = prefix - interactive, mode = _is_interactive_amp_invocation(argv) - if not interactive: - continue - - sessions.append( - AmpSession( - pid=pid, - cwd=_extract_env_value(line, "PWD"), - agent_session_id=_extract_env_value(line, "AGENT_SESSION_ID"), - mode=mode, - ) - ) - - return sessions - - -def _extract_thread_id_from_log_entry(entry: dict, *, allow_session_state: bool = False) -> str | None: - for key in ("threadId", "threadID", "newThreadID", "currentThreadID"): - value = entry.get(key) - if isinstance(value, str) and AMP_THREAD_ID_PATTERN.fullmatch(value): - return value - - if allow_session_state: - value = entry.get("lastThreadId") - if isinstance(value, str) and AMP_THREAD_ID_PATTERN.fullmatch(value): - return value - - message = entry.get("message") - if isinstance(message, str) and "Switching to thread:" in message: - match = AMP_THREAD_ID_PATTERN.search(message) - if match: - return match.group(0) - - return None - - -def parse_amp_thread_ids_from_log( - log_lines: Iterable[str] | str, - candidate_pids: set[int] | None = None, -) -> dict[int, str]: - """Return the latest known Amp thread ID for each live PID in the CLI log.""" - latest_thread_by_pid: dict[int, tuple[str, str]] = {} - latest_session_start_by_pid: dict[int, str] = {} - line_iterator = log_lines.splitlines() if isinstance(log_lines, str) else log_lines - - for raw_line in line_iterator: - line = raw_line.strip() - if not line: - continue - - try: - entry = json.loads(line) - except json.JSONDecodeError: - continue - - try: - pid = int(entry["pid"]) - except (KeyError, TypeError, ValueError): - continue - - if candidate_pids is not None and pid not in candidate_pids: - continue - - timestamp = entry.get("timestamp") - if not isinstance(timestamp, str) or not timestamp: - continue - - message = entry.get("message") - if message == "Loaded session state:": - latest_session_start_by_pid[pid] = timestamp - thread_id = _extract_thread_id_from_log_entry(entry, allow_session_state=True) - if thread_id is None: - latest_thread_by_pid.pop(pid, None) - else: - latest_thread_by_pid[pid] = (timestamp, thread_id) - continue - - session_started_at = latest_session_start_by_pid.get(pid) - if session_started_at is not None and timestamp < session_started_at: - continue - - thread_id = _extract_thread_id_from_log_entry(entry) - - if thread_id is None: - continue - - current = latest_thread_by_pid.get(pid) - if current is None or timestamp >= current[0]: - latest_thread_by_pid[pid] = (timestamp, thread_id) - - return {pid: thread_id for pid, (_, thread_id) in latest_thread_by_pid.items()} - - -def discover_amp_sessions(cli_log_path: Path = AMP_CLI_LOG_PATH) -> list[AmpSession]: - """Discover live interactive Amp sessions and resolve their current thread IDs.""" - last_error = "Failed to enumerate running processes with ps" - result = None - for command in AMP_PS_COMMAND_CANDIDATES: - try: - result = subprocess.run( - command, - capture_output=True, - text=True, - timeout=5, - ) - except subprocess.TimeoutExpired: - last_error = f"`{' '.join(command)}` timed out after 5s" - continue - except (FileNotFoundError, PermissionError, OSError) as exc: - last_error = f"`{' '.join(command)}` failed: {exc}" - continue - - if result.returncode == 0: - break - stderr = result.stderr.strip() - stdout = result.stdout.strip() - details = stderr or stdout - if details: - last_error = details - else: - raise RuntimeError(last_error) - - sessions = parse_amp_sessions_from_ps_output(result.stdout) - if not sessions or not cli_log_path.exists(): - return sessions - - with cli_log_path.open() as cli_log: - thread_ids = parse_amp_thread_ids_from_log( - cli_log, - candidate_pids={session.pid for session in sessions}, - ) - for session in sessions: - session.thread_id = thread_ids.get(session.pid) - - return sessions - - -def _amp_session_payload(session: AmpSession) -> dict[str, str | int | None]: - return { - "pid": session.pid, - "mode": session.mode, - "agent_session_id": session.agent_session_id, - "cwd": session.cwd, - "thread_id": session.thread_id, - "stop_command": session.stop_command, - "continue_command": session.continue_command, - } - - -def cmd_amp_restart(args) -> int: - """Resolve live interactive Amp sessions to thread IDs and print restart commands.""" - pid_filter = set(args.pid or []) - - try: - sessions = discover_amp_sessions() - except Exception as exc: - print(f"Error: {exc}", file=sys.stderr) - return 1 - - if pid_filter: - sessions = [session for session in sessions if session.pid in pid_filter] - found_pids = {session.pid for session in sessions} - missing_pids = sorted(pid_filter - found_pids) - if missing_pids: - joined = ", ".join(str(pid) for pid in missing_pids) - print(f"Error: No live interactive Amp session found for PID(s): {joined}", file=sys.stderr) - return 1 - - unresolved = [session for session in sessions if not session.cwd or not session.thread_id] - - if getattr(args, "json", False): - output = { - "sessions": [_amp_session_payload(session) for session in sessions], - "summary": { - "total": len(sessions), - "resolved": len(sessions) - len(unresolved), - "unresolved": len(unresolved), - }, - } - print(json.dumps(output)) - return 1 if pid_filter and unresolved else 0 - - if getattr(args, "shell", False): - for index, session in enumerate(sessions): - if index: - print() - if session.continue_command: - print(f"# PID {session.pid} thread={session.thread_id} cwd={session.cwd}") - print(session.stop_command) - print(session.continue_command) - else: - reason = "missing thread ID" if not session.thread_id else "missing cwd" - print(f"# PID {session.pid} unresolved ({reason})", file=sys.stderr) - return 1 if pid_filter and unresolved else 0 - - if not sessions: - print("No live interactive Amp sessions found") - return 0 - - for session in sessions: - session_label = session.agent_session_id or "-" - mode_label = session.mode or "-" - print(f"PID {session.pid} session={session_label} mode={mode_label}") - print(f" cwd: {session.cwd or '(unresolved)'}") - print(f" thread: {session.thread_id or '(unresolved)'}") - print(f" stop: {session.stop_command}") - print(f" continue: {session.continue_command or '(unresolved)'}") - print() - - if unresolved: - print( - f"Unresolved sessions: {len(unresolved)} (missing cwd or thread ID in {AMP_CLI_LOG_PATH})", - file=sys.stderr, - ) - - return 1 if pid_filter and unresolved else 0 - - # --- Run Command Implementation --- def log_metric(paths: QueuePaths, event: str, **kwargs): @@ -965,24 +613,7 @@ def main(): logs_parser.add_argument("-n", type=int, default=20, help="Number of entries (default: 20)") logs_parser.add_argument("--json", action="store_true", help="Output in JSON format") - # amp-restart - amp_restart_parser = subparsers.add_parser( - "amp-restart", - help="Resolve live interactive Amp sessions to thread IDs and print restart commands", - ) - amp_restart_parser.add_argument( - "--pid", - action="append", - type=int, - default=[], - help="Target a specific live Amp PID. Repeatable. Defaults to all live interactive Amp sessions.", - ) - amp_restart_parser.add_argument("--json", action="store_true", help="Output in JSON format") - amp_restart_parser.add_argument( - "--shell", - action="store_true", - help="Print shell commands only (kill + amp threads continue)", - ) + amp_restart.add_amp_restart_subparser(subparsers) # Handle implicit run: tq ./gradlew build -> tq run ./gradlew build # Pre-process argv to insert 'run' if needed @@ -1023,7 +654,7 @@ def main(): elif args.command == "logs": cmd_logs(args) elif args.command == "amp-restart": - sys.exit(cmd_amp_restart(args)) + sys.exit(amp_restart.cmd_amp_restart(args)) else: parser.print_help() From 7a4c1f40547268102230c47ac3642bf8c6e84c2f Mon Sep 17 00:00:00 2001 From: Stephen Edwards Date: Mon, 27 Apr 2026 11:34:05 -0400 Subject: [PATCH 11/16] Fix sidecar process inspection regressions Amp-Thread-ID: https://ampcode.com/threads/T-019dcf84-27c3-7334-aea3-769c5efdc4bb Co-authored-by: Amp --- .../sidecar/EnvironmentSnapshot.kt | 50 ++++++++++++++++--- .../sidecar/EnvironmentSnapshotTest.kt | 38 ++++++++++++++ 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshot.kt b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshot.kt index 00b77fd..87a0f4c 100644 --- a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshot.kt +++ b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshot.kt @@ -2,12 +2,14 @@ package com.block.agenttaskqueue.sidecar import java.nio.file.Path import java.nio.file.Paths +import java.util.concurrent.Executors import java.util.concurrent.TimeUnit private val DEFAULT_QUEUE_DATA_DIR: Path = Paths.get("/tmp/agent-task-queue").toAbsolutePath().normalize() private const val COMMAND_TIMEOUT_SECONDS = 5L private val EMULATOR_SERIAL_PATTERN = Regex("^(?:emu|emulator)-(\\d+)$", RegexOption.IGNORE_CASE) private val LOCALHOST_EMULATOR_PATTERN = Regex("^(?:127\\.0\\.0\\.1|localhost):(\\d+)$", RegexOption.IGNORE_CASE) +private val TASK_QUEUE_ENTRYPOINT_NAMES = setOf("agent-task-queue", "task_queue", "task_queue.py") data class QueueConfigurationSnapshot( val serverProcesses: List, @@ -288,12 +290,41 @@ private fun looksLikeTaskQueueServer(tokens: List): Boolean { val executable = tokens.first().substringAfterLast('/') if (executable.startsWith("python")) { - return tokens.any { it.endsWith("task_queue.py") } + return firstPythonEntrypointToken(tokens.drop(1)) + ?.let(::looksLikeTaskQueueEntrypoint) + ?: false } - return executable == "agent-task-queue" || - executable == "task_queue" || - executable == "task_queue.py" + return tokens.first().let(::looksLikeTaskQueueEntrypoint) +} + +private fun looksLikeTaskQueueEntrypoint(token: String): Boolean { + return token.substringAfterLast('/') in TASK_QUEUE_ENTRYPOINT_NAMES +} + +private fun firstPythonEntrypointToken(tokens: List): String? { + var index = 0 + while (index < tokens.size) { + val token = tokens[index] + when { + token == "-m" -> return tokens.getOrNull(index + 1) + token == "-c" -> return null + token == "-W" || token == "-X" -> index += 2 + token.startsWith('-') -> index += 1 + looksLikeEnvironmentAssignment(token) -> index += 1 + else -> return token + } + } + return null +} + +private fun looksLikeEnvironmentAssignment(token: String): Boolean { + val separator = token.indexOf('=') + if (separator <= 0) return false + + val name = token.substring(0, separator) + val startsLikeEnvName = name.firstOrNull()?.let { it == '_' || it.isLetter() } == true + return startsLikeEnvName && name.all { it == '_' || it.isLetterOrDigit() } } private fun inferProcessAgentLabel( @@ -455,14 +486,19 @@ private fun parseAdbDevice(line: String): AdbDevice? { ) } -private fun runCommand(vararg command: String): CommandResult { +internal fun runCommand(vararg command: String): CommandResult { + val outputReader = Executors.newSingleThreadExecutor() return try { val process = ProcessBuilder(*command) .redirectErrorStream(true) .start() + val outputFuture = outputReader.submit { + process.inputStream.bufferedReader().use { it.readText() } + } if (!process.waitFor(COMMAND_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { process.destroyForcibly() process.waitFor() + outputFuture.cancel(true) return CommandResult( output = "", exitCode = -1, @@ -470,7 +506,7 @@ private fun runCommand(vararg command: String): CommandResult { ) } - val output = process.inputStream.bufferedReader().use { it.readText() } + val output = outputFuture.get() CommandResult(output = output.trim(), exitCode = process.exitValue()) } catch (error: Exception) { CommandResult( @@ -478,6 +514,8 @@ private fun runCommand(vararg command: String): CommandResult { exitCode = -1, errorMessage = error.message ?: "Failed to run `${command.joinToString(" ")}`.", ) + } finally { + outputReader.shutdownNow() } } diff --git a/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshotTest.kt b/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshotTest.kt index 359d50d..3a51e63 100644 --- a/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshotTest.kt +++ b/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshotTest.kt @@ -7,6 +7,19 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class EnvironmentSnapshotTest { + @Test + fun commandRunnerDrainsLargeStdoutWithoutTimingOut() { + val result = runCommand( + "python3", + "-c", + "import sys; sys.stdout.write('x' * 200000)", + ) + + assertEquals(null, result.errorMessage) + assertEquals(0, result.exitCode) + assertEquals(200000, result.output.length) + } + @Test fun parsesLiveTaskQueueServerCapacitiesFromPsOutput() { val processes = parseTaskQueueProcesses( @@ -36,6 +49,31 @@ class EnvironmentSnapshotTest { assertEquals(2, process.queueCapacities["gradle"]) } + @Test + fun parsesInstalledAgentTaskQueueEntrypointLaunchedUnderPython() { + val process = parseTaskQueueProcesses( + """ + 73781 1 /Users/me/.venv/bin/python3 /Users/me/.venv/bin/agent-task-queue --queue-capacity=gradle=2 --queue-capacity=gradle/emulator-5554=1 TASK_QUEUE_DATA_DIR=/tmp/custom-queue + """.trimIndent() + ).single() + + assertEquals(Paths.get("/tmp/custom-queue"), process.dataDir) + assertEquals(2, process.queueCapacities["gradle"]) + assertEquals(1, process.queueCapacities["gradle/emulator-5554"]) + } + + @Test + fun parsesInstalledEntrypointWhenPythonUsesFlagValuesWithEquals() { + val process = parseTaskQueueProcesses( + """ + 73781 1 /Users/me/.venv/bin/python3 -X faulthandler=1 /Users/me/.venv/bin/agent-task-queue --queue-capacity=gradle=2 TASK_QUEUE_DATA_DIR=/tmp/custom-queue + """.trimIndent() + ).single() + + assertEquals(Paths.get("/tmp/custom-queue"), process.dataDir) + assertEquals(2, process.queueCapacities["gradle"]) + } + @Test fun parsesAdbDevicesAndMatchesEmulatorPorts() { val adb = parseAdbSnapshot( From e3100e9a237e7e9d277ae9179189d9ea01252c34 Mon Sep 17 00:00:00 2001 From: Stephen Edwards Date: Mon, 27 Apr 2026 11:37:53 -0400 Subject: [PATCH 12/16] Handle sidecar queue schema bootstrap state Amp-Thread-ID: https://ampcode.com/threads/T-019dcf84-27c3-7334-aea3-769c5efdc4bb Co-authored-by: Amp --- .../sidecar/TaskQueueDatabase.kt | 22 ++++++++++++++++++ .../sidecar/TaskQueueDatabaseTest.kt | 23 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/TaskQueueDatabase.kt b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/TaskQueueDatabase.kt index 91d0e9b..6487c40 100644 --- a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/TaskQueueDatabase.kt +++ b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/TaskQueueDatabase.kt @@ -1,5 +1,6 @@ package com.block.agenttaskqueue.sidecar +import java.sql.Connection import java.nio.file.Path import java.sql.DriverManager import java.sql.ResultSet @@ -31,6 +32,16 @@ object TaskQueueDatabase { statement.execute("PRAGMA busy_timeout=5000") } + if (!connection.hasTable("queue")) { + return QueueSnapshot.empty( + dataDir = dataDir, + configuration = configuration, + adb = adb, + metrics = metrics, + statusMessage = "Waiting for queue schema at $dbPath", + ) + } + connection.createStatement().use { statement -> statement.executeQuery("SELECT * FROM queue ORDER BY queue_name, id").use { rs -> val availableColumns = rs.columnNames() @@ -76,6 +87,17 @@ object TaskQueueDatabase { } } +private fun Connection.hasTable(tableName: String): Boolean { + prepareStatement( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1" + ).use { statement -> + statement.setString(1, tableName) + statement.executeQuery().use { resultSet -> + return resultSet.next() + } + } +} + private fun ResultSet.getNullableInt(columnName: String): Int? { return coerceNullableInt(getObject(columnName)) } diff --git a/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/TaskQueueDatabaseTest.kt b/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/TaskQueueDatabaseTest.kt index b2a505c..f89db6e 100644 --- a/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/TaskQueueDatabaseTest.kt +++ b/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/TaskQueueDatabaseTest.kt @@ -1,10 +1,33 @@ package com.block.agenttaskqueue.sidecar +import java.nio.file.Files +import java.sql.DriverManager import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull class TaskQueueDatabaseTest { + @Test + fun missingQueueTableIsTreatedAsWaitingForSchema() { + val tempDir = Files.createTempDirectory("task-queue-db-test") + try { + val dbPath = tempDir.resolve("queue.db") + DriverManager.getConnection("jdbc:sqlite:$dbPath").use { connection -> + connection.createStatement().use { statement -> + statement.execute("CREATE TABLE metadata (id INTEGER PRIMARY KEY)") + } + } + + val snapshot = TaskQueueDatabase.loadSnapshot(tempDir) + + assertEquals(emptyList(), snapshot.tasks) + assertEquals("Waiting for queue schema at $dbPath", snapshot.statusMessage) + assertNull(snapshot.errorMessage) + } finally { + tempDir.toFile().deleteRecursively() + } + } + @Test fun coerceNullableIntKeepsInRangeValues() { assertEquals(42, coerceNullableInt(42L)) From 3613119d24de47d7871b0bb6e0ab847701b5ea5d Mon Sep 17 00:00:00 2001 From: Stephen Edwards Date: Mon, 27 Apr 2026 11:51:43 -0400 Subject: [PATCH 13/16] Sanitize OSS test fixtures Amp-Thread-ID: https://ampcode.com/threads/T-019dcf84-27c3-7334-aea3-769c5efdc4bb Co-authored-by: Amp --- .../sidecar/EnvironmentSnapshotTest.kt | 4 +- .../sidecar/MetricsSnapshotTest.kt | 8 +-- .../sidecar/QueueSnapshotTest.kt | 24 +++---- tests/test_tq_cli.py | 68 +++++++++++-------- 4 files changed, 57 insertions(+), 47 deletions(-) diff --git a/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshotTest.kt b/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshotTest.kt index 3a51e63..de7ea6d 100644 --- a/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshotTest.kt +++ b/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshotTest.kt @@ -107,7 +107,7 @@ class EnvironmentSnapshotTest { val process = parseTaskQueueProcesses( """ 900 1 amp -m deep - 901 900 uv run --directory /Users/me/Development/agent-task-queue-worktrees/queue-visibility python task_queue.py --data-dir /tmp/agent-task-queue --queue-capacity=gradle=2 + 901 900 uv run --directory /Users/example/Development/sample-repo-worktrees/feature-queue-view python task_queue.py --data-dir /tmp/agent-task-queue --queue-capacity=gradle=2 902 901 /Users/me/.venv/bin/python3 task_queue.py --data-dir /tmp/agent-task-queue --queue-capacity=gradle=2 """.trimIndent() ).single() @@ -115,6 +115,6 @@ class EnvironmentSnapshotTest { assertEquals(902, process.pid) assertEquals(901, process.parentPid) assertEquals("Amp deep", process.agentLabel) - assertEquals("queue-visibility", process.contextLabel) + assertEquals("feature-queue-view", process.contextLabel) } } diff --git a/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/MetricsSnapshotTest.kt b/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/MetricsSnapshotTest.kt index 4d7e2b7..425b1f4 100644 --- a/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/MetricsSnapshotTest.kt +++ b/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/MetricsSnapshotTest.kt @@ -8,15 +8,15 @@ class MetricsSnapshotTest { fun parseMetricsSnapshotKeepsLatestUsagePerPid() { val snapshot = parseMetricsSnapshot( """ - {"event":"task_queued","timestamp":"2026-04-24T11:00:01.389560","task_id":74,"queue_name":"global","pid":88226,"agent_name":"amp","repo_name":"agent-task-queue","git_branch":"sedwards/no-ticket/queue-visibility"} - {"event":"task_completed","timestamp":"2026-04-24T11:10:13.561185","task_id":77,"queue_name":"global","pid":88226,"agent_name":"amp","repo_name":"android-register","git_branch":"sedwards/no-ticket/real-work"} - {"event":"task_completed","timestamp":"2026-04-24T11:10:33.478135","task_id":78,"queue_name":"gradle","pid":74067,"agent_name":"claude","repo_name":"cash-android","git_branch":"feature/payments"} + {"event":"task_queued","timestamp":"2026-04-24T11:00:01.389560","task_id":74,"queue_name":"global","pid":88226,"agent_name":"amp","repo_name":"sample-repo","git_branch":"feature/queue-visibility"} + {"event":"task_completed","timestamp":"2026-04-24T11:10:13.561185","task_id":77,"queue_name":"global","pid":88226,"agent_name":"amp","repo_name":"sample-mobile-app","git_branch":"feature/real-work"} + {"event":"task_completed","timestamp":"2026-04-24T11:10:33.478135","task_id":78,"queue_name":"gradle","pid":74067,"agent_name":"claude","repo_name":"payments-app","git_branch":"feature/payments"} """.trimIndent() ) assertEquals(2, snapshot.latestUsageByPid.size) assertEquals( - "android-register · sedwards/no-ticket/real-work", + "sample-mobile-app · feature/real-work", snapshot.latestUsageByPid.getValue(88226).displayContextLabel, ) assertEquals("Amp", snapshot.latestUsageByPid.getValue(88226).displayAgentLabel) diff --git a/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/QueueSnapshotTest.kt b/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/QueueSnapshotTest.kt index e7cff30..bf9c9b5 100644 --- a/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/QueueSnapshotTest.kt +++ b/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/QueueSnapshotTest.kt @@ -83,16 +83,16 @@ class QueueSnapshotTest { childPid = 8112, createdAt = null, updatedAt = null, - workingDirectory = "/Users/me/Development/agent-task-queue", - worktreeRoot = "/Users/me/Development/agent-task-queue-worktrees/queue-visibility", - repoName = "agent-task-queue", - gitBranch = "sedwards/no-ticket/queue-visibility", + workingDirectory = "/Users/example/Development/sample-repo", + worktreeRoot = "/Users/example/Development/sample-repo-worktrees/feature-queue-view", + repoName = "sample-repo", + gitBranch = "feature/queue-view", agentName = "amp", ) assertEquals("Amp", task.displayAgentLabel) - assertEquals("agent-task-queue · sedwards/no-ticket/queue-visibility", task.displayContextLabel) - assertEquals("Amp · agent-task-queue · sedwards/no-ticket/queue-visibility", task.displayIdentityLabel) + assertEquals("sample-repo · feature/queue-view", task.displayContextLabel) + assertEquals("Amp · sample-repo · feature/queue-view", task.displayIdentityLabel) } @Test @@ -109,8 +109,8 @@ class QueueSnapshotTest { childPid = null, createdAt = null, updatedAt = null, - repoName = "android-register", - gitBranch = "sedwards/no-ticket/real-work", + repoName = "sample-mobile-app", + gitBranch = "feature/real-work", agentName = "amp", ) ), @@ -131,7 +131,7 @@ class QueueSnapshotTest { val identity = snapshot.serverIdentityByPid.getValue(902) assertEquals("Amp", identity.primaryLabel) - assertEquals("android-register · sedwards/no-ticket/real-work", identity.contextLabel) + assertEquals("sample-mobile-app · feature/real-work", identity.contextLabel) assertEquals("desktop-sidecar", identity.launchContextLabel) } @@ -185,8 +185,8 @@ class QueueSnapshotTest { 902 to HistoricalTaskUsage( pid = 902, timestamp = "2026-04-24T11:10:13.561185", - repoName = "android-register", - gitBranch = "sedwards/no-ticket/real-work", + repoName = "sample-mobile-app", + gitBranch = "feature/real-work", agentName = "amp", ) ) @@ -194,7 +194,7 @@ class QueueSnapshotTest { ) val identity = snapshot.serverIdentityByPid.getValue(902) - assertEquals("Amp · android-register · sedwards/no-ticket/real-work", identity.displayLabel) + assertEquals("Amp · sample-mobile-app · feature/real-work", identity.displayLabel) assertEquals("desktop-sidecar", identity.launchContextLabel) assertEquals("idle server", identity.detailLabel) } diff --git a/tests/test_tq_cli.py b/tests/test_tq_cli.py index 8759923..cd1567c 100644 --- a/tests/test_tq_cli.py +++ b/tests/test_tq_cli.py @@ -24,6 +24,16 @@ # Path to tq.py TQ_PATH = Path(__file__).parent.parent / "tq.py" T = TypeVar("T") +SAMPLE_AMP_SPACE_CWD = "/Users/example/Development/Repo With Spaces" +SAMPLE_AMP_APP_CWD = "/Users/example/Development/sample-app" +SAMPLE_AMP_REPO_CWD = "/Users/example/Development/public-repo" +SAMPLE_AMP_TOOLING_CWD = "/Users/example/Development/tooling" +SAMPLE_AMP_SESSION_ID = "session-6" +SAMPLE_AMP_SESSION_ID_ALT = "session-11" +SAMPLE_THREAD_ID_1 = "T-00000000-0000-0000-0000-000000000001" +SAMPLE_THREAD_ID_2 = "T-00000000-0000-0000-0000-000000000002" +SAMPLE_THREAD_ID_3 = "T-00000000-0000-0000-0000-000000000003" +SAMPLE_THREAD_ID_4 = "T-00000000-0000-0000-0000-000000000004" @pytest.fixture @@ -595,19 +605,19 @@ def test_run_help(self): class TestAmpRestart: def test_extract_env_value_preserves_paths_with_spaces(self): process_line = ( - "86296 amp -m deep PWD=/Users/sedwards/Development/Repo With Spaces " - "AGENT_SESSION_ID=20260420_6 SHELL=/bin/zsh" + f"86296 amp -m deep PWD={SAMPLE_AMP_SPACE_CWD} " + f"AGENT_SESSION_ID={SAMPLE_AMP_SESSION_ID} SHELL=/bin/zsh" ) - assert amp_restart._extract_env_value(process_line, "PWD") == "/Users/sedwards/Development/Repo With Spaces" - assert amp_restart._extract_env_value(process_line, "AGENT_SESSION_ID") == "20260420_6" + assert amp_restart._extract_env_value(process_line, "PWD") == SAMPLE_AMP_SPACE_CWD + assert amp_restart._extract_env_value(process_line, "AGENT_SESSION_ID") == SAMPLE_AMP_SESSION_ID def test_parse_amp_sessions_filters_to_interactive_invocations(self): - ps_output = """ -86296 amp -m deep PWD=/Users/sedwards/Development/block-invert-config AGENT_SESSION_ID=20260420_6 -88150 amp threads continue T-019dbffa-53be-708c-b468-b62fff98a27d PWD=/Users/sedwards/Development/agent-task-queue -90000 amp threads search --json repo:block/agent-task-queue PWD=/tmp -91000 /Users/sedwards/.amp/bin/amp --mode=smart PWD=/Users/sedwards/Development/agents AGENT_SESSION_ID=20260422_11 + ps_output = f""" +86296 amp -m deep PWD={SAMPLE_AMP_APP_CWD} AGENT_SESSION_ID={SAMPLE_AMP_SESSION_ID} +88150 amp threads continue {SAMPLE_THREAD_ID_1} PWD={SAMPLE_AMP_REPO_CWD} +90000 amp threads search --json repo:example/public-repo PWD=/tmp +91000 /Users/example/.amp/bin/amp --mode=smart PWD={SAMPLE_AMP_TOOLING_CWD} AGENT_SESSION_ID={SAMPLE_AMP_SESSION_ID_ALT} """ sessions = amp_restart.parse_amp_sessions_from_ps_output(ps_output) @@ -617,9 +627,9 @@ def test_parse_amp_sessions_filters_to_interactive_invocations(self): (88150, None), (91000, "smart"), ] - assert sessions[0].cwd == "/Users/sedwards/Development/block-invert-config" - assert sessions[0].agent_session_id == "20260420_6" - assert sessions[1].cwd == "/Users/sedwards/Development/agent-task-queue" + assert sessions[0].cwd == SAMPLE_AMP_APP_CWD + assert sessions[0].agent_session_id == SAMPLE_AMP_SESSION_ID + assert sessions[1].cwd == SAMPLE_AMP_REPO_CWD def test_discover_amp_sessions_falls_back_to_linux_ps_flags(self, monkeypatch, tmp_path): cli_log_path = tmp_path / "cli.log" @@ -632,7 +642,7 @@ def fake_run(command, capture_output, text, timeout): return SimpleNamespace(returncode=1, stdout="", stderr="must set personality to get -x option") return SimpleNamespace( returncode=0, - stdout="86296 amp -m deep PWD=/tmp AGENT_SESSION_ID=20260420_6\n", + stdout=f"86296 amp -m deep PWD=/tmp AGENT_SESSION_ID={SAMPLE_AMP_SESSION_ID}\n", stderr="", ) @@ -650,29 +660,29 @@ def test_parse_amp_thread_ids_uses_latest_entry_per_pid(self): { "pid": 86296, "timestamp": "2026-04-24T15:00:00.000Z", - "threadId": "T-019dbffa-53be-708c-b468-b62fff98a27d", + "threadId": SAMPLE_THREAD_ID_1, } ), json.dumps( { "pid": 86296, "timestamp": "2026-04-24T15:05:00.000Z", - "message": "[switchToExistingThread] Switching to thread: T-019dc029-f25d-767c-8005-e2996169f6f8", + "message": f"[switchToExistingThread] Switching to thread: {SAMPLE_THREAD_ID_2}", } ), json.dumps( { "pid": 88150, "timestamp": "2026-04-24T15:10:00.000Z", - "newThreadID": "T-019dbfd5-6e1d-7548-b824-f87378e25a8e", + "newThreadID": SAMPLE_THREAD_ID_3, } ), ] ) assert amp_restart.parse_amp_thread_ids_from_log(log_text, {86296, 88150}) == { - 86296: "T-019dc029-f25d-767c-8005-e2996169f6f8", - 88150: "T-019dbfd5-6e1d-7548-b824-f87378e25a8e", + 86296: SAMPLE_THREAD_ID_2, + 88150: SAMPLE_THREAD_ID_3, } def test_parse_amp_thread_ids_resets_when_pid_is_reused(self): @@ -682,7 +692,7 @@ def test_parse_amp_thread_ids_resets_when_pid_is_reused(self): { "pid": 86296, "timestamp": "2026-04-24T15:05:00.000Z", - "threadId": "T-019dc029-f25d-767c-8005-e2996169f6f8", + "threadId": SAMPLE_THREAD_ID_2, } ), json.dumps( @@ -690,14 +700,14 @@ def test_parse_amp_thread_ids_resets_when_pid_is_reused(self): "pid": 86296, "timestamp": "2026-04-24T15:20:00.000Z", "message": "Loaded session state:", - "lastThreadId": "T-019dcf45-5d79-74e8-9ae4-cde26e8f1971", + "lastThreadId": SAMPLE_THREAD_ID_4, } ), ] ) assert amp_restart.parse_amp_thread_ids_from_log(log_text, {86296}) == { - 86296: "T-019dcf45-5d79-74e8-9ae4-cde26e8f1971", + 86296: SAMPLE_THREAD_ID_4, } def test_amp_restart_shell_output_for_targeted_pids(self, monkeypatch, capsys): @@ -707,15 +717,15 @@ def test_amp_restart_shell_output_for_targeted_pids(self, monkeypatch, capsys): lambda: [ amp_restart.AmpSession( pid=86296, - cwd="/Users/sedwards/Development/block-invert-config", - thread_id="T-019dc029-f25d-767c-8005-e2996169f6f8", - agent_session_id="20260420_6", + cwd=SAMPLE_AMP_APP_CWD, + thread_id=SAMPLE_THREAD_ID_2, + agent_session_id=SAMPLE_AMP_SESSION_ID, mode="deep", ), amp_restart.AmpSession( pid=88150, - cwd="/Users/sedwards/Development/agent-task-queue", - thread_id="T-019dbffa-53be-708c-b468-b62fff98a27d", + cwd=SAMPLE_AMP_REPO_CWD, + thread_id=SAMPLE_THREAD_ID_1, mode="deep", ), ], @@ -728,7 +738,7 @@ def test_amp_restart_shell_output_for_targeted_pids(self, monkeypatch, capsys): captured = capsys.readouterr() assert exit_code == 0 assert "kill -TERM 86296" in captured.out - assert "amp threads continue T-019dc029-f25d-767c-8005-e2996169f6f8" in captured.out + assert f"amp threads continue {SAMPLE_THREAD_ID_2}" in captured.out assert "88150" not in captured.out def test_amp_restart_json_fails_for_unresolved_targeted_pid(self, monkeypatch, capsys): @@ -738,9 +748,9 @@ def test_amp_restart_json_fails_for_unresolved_targeted_pid(self, monkeypatch, c lambda: [ amp_restart.AmpSession( pid=86296, - cwd="/Users/sedwards/Development/block-invert-config", + cwd=SAMPLE_AMP_APP_CWD, thread_id=None, - agent_session_id="20260420_6", + agent_session_id=SAMPLE_AMP_SESSION_ID, mode="deep", ) ], From 8ecd916d71a0ff0de2c5a1720bdb9117bfc7f5f5 Mon Sep 17 00:00:00 2001 From: Stephen Edwards Date: Mon, 27 Apr 2026 12:10:48 -0400 Subject: [PATCH 14/16] Remove temporary Amp restart tooling Amp-Thread-ID: https://ampcode.com/threads/T-019dcf84-27c3-7334-aea3-769c5efdc4bb Co-authored-by: Amp --- amp_restart.py | 385 ------------------------------------------- pyproject.toml | 2 +- tests/test_tq_cli.py | 178 -------------------- tq.py | 7 +- 4 files changed, 2 insertions(+), 570 deletions(-) delete mode 100644 amp_restart.py diff --git a/amp_restart.py b/amp_restart.py deleted file mode 100644 index 51ab84d..0000000 --- a/amp_restart.py +++ /dev/null @@ -1,385 +0,0 @@ -"""Amp-specific session discovery helpers for the tq CLI.""" - -from __future__ import annotations - -import json -import re -import shlex -import subprocess -import sys -from collections.abc import Iterable -from dataclasses import dataclass -from pathlib import Path - -AMP_CLI_LOG_PATH = Path.home() / ".cache" / "amp" / "logs" / "cli.log" -AMP_THREAD_ID_PATTERN = re.compile(r"T-[0-9a-f-]{36}") -AMP_ENV_ASSIGNMENT_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") -AMP_ENV_VALUE_PATTERN_TEMPLATE = r"(?:^|\s){name}=(.*?)(?=\s+[A-Za-z_][A-Za-z0-9_]*=|$)" -AMP_PS_COMMAND_CANDIDATES = [ - ["ps", "eww", "-axo", "pid=,command="], - ["ps", "eww", "axo", "pid=,command="], -] - -# Mirrors the current `amp --help` global options so the generic queue CLI does not own -# Amp-specific argument parsing details. -AMP_GLOBAL_FLAGS_WITH_VALUE = { - "--visibility", - "--settings-file", - "--log-level", - "--log-file", - "--mcp-config", - "-l", - "--label", -} -AMP_GLOBAL_BOOLEAN_FLAGS = { - "--notifications", - "--no-notifications", - "--color", - "--no-color", - "--dangerously-allow-all", - "--jetbrains", - "--no-jetbrains", - "--ide", - "--no-ide", - "--stream-json", - "--stream-json-thinking", - "--stream-json-input", - "--archive", -} - - -@dataclass -class AmpSession: - pid: int - cwd: str | None - thread_id: str | None = None - agent_session_id: str | None = None - mode: str | None = None - - @property - def stop_command(self) -> str: - return f"kill -TERM {self.pid}" - - @property - def continue_command(self) -> str | None: - if not self.cwd or not self.thread_id: - return None - return f"(cd {shlex.quote(self.cwd)} && amp threads continue {self.thread_id})" - - -def add_amp_restart_subparser(subparsers) -> None: - parser = subparsers.add_parser( - "amp-restart", - help="Resolve live interactive Amp sessions to thread IDs and print restart commands", - ) - parser.add_argument( - "--pid", - action="append", - type=int, - default=[], - help="Target a specific live Amp PID. Repeatable. Defaults to all live interactive Amp sessions.", - ) - parser.add_argument("--json", action="store_true", help="Output in JSON format") - parser.add_argument( - "--shell", - action="store_true", - help="Print shell commands only (kill + amp threads continue)", - ) - - -def _extract_env_value(process_line: str, env_name: str) -> str | None: - pattern = AMP_ENV_VALUE_PATTERN_TEMPLATE.format(name=re.escape(env_name)) - match = re.search(pattern, process_line) - if not match: - return None - value = match.group(1).strip() - return value or None - - -def _amp_process_prefix_tokens(process_line: str) -> tuple[int, list[str]] | None: - line = process_line.strip() - if not line: - return None - - try: - pid_text, command = line.split(None, 1) - pid = int(pid_text) - except ValueError: - return None - - argv = [] - for token in command.split(): - if AMP_ENV_ASSIGNMENT_PATTERN.match(token): - break - argv.append(token) - - if not argv: - return None - - return pid, argv - - -def _is_interactive_amp_invocation(argv: list[str]) -> tuple[bool, str | None]: - if not argv or Path(argv[0]).name != "amp": - return False, None - - remaining: list[str] = [] - mode: str | None = None - i = 1 - while i < len(argv): - token = argv[i] - if token in {"-x", "--execute"} or token.startswith("--execute="): - return False, mode - if token in {"-m", "--mode"}: - if i + 1 < len(argv): - mode = argv[i + 1] - i += 2 - continue - if token.startswith("--mode="): - mode = token.split("=", 1)[1] or None - i += 1 - continue - if token in AMP_GLOBAL_FLAGS_WITH_VALUE: - i += 2 - continue - if any(token.startswith(flag + "=") for flag in AMP_GLOBAL_FLAGS_WITH_VALUE if flag.startswith("--")): - i += 1 - continue - if token in AMP_GLOBAL_BOOLEAN_FLAGS: - i += 1 - continue - remaining = argv[i:] - break - - interactive = not remaining or ( - len(remaining) >= 2 - and remaining[0] in {"threads", "thread", "t"} - and remaining[1] in {"continue", "c", "new", "n"} - ) - return interactive, mode - - -def parse_amp_sessions_from_ps_output(ps_output: str) -> list[AmpSession]: - """Parse `ps eww` output and return live interactive Amp sessions.""" - sessions: list[AmpSession] = [] - for line in ps_output.splitlines(): - prefix = _amp_process_prefix_tokens(line) - if prefix is None: - continue - - pid, argv = prefix - interactive, mode = _is_interactive_amp_invocation(argv) - if not interactive: - continue - - sessions.append( - AmpSession( - pid=pid, - cwd=_extract_env_value(line, "PWD"), - agent_session_id=_extract_env_value(line, "AGENT_SESSION_ID"), - mode=mode, - ) - ) - - return sessions - - -def _extract_thread_id_from_log_entry(entry: dict, *, allow_session_state: bool = False) -> str | None: - for key in ("threadId", "threadID", "newThreadID", "currentThreadID"): - value = entry.get(key) - if isinstance(value, str) and AMP_THREAD_ID_PATTERN.fullmatch(value): - return value - - if allow_session_state: - value = entry.get("lastThreadId") - if isinstance(value, str) and AMP_THREAD_ID_PATTERN.fullmatch(value): - return value - - message = entry.get("message") - if isinstance(message, str) and "Switching to thread:" in message: - match = AMP_THREAD_ID_PATTERN.search(message) - if match: - return match.group(0) - - return None - - -def parse_amp_thread_ids_from_log( - log_lines: Iterable[str] | str, - candidate_pids: set[int] | None = None, -) -> dict[int, str]: - """Return the latest known Amp thread ID for each live PID in the CLI log.""" - latest_thread_by_pid: dict[int, tuple[str, str]] = {} - latest_session_start_by_pid: dict[int, str] = {} - line_iterator = log_lines.splitlines() if isinstance(log_lines, str) else log_lines - - for raw_line in line_iterator: - line = raw_line.strip() - if not line: - continue - - try: - entry = json.loads(line) - except json.JSONDecodeError: - continue - - try: - pid = int(entry["pid"]) - except (KeyError, TypeError, ValueError): - continue - - if candidate_pids is not None and pid not in candidate_pids: - continue - - timestamp = entry.get("timestamp") - if not isinstance(timestamp, str) or not timestamp: - continue - - message = entry.get("message") - if message == "Loaded session state:": - latest_session_start_by_pid[pid] = timestamp - thread_id = _extract_thread_id_from_log_entry(entry, allow_session_state=True) - if thread_id is None: - latest_thread_by_pid.pop(pid, None) - else: - latest_thread_by_pid[pid] = (timestamp, thread_id) - continue - - session_started_at = latest_session_start_by_pid.get(pid) - if session_started_at is not None and timestamp < session_started_at: - continue - - thread_id = _extract_thread_id_from_log_entry(entry) - if thread_id is None: - continue - - current = latest_thread_by_pid.get(pid) - if current is None or timestamp >= current[0]: - latest_thread_by_pid[pid] = (timestamp, thread_id) - - return {pid: thread_id for pid, (_, thread_id) in latest_thread_by_pid.items()} - - -def discover_amp_sessions(cli_log_path: Path = AMP_CLI_LOG_PATH) -> list[AmpSession]: - """Discover live interactive Amp sessions and resolve their current thread IDs.""" - last_error = "Failed to enumerate running processes with ps" - result = None - for command in AMP_PS_COMMAND_CANDIDATES: - try: - result = subprocess.run( - command, - capture_output=True, - text=True, - timeout=5, - ) - except subprocess.TimeoutExpired: - last_error = f"`{' '.join(command)}` timed out after 5s" - continue - except (FileNotFoundError, PermissionError, OSError) as exc: - last_error = f"`{' '.join(command)}` failed: {exc}" - continue - - if result.returncode == 0: - break - stderr = result.stderr.strip() - stdout = result.stdout.strip() - details = stderr or stdout - if details: - last_error = details - else: - raise RuntimeError(last_error) - - sessions = parse_amp_sessions_from_ps_output(result.stdout) - if not sessions or not cli_log_path.exists(): - return sessions - - with cli_log_path.open() as cli_log: - thread_ids = parse_amp_thread_ids_from_log( - cli_log, - candidate_pids={session.pid for session in sessions}, - ) - for session in sessions: - session.thread_id = thread_ids.get(session.pid) - - return sessions - - -def _amp_session_payload(session: AmpSession) -> dict[str, str | int | None]: - return { - "pid": session.pid, - "mode": session.mode, - "agent_session_id": session.agent_session_id, - "cwd": session.cwd, - "thread_id": session.thread_id, - "stop_command": session.stop_command, - "continue_command": session.continue_command, - } - - -def cmd_amp_restart(args) -> int: - """Resolve live interactive Amp sessions to thread IDs and print restart commands.""" - pid_filter = set(args.pid or []) - - try: - sessions = discover_amp_sessions() - except Exception as exc: - print(f"Error: {exc}", file=sys.stderr) - return 1 - - if pid_filter: - sessions = [session for session in sessions if session.pid in pid_filter] - found_pids = {session.pid for session in sessions} - missing_pids = sorted(pid_filter - found_pids) - if missing_pids: - joined = ", ".join(str(pid) for pid in missing_pids) - print(f"Error: No live interactive Amp session found for PID(s): {joined}", file=sys.stderr) - return 1 - - unresolved = [session for session in sessions if not session.cwd or not session.thread_id] - - if getattr(args, "json", False): - output = { - "sessions": [_amp_session_payload(session) for session in sessions], - "summary": { - "total": len(sessions), - "resolved": len(sessions) - len(unresolved), - "unresolved": len(unresolved), - }, - } - print(json.dumps(output)) - return 1 if pid_filter and unresolved else 0 - - if getattr(args, "shell", False): - for index, session in enumerate(sessions): - if index: - print() - if session.continue_command: - print(f"# PID {session.pid} thread={session.thread_id} cwd={session.cwd}") - print(session.stop_command) - print(session.continue_command) - else: - reason = "missing thread ID" if not session.thread_id else "missing cwd" - print(f"# PID {session.pid} unresolved ({reason})", file=sys.stderr) - return 1 if pid_filter and unresolved else 0 - - if not sessions: - print("No live interactive Amp sessions found") - return 0 - - for session in sessions: - session_label = session.agent_session_id or "-" - mode_label = session.mode or "-" - print(f"PID {session.pid} session={session_label} mode={mode_label}") - print(f" cwd: {session.cwd or '(unresolved)'}") - print(f" thread: {session.thread_id or '(unresolved)'}") - print(f" stop: {session.stop_command}") - print(f" continue: {session.continue_command or '(unresolved)'}") - print() - - if unresolved: - print( - f"Unresolved sessions: {len(unresolved)} (missing cwd or thread ID in {AMP_CLI_LOG_PATH})", - file=sys.stderr, - ) - - return 1 if pid_filter and unresolved else 0 diff --git a/pyproject.toml b/pyproject.toml index bdd2278..7884bd9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,4 +30,4 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["."] -only-include = ["task_queue.py", "tq.py", "queue_core.py", "amp_restart.py"] +only-include = ["task_queue.py", "tq.py", "queue_core.py"] diff --git a/tests/test_tq_cli.py b/tests/test_tq_cli.py index cd1567c..383e289 100644 --- a/tests/test_tq_cli.py +++ b/tests/test_tq_cli.py @@ -3,8 +3,6 @@ Tests the command-line interface for running tasks and inspecting the queue. """ -import argparse -import amp_restart import json import os import signal @@ -15,7 +13,6 @@ import time from collections.abc import Callable from pathlib import Path -from types import SimpleNamespace from typing import TypeVar import pytest @@ -24,16 +21,6 @@ # Path to tq.py TQ_PATH = Path(__file__).parent.parent / "tq.py" T = TypeVar("T") -SAMPLE_AMP_SPACE_CWD = "/Users/example/Development/Repo With Spaces" -SAMPLE_AMP_APP_CWD = "/Users/example/Development/sample-app" -SAMPLE_AMP_REPO_CWD = "/Users/example/Development/public-repo" -SAMPLE_AMP_TOOLING_CWD = "/Users/example/Development/tooling" -SAMPLE_AMP_SESSION_ID = "session-6" -SAMPLE_AMP_SESSION_ID_ALT = "session-11" -SAMPLE_THREAD_ID_1 = "T-00000000-0000-0000-0000-000000000001" -SAMPLE_THREAD_ID_2 = "T-00000000-0000-0000-0000-000000000002" -SAMPLE_THREAD_ID_3 = "T-00000000-0000-0000-0000-000000000003" -SAMPLE_THREAD_ID_4 = "T-00000000-0000-0000-0000-000000000004" @pytest.fixture @@ -602,171 +589,6 @@ def test_run_help(self): assert "--dir" in result.stdout -class TestAmpRestart: - def test_extract_env_value_preserves_paths_with_spaces(self): - process_line = ( - f"86296 amp -m deep PWD={SAMPLE_AMP_SPACE_CWD} " - f"AGENT_SESSION_ID={SAMPLE_AMP_SESSION_ID} SHELL=/bin/zsh" - ) - - assert amp_restart._extract_env_value(process_line, "PWD") == SAMPLE_AMP_SPACE_CWD - assert amp_restart._extract_env_value(process_line, "AGENT_SESSION_ID") == SAMPLE_AMP_SESSION_ID - - def test_parse_amp_sessions_filters_to_interactive_invocations(self): - ps_output = f""" -86296 amp -m deep PWD={SAMPLE_AMP_APP_CWD} AGENT_SESSION_ID={SAMPLE_AMP_SESSION_ID} -88150 amp threads continue {SAMPLE_THREAD_ID_1} PWD={SAMPLE_AMP_REPO_CWD} -90000 amp threads search --json repo:example/public-repo PWD=/tmp -91000 /Users/example/.amp/bin/amp --mode=smart PWD={SAMPLE_AMP_TOOLING_CWD} AGENT_SESSION_ID={SAMPLE_AMP_SESSION_ID_ALT} - """ - - sessions = amp_restart.parse_amp_sessions_from_ps_output(ps_output) - - assert [(session.pid, session.mode) for session in sessions] == [ - (86296, "deep"), - (88150, None), - (91000, "smart"), - ] - assert sessions[0].cwd == SAMPLE_AMP_APP_CWD - assert sessions[0].agent_session_id == SAMPLE_AMP_SESSION_ID - assert sessions[1].cwd == SAMPLE_AMP_REPO_CWD - - def test_discover_amp_sessions_falls_back_to_linux_ps_flags(self, monkeypatch, tmp_path): - cli_log_path = tmp_path / "cli.log" - cli_log_path.write_text("") - calls = [] - - def fake_run(command, capture_output, text, timeout): - calls.append(command) - if command == amp_restart.AMP_PS_COMMAND_CANDIDATES[0]: - return SimpleNamespace(returncode=1, stdout="", stderr="must set personality to get -x option") - return SimpleNamespace( - returncode=0, - stdout=f"86296 amp -m deep PWD=/tmp AGENT_SESSION_ID={SAMPLE_AMP_SESSION_ID}\n", - stderr="", - ) - - monkeypatch.setattr(amp_restart.subprocess, "run", fake_run) - - sessions = amp_restart.discover_amp_sessions(cli_log_path=cli_log_path) - - assert [session.pid for session in sessions] == [86296] - assert calls == amp_restart.AMP_PS_COMMAND_CANDIDATES[:2] - - def test_parse_amp_thread_ids_uses_latest_entry_per_pid(self): - log_text = "\n".join( - [ - json.dumps( - { - "pid": 86296, - "timestamp": "2026-04-24T15:00:00.000Z", - "threadId": SAMPLE_THREAD_ID_1, - } - ), - json.dumps( - { - "pid": 86296, - "timestamp": "2026-04-24T15:05:00.000Z", - "message": f"[switchToExistingThread] Switching to thread: {SAMPLE_THREAD_ID_2}", - } - ), - json.dumps( - { - "pid": 88150, - "timestamp": "2026-04-24T15:10:00.000Z", - "newThreadID": SAMPLE_THREAD_ID_3, - } - ), - ] - ) - - assert amp_restart.parse_amp_thread_ids_from_log(log_text, {86296, 88150}) == { - 86296: SAMPLE_THREAD_ID_2, - 88150: SAMPLE_THREAD_ID_3, - } - - def test_parse_amp_thread_ids_resets_when_pid_is_reused(self): - log_text = "\n".join( - [ - json.dumps( - { - "pid": 86296, - "timestamp": "2026-04-24T15:05:00.000Z", - "threadId": SAMPLE_THREAD_ID_2, - } - ), - json.dumps( - { - "pid": 86296, - "timestamp": "2026-04-24T15:20:00.000Z", - "message": "Loaded session state:", - "lastThreadId": SAMPLE_THREAD_ID_4, - } - ), - ] - ) - - assert amp_restart.parse_amp_thread_ids_from_log(log_text, {86296}) == { - 86296: SAMPLE_THREAD_ID_4, - } - - def test_amp_restart_shell_output_for_targeted_pids(self, monkeypatch, capsys): - monkeypatch.setattr( - amp_restart, - "discover_amp_sessions", - lambda: [ - amp_restart.AmpSession( - pid=86296, - cwd=SAMPLE_AMP_APP_CWD, - thread_id=SAMPLE_THREAD_ID_2, - agent_session_id=SAMPLE_AMP_SESSION_ID, - mode="deep", - ), - amp_restart.AmpSession( - pid=88150, - cwd=SAMPLE_AMP_REPO_CWD, - thread_id=SAMPLE_THREAD_ID_1, - mode="deep", - ), - ], - ) - - exit_code = amp_restart.cmd_amp_restart( - argparse.Namespace(pid=[86296], json=False, shell=True) - ) - - captured = capsys.readouterr() - assert exit_code == 0 - assert "kill -TERM 86296" in captured.out - assert f"amp threads continue {SAMPLE_THREAD_ID_2}" in captured.out - assert "88150" not in captured.out - - def test_amp_restart_json_fails_for_unresolved_targeted_pid(self, monkeypatch, capsys): - monkeypatch.setattr( - amp_restart, - "discover_amp_sessions", - lambda: [ - amp_restart.AmpSession( - pid=86296, - cwd=SAMPLE_AMP_APP_CWD, - thread_id=None, - agent_session_id=SAMPLE_AMP_SESSION_ID, - mode="deep", - ) - ], - ) - - exit_code = amp_restart.cmd_amp_restart( - argparse.Namespace(pid=[86296], json=True, shell=False) - ) - - captured = capsys.readouterr() - payload = json.loads(captured.out) - assert exit_code == 1 - assert payload["summary"] == {"total": 1, "resolved": 0, "unresolved": 1} - assert payload["sessions"][0]["thread_id"] is None - - class TestQueueIntegration: """Tests for queue behavior with CLI.""" diff --git a/tq.py b/tq.py index 2da67e4..fbfd235 100644 --- a/tq.py +++ b/tq.py @@ -6,7 +6,6 @@ """ import argparse -import amp_restart import json import os import shlex @@ -613,11 +612,9 @@ def main(): logs_parser.add_argument("-n", type=int, default=20, help="Number of entries (default: 20)") logs_parser.add_argument("--json", action="store_true", help="Output in JSON format") - amp_restart.add_amp_restart_subparser(subparsers) - # Handle implicit run: tq ./gradlew build -> tq run ./gradlew build # Pre-process argv to insert 'run' if needed - known_subcommands = {"run", "list", "clear", "logs", "amp-restart"} + known_subcommands = {"run", "list", "clear", "logs"} args_list = sys.argv[1:] # Find the first non-option argument (skip --data-dir and its value) @@ -653,8 +650,6 @@ def main(): cmd_clear(args) elif args.command == "logs": cmd_logs(args) - elif args.command == "amp-restart": - sys.exit(amp_restart.cmd_amp_restart(args)) else: parser.print_help() From 07e4000a8c6bbccb04cafb4be33b9fe45a63bdcf Mon Sep 17 00:00:00 2001 From: Stephen Edwards Date: Mon, 27 Apr 2026 12:23:51 -0400 Subject: [PATCH 15/16] Fix CI lint regression Amp-Thread-ID: https://ampcode.com/threads/T-019dcf84-27c3-7334-aea3-769c5efdc4bb Co-authored-by: Amp --- tests/test_tq_cli.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_tq_cli.py b/tests/test_tq_cli.py index 383e289..c04f203 100644 --- a/tests/test_tq_cli.py +++ b/tests/test_tq_cli.py @@ -16,7 +16,6 @@ from typing import TypeVar import pytest -import tq # Path to tq.py TQ_PATH = Path(__file__).parent.parent / "tq.py" From 7d58723f853e9652d6c5533c20adf821fe05d349 Mon Sep 17 00:00:00 2001 From: Stephen Edwards Date: Mon, 27 Apr 2026 12:37:46 -0400 Subject: [PATCH 16/16] Fix remaining PR review findings Amp-Thread-ID: https://ampcode.com/threads/T-019dcf84-27c3-7334-aea3-769c5efdc4bb Co-authored-by: Amp --- .../sidecar/EnvironmentSnapshot.kt | 22 +++++++++++- .../sidecar/EnvironmentSnapshotTest.kt | 34 +++++++++++++++++++ queue_core.py | 2 +- tests/test_queue.py | 32 +++++++++++++++++ 4 files changed, 88 insertions(+), 2 deletions(-) diff --git a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshot.kt b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshot.kt index 87a0f4c..ee13925 100644 --- a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshot.kt +++ b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshot.kt @@ -92,7 +92,12 @@ data class AdbDevice( object TaskQueueProcessInspector { fun loadConfiguration(dataDir: Path): QueueConfigurationSnapshot { val normalizedDataDir = dataDir.toAbsolutePath().normalize() - val commandResult = runCommand("ps", "eww", "-axo", "pid=,ppid=,command=") + val commandResult = runCommandCandidates( + listOf( + listOf("ps", "eww", "-axo", "pid=,ppid=,command="), + listOf("ps", "eww", "axo", "pid=,ppid=,command="), + ) + ) if (commandResult.errorMessage != null) { return QueueConfigurationSnapshot( @@ -519,6 +524,21 @@ internal fun runCommand(vararg command: String): CommandResult { } } +internal fun runCommandCandidates( + candidates: List>, + runner: (List) -> CommandResult = { runCommand(*it.toTypedArray()) }, +): CommandResult { + var lastResult = CommandResult(output = "", exitCode = -1, errorMessage = "No command candidates provided.") + candidates.forEach { command -> + val result = runner(command) + if (result.errorMessage == null && result.exitCode == 0) { + return result + } + lastResult = result + } + return lastResult +} + private fun shellSplit(commandLine: String): List { val tokens = mutableListOf() val current = StringBuilder() diff --git a/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshotTest.kt b/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshotTest.kt index de7ea6d..4b4a9a8 100644 --- a/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshotTest.kt +++ b/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/EnvironmentSnapshotTest.kt @@ -7,6 +7,40 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class EnvironmentSnapshotTest { + @Test + fun commandRunnerFallsBackToLinuxPsFlags() { + val attemptedCommands = mutableListOf>() + val result = runCommandCandidates( + listOf( + listOf("ps", "eww", "-axo", "pid=,ppid=,command="), + listOf("ps", "eww", "axo", "pid=,ppid=,command="), + ) + ) { command -> + attemptedCommands += command + when (command) { + listOf("ps", "eww", "-axo", "pid=,ppid=,command=") -> CommandResult( + output = "must set personality to get -x option", + exitCode = 1, + ) + listOf("ps", "eww", "axo", "pid=,ppid=,command=") -> CommandResult( + output = "902 1 python task_queue.py", + exitCode = 0, + ) + else -> error("Unexpected command: $command") + } + } + + assertEquals( + listOf( + listOf("ps", "eww", "-axo", "pid=,ppid=,command="), + listOf("ps", "eww", "axo", "pid=,ppid=,command="), + ), + attemptedCommands, + ) + assertEquals(0, result.exitCode) + assertEquals("902 1 python task_queue.py", result.output) + } + @Test fun commandRunnerDrainsLargeStdoutWithoutTimingOut() { val result = runCommand( diff --git a/queue_core.py b/queue_core.py index ec4d96a..821b354 100644 --- a/queue_core.py +++ b/queue_core.py @@ -225,8 +225,8 @@ def _git_context(working_directory: str) -> tuple[str | None, str | None, str | "rev-parse", "--show-toplevel", "--git-common-dir", - "--symbolic-full-name", "HEAD", + "--symbolic-full-name", "HEAD", ], capture_output=True, diff --git a/tests/test_queue.py b/tests/test_queue.py index 4c81c3b..9ca0a83 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -10,6 +10,7 @@ import subprocess import time from pathlib import Path +from types import SimpleNamespace # Set fast polling intervals for tests BEFORE importing task_queue os.environ["TASK_QUEUE_POLL_WAITING"] = "0.1" @@ -108,6 +109,37 @@ def create_git_repo(tmp_path: Path, name: str) -> Path: return repo_dir +def test_git_context_uses_commit_hash_for_detached_head(monkeypatch): + calls = [] + + def fake_run(command, capture_output, text, timeout): + calls.append(command) + return SimpleNamespace( + returncode=0, + stdout="/tmp/repo\n/tmp/repo/.git\n0123456789abcdef0123456789abcdef01234567\nHEAD\n", + ) + + monkeypatch.setattr(queue_core.subprocess, "run", fake_run) + monkeypatch.setattr(queue_core, "_git_repo_name_from_common_dir", lambda *_: "sample-repo") + + assert queue_core._git_context("/tmp/repo") == ( + "/tmp/repo", + "sample-repo", + "0123456", + ) + assert calls == [[ + "git", + "-C", + "/tmp/repo", + "rev-parse", + "--show-toplevel", + "--git-common-dir", + "HEAD", + "--symbolic-full-name", + "HEAD", + ]] + + @pytest.mark.asyncio async def test_task_origin_is_persisted_in_queue_and_metrics(client, tmp_path): repo_dir = create_git_repo(tmp_path, "metadata-repo")