From b52e3564d0434a65f6d599f3c66e553fc548cb9d Mon Sep 17 00:00:00 2001 From: Personal Stack Agent Date: Thu, 4 Jun 2026 09:57:53 +0000 Subject: [PATCH 1/4] agents: start interactive sessions fresh --- .../agentgateway/tmux/AgentSession.kt | 7 ++- .../agentgateway/tmux/AgentSessionManager.kt | 57 +++++-------------- .../agentgateway/web/AgentController.kt | 2 +- .../agentgateway/web/dto/Dtos.kt | 1 - .../tmux/AgentSessionManagerTest.kt | 57 +++++++++---------- .../StartAgentSessionCommandHandler.kt | 43 +------------- .../maintenance/RunnerMaintenanceService.kt | 7 ++- .../domain/model/WorkspaceAgentSession.kt | 4 +- .../domain/port/AgentGatewayClient.kt | 1 - .../integration/HttpAgentGatewayClient.kt | 4 +- .../web/AdminRunnerController.kt | 4 +- ...workspace_agent_session_cli_session_id.sql | 4 +- .../StartAgentSessionCommandHandlerTest.kt | 36 +++++++++++- 13 files changed, 92 insertions(+), 135 deletions(-) diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSession.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSession.kt index 29ad2fc2..290f43dc 100644 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSession.kt +++ b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSession.kt @@ -10,8 +10,9 @@ data class AgentSession( val logFile: Path, val cwd: String, val createdAt: Instant, - // Native CLI session id for resume on wake. Set by the gateway for - // Claude (from the --session-id flag); null for Shell sessions and - // for Codex until async discovery is implemented. + // Native CLI session id for observability and future explicit + // continuation flows. Set by the gateway for Claude (from the + // --session-id flag); null for Shell sessions and for Codex until + // async discovery is implemented. val cliSessionId: String? = null, ) 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 15ec6e91..a6a3978a 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 @@ -69,7 +69,6 @@ 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" @@ -79,7 +78,7 @@ class AgentSessionManager( Files.deleteIfExists(logFile) Files.createFile(logFile) - val (command, cliSessionId) = commandAndSessionIdFor(kind, resumeCliSessionId) + val (command, cliSessionId) = commandAndSessionIdFor(kind) tmux.newSession(tmuxSession, command, cwd) tmux.startPipeToFile(tmuxSession, logFile) @@ -170,52 +169,26 @@ class AgentSessionManager( /** * Build the CLI command and return the native session id alongside it. * - * 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 Claude: a fresh UUID is generated and passed as `--session-id + * ` so the CLI process has a stable native identity without + * inheriting another conversation. * - * 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. + * For Codex: launch the interactive CLI directly. `codex resume --last` + * is intentionally not used here because it can attach a new gateway + * agent to a different saved Codex session on the shared credentials PVC. * * Shell has no session id. */ - private fun commandAndSessionIdFor( - kind: AgentKind, - resumeCliSessionId: String?, - ): Pair, String?> = + private fun commandAndSessionIdFor(kind: AgentKind): Pair, String?> = when (kind) { AgentKind.CLAUDE -> { - 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 -> { - 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 - } - } + 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 } @@ -233,8 +206,6 @@ class AgentSessionManager( private fun timestamp(): String = STAGED_INPUT_TIMESTAMP.format(Instant.now()) companion object { - /** Sentinel value for [resumeCliSessionId] that tells the gateway to run `codex resume --last`. */ - const val CODEX_RESUME_LAST = "LATEST" private const val DEFAULT_STAGED_INPUT_NAME = "input.txt" private const val ID_PREVIEW_CHARS = 8 private const val MAX_STAGED_INPUT_NAME_CHARS = 80 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 843d55b7..d379a697 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 @@ -29,7 +29,7 @@ class AgentController( fun spawn( @RequestBody req: SpawnAgentRequest, ): ResponseEntity { - val session = sessions.spawn(req.kind, req.workspacePath, req.resumeCliSessionId) + val session = sessions.spawn(req.kind, req.workspacePath) 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 ed7f8634..623f6dc1 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,7 +5,6 @@ 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 4c88e52c..904aea00 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 @@ -29,7 +29,11 @@ class AgentSessionManagerTest { claude = "/usr/local/bin/claude", codex = "/usr/local/bin/codex", claudeArgs = listOf("--dangerously-skip-permissions"), - codexArgs = listOf("--dangerously-bypass-approvals-and-sandbox"), + codexArgs = + listOf( + "--dangerously-bypass-approvals-and-sandbox", + "--dangerously-bypass-hook-trust", + ), ), git = GatewayProperties.Git(deployKeyDir = "/x"), stagedInputs = stagedInputs, @@ -71,7 +75,8 @@ class AgentSessionManagerTest { ) { val mgr = manager(tmp) val s = mgr.spawn(AgentKind.CLAUDE) - // Claude gets --session-id appended so native resume is possible. + // Claude gets --session-id appended so each gateway agent has + // a fresh native session instead of inheriting another conversation. assertThat(s.cliSessionId).isNotNull() verify { tmux.newSession( @@ -87,21 +92,19 @@ class AgentSessionManagerTest { } @Test - fun `spawn resumes claude session when resumeCliSessionId is provided`( + fun `spawn never resumes claude from another session`( @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) + val s = mgr.spawn(AgentKind.CLAUDE) 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") + cmd.contains("--session-id") && + cmd[cmd.indexOf("--session-id") + 1] == s.cliSessionId && + !cmd.contains("--resume") }, "/workspace", ) @@ -109,35 +112,23 @@ class AgentSessionManagerTest { } @Test - fun `spawn resumes last codex session when LATEST sentinel is provided`( + fun `spawn never resumes codex last session`( @TempDir tmp: Path, ) { val mgr = manager(tmp) - val s = mgr.spawn(AgentKind.CODEX, resumeCliSessionId = "LATEST") + val s = mgr.spawn(AgentKind.CODEX) 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") + cmd.containsAll( + listOf( + "/usr/local/bin/codex", + "--dangerously-bypass-approvals-and-sandbox", + "--dangerously-bypass-hook-trust", + ), + ) && !cmd.contains("resume") && !cmd.contains("--last") }, "/workspace", ) @@ -155,7 +146,11 @@ class AgentSessionManagerTest { verify { tmux.newSession( s.tmuxSession, - listOf("/usr/local/bin/codex", "--dangerously-bypass-approvals-and-sandbox"), + listOf( + "/usr/local/bin/codex", + "--dangerously-bypass-approvals-and-sandbox", + "--dangerously-bypass-hook-trust", + ), "/workspace", ) } 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 ab3ef1d6..8cae789e 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 @@ -5,7 +5,6 @@ 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 @@ -82,8 +81,7 @@ class StartAgentSessionCommandHandler( ) sessions.save(session) - val resumeId = previousSessionResumeId(command.workspaceId, command.kind) - val gatewayAgent = spawnAgentWithRetry(healthy, command, resumeId) + val gatewayAgent = spawnAgentWithRetry(healthy, command) sessions.save(session.bindGatewayAgent(gatewayAgent.id, gatewayAgent.cliSessionId)) } @@ -168,37 +166,6 @@ class StartAgentSessionCommandHandler( ) } - /** - * 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 previousSessionResumeId( - workspaceId: WorkspaceId, - kind: WorkspaceAgentKind, - ): String? { - 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 - } - } - /** * Retry the spawn on `ResourceAccessException` (the Spring * RestClient wrapping of any transport-level failure: socket @@ -209,12 +176,11 @@ 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, resumeCliSessionId = resumeCliSessionId) + return gateway.spawnAgent(workspace, command.kind) } catch (ex: ResourceAccessException) { lastFailure = ex val sleepMs = backoffInitialMs * (attempt + 1) @@ -239,10 +205,5 @@ 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" } } 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 9494e22d..590f0bcb 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 @@ -18,8 +18,9 @@ import java.time.Clock * - **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. + * pulling `:latest` automatically. The workspace PVC is preserved, + * but a later "new session" starts a fresh agent process instead + * of resuming a prior native CLI conversation. * - **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. @@ -44,7 +45,7 @@ class RunnerMaintenanceService( * 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. + * the same disk and CLI history. * * Scale-down failures are logged and skipped — the orchestrator's * [AgentRunnerOrchestrator.scaleDown] treats a missing Pod as a no-op, diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceAgentSession.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceAgentSession.kt index bc9173e5..6b0cedf9 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceAgentSession.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceAgentSession.kt @@ -14,8 +14,8 @@ enum class WorkspaceAgentSessionStatus { STARTING, RUNNING, STOPPED, FAILED } * `cliSessionId` is the native CLI session id returned by the * gateway at spawn time (Claude: from `--session-id `; * Codex: captured after spawn, null until discovered; Shell: never - * set). Stored so a wake/re-attach can pass `--resume`/`resume` to - * the CLI without starting a fresh session. + * set). Stored for observability and future explicit continuation + * flows; new session starts do not implicitly resume it. * * `runMode` is `INTERACTIVE` for browser-terminal sessions and will * be `HEADLESS` once N4 headless runs land. 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 c5796905..45eef1f3 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 @@ -51,7 +51,6 @@ 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 8860e48d..7e4a1663 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,7 +32,6 @@ class HttpAgentGatewayClient( private data class SpawnBody( val kind: WorkspaceAgentKind, val workspacePath: String? = null, - val resumeCliSessionId: String? = null, ) private data class SendBody( @@ -87,13 +86,12 @@ class HttpAgentGatewayClient( workspace: Workspace, kind: WorkspaceAgentKind, workspacePath: String?, - resumeCliSessionId: String?, ): AgentGatewayClient.GatewayAgent { val dto = restClient .post() .uri("${endpoint(workspace)}/agents") - .body(SpawnBody(kind, workspacePath, resumeCliSessionId)) + .body(SpawnBody(kind, workspacePath)) .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 index ed9e855a..63e0d3ca 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 @@ -20,8 +20,8 @@ class AdminRunnerController( /** * 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`. + * on any workspace re-provisions the Pod pulling `:latest` and starts + * a fresh agent process. * * Intended for two scenarios: * 1. After pushing a new agent-runner image to pick up the update. diff --git a/services/assistant-api/src/main/resources/db/migration/V12__workspace_agent_session_cli_session_id.sql b/services/assistant-api/src/main/resources/db/migration/V12__workspace_agent_session_cli_session_id.sql index 8a51cc93..90e8a875 100644 --- a/services/assistant-api/src/main/resources/db/migration/V12__workspace_agent_session_cli_session_id.sql +++ b/services/assistant-api/src/main/resources/db/migration/V12__workspace_agent_session_cli_session_id.sql @@ -1,5 +1,5 @@ --- Store the native CLI session id so a resume flag can be passed on --- wake/re-attach without starting a fresh session. Nullable because: +-- Store the native CLI session id for observability and future explicit +-- continuation flows. Nullable because: -- - SHELL sessions have no native CLI id -- - Codex session id discovery is async (captured after spawn) -- - Old rows have no id 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 80a07d6e..0e4eddbe 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 @@ -24,8 +24,6 @@ import java.time.Instant class StartAgentSessionCommandHandlerTest { private val workspaces = 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() @@ -58,6 +56,40 @@ class StartAgentSessionCommandHandlerTest { assertThat(saved.last().status).isEqualTo(WorkspaceAgentSessionStatus.RUNNING) } + @Test + fun `handle starts fresh even when prior sessions exist for the same workspace and kind`() { + val ws = workspace() + every { workspaces.findById(ws.id) } returns ws + every { gateway.isReady(ws) } returns true + every { sessions.save(any()) } answers { firstArg() } + every { sessions.findAllByWorkspaceId(ws.id) } returns + listOf( + WorkspaceAgentSession( + id = WorkspaceAgentSessionId.random(), + workspaceId = ws.id, + kind = WorkspaceAgentKind.CLAUDE, + gatewayAgentId = "old-agent", + status = WorkspaceAgentSessionStatus.RUNNING, + createdAt = Instant.parse("2026-05-19T10:00:00Z"), + updatedAt = Instant.parse("2026-05-19T10:00:00Z"), + cliSessionId = "old-native-session", + ), + ) + every { gateway.spawnAgent(ws, WorkspaceAgentKind.CLAUDE) } returns + AgentGatewayClient.GatewayAgent(id = "fresh", kind = WorkspaceAgentKind.CLAUDE, cwd = "/workspace") + + handler.handle( + StartAgentSessionCommand( + sessionId = WorkspaceAgentSessionId.random(), + workspaceId = ws.id, + kind = WorkspaceAgentKind.CLAUDE, + ), + ) + + verify(exactly = 0) { sessions.findAllByWorkspaceId(any()) } + verify(exactly = 1) { gateway.spawnAgent(ws, WorkspaceAgentKind.CLAUDE) } + } + @Test fun `handle raises NoSuchElementException when workspace does not exist`() { val missingId = WorkspaceId.random() From 3fb7a533a3b4735416e9326a4d6e0d5c5f5f8f87 Mon Sep 17 00:00:00 2001 From: Personal Stack Agent Date: Thu, 4 Jun 2026 09:58:11 +0000 Subject: [PATCH 2/4] agents: bypass Codex hook trust prompts --- .../cluster/flux/apps/agents/AGENT-PARITY.md | 10 ++++-- .../headless/HeadlessJobManager.kt | 14 +++++--- .../src/main/resources/application.yml | 1 + .../headless/HeadlessJobManagerTest.kt | 35 +++++++++++++++++++ 4 files changed, 52 insertions(+), 8 deletions(-) diff --git a/platform/cluster/flux/apps/agents/AGENT-PARITY.md b/platform/cluster/flux/apps/agents/AGENT-PARITY.md index b8418336..01968fb3 100644 --- a/platform/cluster/flux/apps/agents/AGENT-PARITY.md +++ b/platform/cluster/flux/apps/agents/AGENT-PARITY.md @@ -46,11 +46,15 @@ so the entrypoint seeds them on every boot: `approval_policy` / `sandbox_mode` from `~/.codex/config.toml` on the `codex-credentials` PVC. The entrypoint writes a trusted + non-interactive config only when none exists, so a - hand-edited file on the PVC is never overwritten. + hand-edited file on the PVC is never overwritten. Agent-gateway also + passes `--dangerously-bypass-hook-trust` for the managed runner + process so repo-managed hooks do not block startup on the interactive + "Hooks need review" prompt. Without this seeding every fresh session re-shows the theme picker -and onboarding wizard (Claude) and the directory-trust prompt -(Codex), all of which block the non-interactive tmux pane. +and onboarding wizard (Claude), the directory-trust prompt (Codex), or +the hook trust prompt (Codex), all of which block the non-interactive +tmux pane. ## Domain / runtime diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/headless/HeadlessJobManager.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/headless/HeadlessJobManager.kt index 6a1a5f33..e81b226a 100644 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/headless/HeadlessJobManager.kt +++ b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/headless/HeadlessJobManager.kt @@ -167,9 +167,9 @@ class HeadlessJobManager( /** * Build the one-shot CLI command for a headless run. Claude uses * `-p` (print mode) with `--output-format stream-json` for machine- - * parseable streaming events. Codex uses `exec --json`; when a - * `cliSessionId` is provided it resumes an existing context via - * `exec resume --json`. + * parseable streaming events. Codex uses `exec --json` plus the + * configured Codex CLI args; when a `cliSessionId` is explicitly + * provided it resumes that exact context via `exec resume --json`. */ private fun headlessCommandFor( kind: AgentKind, @@ -188,9 +188,13 @@ class HeadlessJobManager( AgentKind.CODEX -> if (cliSessionId != null) { - listOf(props.cli.codex, "exec", "resume", cliSessionId, "--json", "--", prompt) + listOf(props.cli.codex, "exec", "resume") + + props.cli.codexArgs + + listOf("--json", cliSessionId, "--", prompt) } else { - listOf(props.cli.codex, "exec", "--json", "--", prompt) + listOf(props.cli.codex, "exec") + + props.cli.codexArgs + + listOf("--json", "--", prompt) } AgentKind.SHELL -> listOf("/bin/sh", "-c", prompt) diff --git a/services/agent-gateway/src/main/resources/application.yml b/services/agent-gateway/src/main/resources/application.yml index 7497c0c9..33a46955 100644 --- a/services/agent-gateway/src/main/resources/application.yml +++ b/services/agent-gateway/src/main/resources/application.yml @@ -47,6 +47,7 @@ agent-gateway: - --dangerously-skip-permissions codex-args: - --dangerously-bypass-approvals-and-sandbox + - --dangerously-bypass-hook-trust git: # The deploy-key Secret is mounted into the Pod at this path by # assistant-api when it creates the runner Pod (Step 3). diff --git a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/headless/HeadlessJobManagerTest.kt b/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/headless/HeadlessJobManagerTest.kt index f2bd9d35..926c529a 100644 --- a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/headless/HeadlessJobManagerTest.kt +++ b/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/headless/HeadlessJobManagerTest.kt @@ -23,6 +23,11 @@ class HeadlessJobManagerTest { GatewayProperties.Cli( claude = "/usr/local/bin/claude", codex = "/usr/local/bin/codex", + codexArgs = + listOf( + "--dangerously-bypass-approvals-and-sandbox", + "--dangerously-bypass-hook-trust", + ), ), git = GatewayProperties.Git(deployKeyDir = "/x"), ) @@ -89,6 +94,36 @@ class HeadlessJobManagerTest { mgr.destroy() } + @Test + fun `launch codex headless uses bypass flags and starts fresh by default`( + @TempDir tmp: Path, + ) { + val latch = CountDownLatch(1) + var capturedCommand: List? = null + val mgr = + manager(tmp) { command, _ -> + capturedCommand = command + latch.countDown() + processOf(exitCode = 0, output = "done") + } + + mgr.launch(AgentKind.CODEX, "inspect repo") + + assertThat(latch.await(3, TimeUnit.SECONDS)).isTrue() + assertThat(capturedCommand) + .containsExactly( + "/usr/local/bin/codex", + "exec", + "--dangerously-bypass-approvals-and-sandbox", + "--dangerously-bypass-hook-trust", + "--json", + "--", + "inspect repo", + ) + assertThat(capturedCommand).doesNotContain("resume") + mgr.destroy() + } + @Test fun `cancel kills the process and marks job CANCELLED`( @TempDir tmp: Path, From 9edb1cbd58bfcf39804697cd6e5e3767c5b39c35 Mon Sep 17 00:00:00 2001 From: Personal Stack Agent Date: Thu, 4 Jun 2026 09:58:22 +0000 Subject: [PATCH 3/4] agents: infer repo URL for GitHub MCP tokens --- .../platform/PlatformAgentMcpFluxTest.kt | 21 +++++++++++++++++++ services/agent-runner/agent-github-token.sh | 21 ++++++++++++++++--- services/agent-runner/gh-mcp-wrapper.sh | 8 +++---- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/platform/tooling/src/test/kotlin/com/jorisjonkers/personalstack/platform/PlatformAgentMcpFluxTest.kt b/platform/tooling/src/test/kotlin/com/jorisjonkers/personalstack/platform/PlatformAgentMcpFluxTest.kt index 2d5e0bf7..600815cd 100644 --- a/platform/tooling/src/test/kotlin/com/jorisjonkers/personalstack/platform/PlatformAgentMcpFluxTest.kt +++ b/platform/tooling/src/test/kotlin/com/jorisjonkers/personalstack/platform/PlatformAgentMcpFluxTest.kt @@ -51,11 +51,21 @@ class PlatformAgentMcpFluxTest { .resolve("services/agent-runner/entrypoint.sh") .toFile() .readText() + val agentGatewayConfig = + repositoryRoot + .resolve("services/agent-gateway/src/main/resources/application.yml") + .toFile() + .readText() val githubMcpWrapper = repositoryRoot .resolve("services/agent-runner/gh-mcp-wrapper.sh") .toFile() .readText() + val githubTokenHelper = + repositoryRoot + .resolve("services/agent-runner/agent-github-token.sh") + .toFile() + .readText() val mcpConfigMap = repositoryRoot .resolve("platform/cluster/flux/apps/agents/mcp/agents-mcp-servers-configmap.yaml") @@ -74,14 +84,25 @@ class PlatformAgentMcpFluxTest { .contains("url.https://github.com/.insteadOf git@github.com:") .contains("url.https://github.com/.insteadOf ssh://git@github.com/") + assertThat(agentGatewayConfig) + .contains("--dangerously-bypass-approvals-and-sandbox") + .contains("--dangerously-bypass-hook-trust") + assertThat(githubMcpWrapper) .contains("GITHUB_MCP_TOKEN_RETRIES:-4") .contains("GITHUB_MCP_TOKEN_RETRY_SLEEP_SECONDS:-3") + .contains("GITHUB_APP_TOKEN_URL") + .contains("GITHUB_APP_TOKEN_BEARER") .contains("GITHUB_MCP_TOOLSETS:-repos,pull_requests,issues,actions,git") .contains("GITHUB_MCP_EXCLUDE_TOOLS:-create_repository,fork_repository") .contains("--toolsets") .contains("--exclude-tools") + assertThat(githubTokenHelper) + .contains("WORKSPACE_ROOT=\"\${WORKSPACE_ROOT:-/workspace}\"") + .contains("git -C \"\$WORKSPACE_ROOT\" remote get-url origin") + .contains("--arg repoUrl \"\$repo_url\"") + assertThat(mcpConfigMap) .contains("[mcp_servers.github]") .contains("command = \"gh-mcp-wrapper\"") diff --git a/services/agent-runner/agent-github-token.sh b/services/agent-runner/agent-github-token.sh index fb46c16d..c7fa0e59 100755 --- a/services/agent-runner/agent-github-token.sh +++ b/services/agent-runner/agent-github-token.sh @@ -7,6 +7,7 @@ set -u CACHE="${GH_APP_TOKEN_CACHE:-/tmp/.gh-app-token}" SKEW="${GH_APP_TOKEN_SKEW_SECONDS:-300}" +WORKSPACE_ROOT="${WORKSPACE_ROOT:-/workspace}" parse_expiry() { local value="$1" @@ -26,10 +27,13 @@ cached_token() { } mint_token() { - [ -n "${GITHUB_APP_TOKEN_URL:-}" ] && [ -n "${GITHUB_APP_TOKEN_BEARER:-}" ] && [ -n "${REPO_URL:-}" ] || return 1 + [ -n "${GITHUB_APP_TOKEN_URL:-}" ] && [ -n "${GITHUB_APP_TOKEN_BEARER:-}" ] || return 1 - local payload resp tok exp_iso exp - payload=$(jq -nc --arg repoUrl "$REPO_URL" '{repoUrl: $repoUrl}') || return 1 + local repo_url payload resp tok exp_iso exp + repo_url="$(repo_url)" || return 1 + [ -n "$repo_url" ] || return 1 + + payload=$(jq -nc --arg repoUrl "$repo_url" '{repoUrl: $repoUrl}') || return 1 resp=$(curl -fsS --max-time 10 -X POST "$GITHUB_APP_TOKEN_URL" \ -H "Authorization: Bearer $GITHUB_APP_TOKEN_BEARER" \ -H "Content-Type: application/json" \ @@ -47,6 +51,17 @@ mint_token() { printf '%s' "$tok" } +repo_url() { + if [ -n "${REPO_URL:-}" ]; then + printf '%s' "$REPO_URL" + return 0 + fi + + git -C "$WORKSPACE_ROOT" remote get-url origin 2>/dev/null || + git remote get-url origin 2>/dev/null || + return 1 +} + if [ -n "${GH_TOKEN:-}" ]; then printf '%s' "$GH_TOKEN" exit 0 diff --git a/services/agent-runner/gh-mcp-wrapper.sh b/services/agent-runner/gh-mcp-wrapper.sh index 53e4266c..e9ed194b 100755 --- a/services/agent-runner/gh-mcp-wrapper.sh +++ b/services/agent-runner/gh-mcp-wrapper.sh @@ -11,8 +11,7 @@ TOKEN_HELPER="${AGENT_GITHUB_TOKEN_HELPER:-/usr/local/bin/agent-github-token}" can_retry_mint() { [ -n "${GITHUB_APP_TOKEN_URL:-}" ] && - [ -n "${GITHUB_APP_TOKEN_BEARER:-}" ] && - [ -n "${REPO_URL:-}" ] + [ -n "${GITHUB_APP_TOKEN_BEARER:-}" ] } TOKEN="" @@ -42,8 +41,9 @@ if [ -z "$TOKEN" ]; then cat >&2 <<'EOF' [gh-mcp-wrapper] ERROR: no GitHub token available for github-mcp-server. [gh-mcp-wrapper] Set GITHUB_PERSONAL_ACCESS_TOKEN/GH_TOKEN, or provide -[gh-mcp-wrapper] GITHUB_APP_TOKEN_URL, GITHUB_APP_TOKEN_BEARER, and REPO_URL -[gh-mcp-wrapper] so the runner can mint an installation token. +[gh-mcp-wrapper] GITHUB_APP_TOKEN_URL and GITHUB_APP_TOKEN_BEARER. The +[gh-mcp-wrapper] runner also needs REPO_URL or a git remote in /workspace +[gh-mcp-wrapper] so it can mint a repo-scoped installation token. EOF exit 78 fi From 7fd846f4401f27913e7577ab7b7c2345a047fd45 Mon Sep 17 00:00:00 2001 From: Personal Stack Agent Date: Thu, 4 Jun 2026 11:14:39 +0000 Subject: [PATCH 4/4] agents: remove unused session kind import --- .../application/command/StartAgentSessionCommandHandler.kt | 1 - 1 file changed, 1 deletion(-) 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 8cae789e..94eb7091 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,7 +2,6 @@ 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.port.AgentGatewayClient