From 1435939e38378b226dc8c121c0a697d16408ed40 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 20 Feb 2026 11:47:36 +0000 Subject: [PATCH 1/8] feat(registry/coder/modules/agentapi): add state persistence AgentAPI can save and restore conversation state across workspace restarts. The base module exports env vars (AGENTAPI_STATE_FILE, AGENTAPI_SAVE_STATE, AGENTAPI_LOAD_STATE, AGENTAPI_PID_FILE) that the binary reads directly. No consumer module start scripts need changes. New variables: - enable_state_persistence (bool, default true) - state_file_path (string, defaults to $HOME//state.json) - pid_file_path (string, defaults to $HOME//agentapi.pid) State persistence requires agentapi >= v0.12.0. A shared version_at_least function in scripts/lib.sh gates both the env var exports in main.sh and SIGUSR1 in the shutdown script. The version is queried from the real binary (agentapi --version) rather than the Terraform variable, so it works correctly when install_agentapi is false. Shutdown script now performs a three-phase shutdown: 1. SIGUSR1 to trigger state save (gated on version + persistence enabled) 2. Log snapshot capture (existing behavior, now fault-tolerant via subshell) 3. SIGTERM for graceful termination with wait loop Also bumps agentapi module version to 2.2.0. Refs: internal#1257, internal#1256, registry#696 --- registry/coder/modules/agentapi/README.md | 29 ++- .../modules/agentapi/agentapi.tftest.hcl | 108 ++++++++++ registry/coder/modules/agentapi/main.test.ts | 199 +++++++++++++++++- registry/coder/modules/agentapi/main.tf | 26 +++ .../agentapi/scripts/agentapi-shutdown.sh | 75 +++++-- .../coder/modules/agentapi/scripts/lib.sh | 44 ++++ .../coder/modules/agentapi/scripts/main.sh | 19 ++ .../testdata/agentapi-mock-shutdown.js | 18 ++ .../agentapi/testdata/agentapi-mock.js | 29 +++ 9 files changed, 531 insertions(+), 16 deletions(-) create mode 100644 registry/coder/modules/agentapi/agentapi.tftest.hcl create mode 100644 registry/coder/modules/agentapi/scripts/lib.sh diff --git a/registry/coder/modules/agentapi/README.md b/registry/coder/modules/agentapi/README.md index e7a9869fb..fffb948c0 100644 --- a/registry/coder/modules/agentapi/README.md +++ b/registry/coder/modules/agentapi/README.md @@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI ```tf module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "2.1.1" + version = "2.2.0" agent_id = var.agent_id web_app_slug = local.app_slug @@ -62,6 +62,33 @@ module "agentapi" { } ``` +## State Persistence + +AgentAPI can save and restore conversation state across workspace restarts. +This is enabled by default and requires agentapi binary >= v0.12.0. + +State and PID files are stored in `$HOME//` alongside other +module files (e.g. `$HOME/.claude-module/state.json`). + +To disable: + +```tf +module "agentapi" { + # ... other config + enable_state_persistence = false +} +``` + +To override file paths: + +```tf +module "agentapi" { + # ... other config + state_file_path = "/custom/path/state.json" + pid_file_path = "/custom/path/agentapi.pid" +} +``` + ## For module developers For a complete example of how to use this module, see the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf). diff --git a/registry/coder/modules/agentapi/agentapi.tftest.hcl b/registry/coder/modules/agentapi/agentapi.tftest.hcl new file mode 100644 index 000000000..4d69926b0 --- /dev/null +++ b/registry/coder/modules/agentapi/agentapi.tftest.hcl @@ -0,0 +1,108 @@ +mock_provider "coder" {} + +variables { + agent_id = "test-agent" + web_app_icon = "/icon/test.svg" + web_app_display_name = "Test" + web_app_slug = "test" + cli_app_display_name = "Test CLI" + cli_app_slug = "test-cli" + start_script = "echo test" + module_dir_name = ".test-module" +} + +run "default_values" { + command = plan + + assert { + condition = var.enable_state_persistence == true + error_message = "enable_state_persistence should default to true" + } + + assert { + condition = var.state_file_path == "" + error_message = "state_file_path should default to empty string" + } + + assert { + condition = var.pid_file_path == "" + error_message = "pid_file_path should default to empty string" + } + + # Verify start script contains state persistence ARG_ vars. + assert { + condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE", coder_script.agentapi.script)) + error_message = "start script should contain ARG_ENABLE_STATE_PERSISTENCE" + } + + assert { + condition = can(regex("ARG_STATE_FILE_PATH", coder_script.agentapi.script)) + error_message = "start script should contain ARG_STATE_FILE_PATH" + } + + assert { + condition = can(regex("ARG_PID_FILE_PATH", coder_script.agentapi.script)) + error_message = "start script should contain ARG_PID_FILE_PATH" + } + + # Verify shutdown script contains PID-related ARG_ vars. + assert { + condition = can(regex("ARG_PID_FILE_PATH", coder_script.agentapi_shutdown.script)) + error_message = "shutdown script should contain ARG_PID_FILE_PATH" + } + + assert { + condition = can(regex("ARG_MODULE_DIR_NAME", coder_script.agentapi_shutdown.script)) + error_message = "shutdown script should contain ARG_MODULE_DIR_NAME" + } + + assert { + condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE", coder_script.agentapi_shutdown.script)) + error_message = "shutdown script should contain ARG_ENABLE_STATE_PERSISTENCE" + } +} + +run "state_persistence_disabled" { + command = plan + + variables { + enable_state_persistence = false + } + + assert { + condition = var.enable_state_persistence == false + error_message = "enable_state_persistence should be false" + } + + # Even when disabled, the ARG_ vars should still be in the script + # (the shell script handles the conditional logic). + assert { + condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE='false'", coder_script.agentapi.script)) + error_message = "start script should contain ARG_ENABLE_STATE_PERSISTENCE='false'" + } +} + +run "custom_paths" { + command = plan + + variables { + state_file_path = "/custom/state.json" + pid_file_path = "/custom/agentapi.pid" + } + + assert { + condition = can(regex("/custom/state.json", coder_script.agentapi.script)) + error_message = "start script should contain custom state_file_path" + } + + assert { + condition = can(regex("/custom/agentapi.pid", coder_script.agentapi.script)) + error_message = "start script should contain custom pid_file_path" + } + + # Verify custom paths also appear in shutdown script. + assert { + condition = can(regex("/custom/agentapi.pid", coder_script.agentapi_shutdown.script)) + error_message = "shutdown script should contain custom pid_file_path" + } +} diff --git a/registry/coder/modules/agentapi/main.test.ts b/registry/coder/modules/agentapi/main.test.ts index 20b47b1a0..84fa412a1 100644 --- a/registry/coder/modules/agentapi/main.test.ts +++ b/registry/coder/modules/agentapi/main.test.ts @@ -258,11 +258,71 @@ describe("agentapi", async () => { expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *"); }); + test("state-persistence-disabled", async () => { + const { id } = await setup({ + moduleVariables: { + enable_state_persistence: "false", + }, + }); + await execModuleScript(id); + await expectAgentAPIStarted(id); + const mockLog = await readFileContainer( + id, + "/home/coder/agentapi-mock.log", + ); + // PID file should always be exported + expect(mockLog).toContain("AGENTAPI_PID_FILE:"); + // State vars should NOT be present when disabled + expect(mockLog).not.toContain("AGENTAPI_STATE_FILE:"); + expect(mockLog).not.toContain("AGENTAPI_SAVE_STATE:"); + expect(mockLog).not.toContain("AGENTAPI_LOAD_STATE:"); + }); + + test("state-persistence-custom-paths", async () => { + const { id } = await setup({ + moduleVariables: { + state_file_path: "/home/coder/custom/state.json", + pid_file_path: "/home/coder/custom/agentapi.pid", + }, + }); + await execModuleScript(id); + await expectAgentAPIStarted(id); + const mockLog = await readFileContainer( + id, + "/home/coder/agentapi-mock.log", + ); + expect(mockLog).toContain( + "AGENTAPI_STATE_FILE: /home/coder/custom/state.json", + ); + expect(mockLog).toContain( + "AGENTAPI_PID_FILE: /home/coder/custom/agentapi.pid", + ); + }); + + test("state-persistence-default-paths", async () => { + const { id } = await setup(); + await execModuleScript(id); + await expectAgentAPIStarted(id); + const mockLog = await readFileContainer( + id, + "/home/coder/agentapi-mock.log", + ); + expect(mockLog).toContain( + `AGENTAPI_STATE_FILE: /home/coder/${moduleDirName}/state.json`, + ); + expect(mockLog).toContain( + `AGENTAPI_PID_FILE: /home/coder/${moduleDirName}/agentapi.pid`, + ); + expect(mockLog).toContain("AGENTAPI_SAVE_STATE: true"); + expect(mockLog).toContain("AGENTAPI_LOAD_STATE: true"); + }); + describe("shutdown script", async () => { const setupMocks = async ( containerId: string, agentapiPreset: string, httpCode: number = 204, + pidFilePath: string = "", ) => { const agentapiMock = await loadTestFile( import.meta.dir, @@ -285,10 +345,11 @@ describe("agentapi", async () => { content: coderMock, }); + const pidFileEnv = pidFilePath ? `AGENTAPI_PID_FILE=${pidFilePath}` : ""; await execContainer(containerId, [ "bash", "-c", - `PRESET=${agentapiPreset} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`, + `PRESET=${agentapiPreset} ${pidFileEnv} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`, ]); await execContainer(containerId, [ @@ -303,12 +364,25 @@ describe("agentapi", async () => { const runShutdownScript = async ( containerId: string, taskId: string = "test-task", + pidFilePath: string = "", + enableStatePersistence: string = "true", ) => { const shutdownScript = await loadTestFile( import.meta.dir, "../scripts/agentapi-shutdown.sh", ); + const libScript = await loadTestFile( + import.meta.dir, + "../scripts/lib.sh", + ); + + await writeExecutable({ + containerId, + filePath: "/tmp/agentapi-lib.sh", + content: libScript, + }); + await writeExecutable({ containerId, filePath: "/tmp/shutdown.sh", @@ -318,7 +392,7 @@ describe("agentapi", async () => { return await execContainer(containerId, [ "bash", "-c", - `ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`, + `ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 ARG_PID_FILE_PATH=${pidFilePath} ARG_ENABLE_STATE_PERSISTENCE=${enableStatePersistence} CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`, ]); }; @@ -409,5 +483,126 @@ describe("agentapi", async () => { "Log snapshot endpoint not supported by this Coder version", ); }); + + test("sends SIGUSR1 before shutdown", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + const pidFile = "/tmp/agentapi-test.pid"; + await setupMocks(id, "normal", 204, pidFile); + const result = await runShutdownScript(id, "test-task", pidFile, "true"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Sending SIGUSR1 to AgentAPI"); + + const sigusr1Log = await readFileContainer(id, "/tmp/sigusr1-received"); + expect(sigusr1Log).toContain("SIGUSR1 received"); + }); + + test("handles missing PID file gracefully", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + await setupMocks(id, "normal"); + // Pass a non-existent PID file path + const result = await runShutdownScript( + id, + "test-task", + "/tmp/nonexistent.pid", + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Shutdown complete"); + }); + + test("sends SIGTERM even when snapshot fails", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + const pidFile = "/tmp/agentapi-test.pid"; + // HTTP 500 will cause snapshot to fail + await setupMocks(id, "normal", 500, pidFile); + const result = await runShutdownScript(id, "test-task", pidFile); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain( + "Log snapshot capture failed, continuing shutdown", + ); + expect(result.stdout).toContain("Sending SIGTERM to AgentAPI"); + }); + + test("resolves default PID path from MODULE_DIR_NAME", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + // Start mock with PID file at the module_dir_name default location. + const defaultPidPath = `/home/coder/${moduleDirName}/agentapi.pid`; + await setupMocks(id, "normal", 204, defaultPidPath); + // Don't pass pidFilePath - let shutdown script compute it from MODULE_DIR_NAME. + const shutdownScript = await loadTestFile( + import.meta.dir, + "../scripts/agentapi-shutdown.sh", + ); + const libScript = await loadTestFile( + import.meta.dir, + "../scripts/lib.sh", + ); + await writeExecutable({ + containerId: id, + filePath: "/tmp/agentapi-lib.sh", + content: libScript, + }); + await writeExecutable({ + containerId: id, + filePath: "/tmp/shutdown.sh", + content: shutdownScript, + }); + const result = await execContainer(id, [ + "bash", + "-c", + `ARG_TASK_ID=test-task ARG_AGENTAPI_PORT=3284 ARG_MODULE_DIR_NAME=${moduleDirName} ARG_ENABLE_STATE_PERSISTENCE=true CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`, + ]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Sending SIGUSR1 to AgentAPI"); + expect(result.stdout).toContain("Sending SIGTERM to AgentAPI"); + }); + + test("skips SIGUSR1 when no PID file available", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + await setupMocks(id, "normal", 204); + // No pidFilePath and no MODULE_DIR_NAME, so no PID file can be resolved. + const result = await runShutdownScript(id, "test-task", "", "false"); + + expect(result.exitCode).toBe(0); + // Should not send SIGUSR1 or SIGTERM (no PID to signal). + expect(result.stdout).not.toContain("Sending SIGUSR1"); + expect(result.stdout).not.toContain("Sending SIGTERM"); + expect(result.stdout).toContain("Shutdown complete"); + }); + + test("skips SIGUSR1 when state persistence disabled", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + const pidFile = "/tmp/agentapi-test.pid"; + await setupMocks(id, "normal", 204, pidFile); + // PID file exists but state persistence is disabled. + const result = await runShutdownScript(id, "test-task", pidFile, "false"); + + expect(result.exitCode).toBe(0); + // Should NOT send SIGUSR1 (persistence disabled). + expect(result.stdout).not.toContain("Sending SIGUSR1"); + // Should still send SIGTERM (graceful shutdown always happens). + expect(result.stdout).toContain("Sending SIGTERM to AgentAPI"); + }); }); }); diff --git a/registry/coder/modules/agentapi/main.tf b/registry/coder/modules/agentapi/main.tf index 6914be779..5629e8032 100644 --- a/registry/coder/modules/agentapi/main.tf +++ b/registry/coder/modules/agentapi/main.tf @@ -164,6 +164,23 @@ variable "module_dir_name" { description = "Name of the subdirectory in the home directory for module files." } +variable "enable_state_persistence" { + type = bool + description = "Enable AgentAPI conversation state persistence across restarts." + default = true +} + +variable "state_file_path" { + type = string + description = "Path to the AgentAPI state file. Defaults to $HOME//state.json." + default = "" +} + +variable "pid_file_path" { + type = string + description = "Path to the AgentAPI PID file. Defaults to $HOME//agentapi.pid." + default = "" +} locals { # we always trim the slash for consistency @@ -182,6 +199,7 @@ locals { agentapi_chat_base_path = var.agentapi_subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${var.web_app_slug}/chat" main_script = file("${path.module}/scripts/main.sh") shutdown_script = file("${path.module}/scripts/agentapi-shutdown.sh") + lib_script = file("${path.module}/scripts/lib.sh") } resource "coder_script" "agentapi" { @@ -195,6 +213,7 @@ resource "coder_script" "agentapi" { echo -n '${base64encode(local.main_script)}' | base64 -d > /tmp/main.sh chmod +x /tmp/main.sh + echo -n '${base64encode(local.lib_script)}' | base64 -d > /tmp/agentapi-lib.sh ARG_MODULE_DIR_NAME='${var.module_dir_name}' \ ARG_WORKDIR="$(echo -n '${base64encode(local.workdir)}' | base64 -d)" \ @@ -209,6 +228,9 @@ resource "coder_script" "agentapi" { ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \ ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \ ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \ + ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \ + ARG_STATE_FILE_PATH='${var.state_file_path}' \ + ARG_PID_FILE_PATH='${var.pid_file_path}' \ /tmp/main.sh EOT run_on_start = true @@ -225,10 +247,14 @@ resource "coder_script" "agentapi_shutdown" { echo -n '${base64encode(local.shutdown_script)}' | base64 -d > /tmp/agentapi-shutdown.sh chmod +x /tmp/agentapi-shutdown.sh + echo -n '${base64encode(local.lib_script)}' | base64 -d > /tmp/agentapi-lib.sh ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \ ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \ ARG_AGENTAPI_PORT='${var.agentapi_port}' \ + ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \ + ARG_MODULE_DIR_NAME='${var.module_dir_name}' \ + ARG_PID_FILE_PATH='${var.pid_file_path}' \ /tmp/agentapi-shutdown.sh EOT } diff --git a/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh b/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh index bbee76282..60801516b 100644 --- a/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh +++ b/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash # AgentAPI shutdown script. # -# Captures the last 10 messages from AgentAPI and posts them to Coder instance -# as a snapshot. This script is called during workspace shutdown to access -# conversation history for paused tasks. +# Performs a graceful shutdown of AgentAPI: sends SIGUSR1 to trigger state save, +# captures the last 10 messages as a log snapshot posted to the Coder instance, +# then sends SIGTERM for graceful termination. set -euo pipefail @@ -11,6 +11,13 @@ set -euo pipefail readonly TASK_ID="${ARG_TASK_ID:-}" readonly TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}" readonly AGENTAPI_PORT="${ARG_AGENTAPI_PORT:-3284}" +readonly ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-true}" +readonly MODULE_DIR_NAME="${ARG_MODULE_DIR_NAME:-}" +readonly PID_FILE_PATH="${ARG_PID_FILE_PATH:-${MODULE_DIR_NAME:+$HOME/$MODULE_DIR_NAME/agentapi.pid}}" + +# Source shared utilities (written by the coder_script wrapper). +# shellcheck source=lib.sh +source /tmp/agentapi-lib.sh # Runtime environment variables. readonly CODER_AGENT_URL="${CODER_AGENT_URL:-}" @@ -138,29 +145,30 @@ post_task_log_snapshot() { capture_task_log_snapshot() { if [[ -z $TASK_ID ]]; then log "No task ID, skipping log snapshot" - exit 0 + return 0 fi if [[ -z $CODER_AGENT_URL ]]; then error "CODER_AGENT_URL not set, cannot capture log snapshot" - exit 1 + return 1 fi if [[ -z $CODER_AGENT_TOKEN ]]; then error "CODER_AGENT_TOKEN not set, cannot capture log snapshot" - exit 1 + return 1 fi if ! command -v jq > /dev/null 2>&1; then error "jq not found, cannot capture log snapshot" - exit 1 + return 1 fi if ! command -v curl > /dev/null 2>&1; then error "curl not found, cannot capture log snapshot" - exit 1 + return 1 fi + local tmpdir tmpdir=$(mktemp -d) trap 'rm -rf "$tmpdir"' EXIT @@ -168,14 +176,14 @@ capture_task_log_snapshot() { if ! fetch_and_build_messages_payload "$payload_file"; then error "Cannot capture log snapshot without messages" - exit 1 + return 1 fi local message_count message_count=$(jq '.messages | length' < "$payload_file") if ((message_count == 0)); then log "No messages for log snapshot" - exit 0 + return 0 fi log "Retrieved $message_count messages for log snapshot" @@ -183,7 +191,7 @@ capture_task_log_snapshot() { # Ensure payload fits within size limit. if ! truncate_messages_payload_to_size "$payload_file" "$MAX_PAYLOAD_SIZE"; then error "Failed to truncate payload to size limit" - exit 1 + return 1 fi local final_size final_count @@ -193,19 +201,60 @@ capture_task_log_snapshot() { if ! post_task_log_snapshot "$payload_file" "$tmpdir"; then error "Log snapshot capture failed" - exit 1 + return 1 fi } main() { log "Shutting down AgentAPI" + local agentapi_pid= + if [[ -n $PID_FILE_PATH ]]; then + agentapi_pid=$(cat "$PID_FILE_PATH" 2> /dev/null || echo "") + fi + + # State persistence is only enabled when the binary supports it (>= v0.12.0). + # The default SIGUSR1 disposition on Linux is terminate, so sending it to an + # older binary would kill the process. + local state_persistence=0 + if [[ $ENABLE_STATE_PERSISTENCE == true ]] && version_at_least 0.12.0 "$(agentapi_version)"; then + state_persistence=1 + fi + + # Trigger state save via SIGUSR1 (saves without exiting). + if ((state_persistence)) && [[ -n $agentapi_pid ]] && kill -0 "$agentapi_pid" 2> /dev/null; then + log "Sending SIGUSR1 to AgentAPI (pid $agentapi_pid) to save state" + kill -USR1 "$agentapi_pid" || true + # Allow time for state save to complete before proceeding. + sleep 1 + fi + + # Capture log snapshot for task history. if [[ $TASK_LOG_SNAPSHOT == true ]]; then - capture_task_log_snapshot + # Subshell scopes the EXIT trap (tmpdir cleanup) inside + # capture_task_log_snapshot and preserves set -e, which + # || would otherwise disable for the function body. + (capture_task_log_snapshot) || log "Log snapshot capture failed, continuing shutdown" else log "Log snapshot disabled, skipping" fi + # Graceful termination. + if [[ -n $agentapi_pid ]] && kill -0 "$agentapi_pid" 2> /dev/null; then + log "Sending SIGTERM to AgentAPI (pid $agentapi_pid)" + kill -TERM "$agentapi_pid" 2> /dev/null || true + + # Wait for process to exit to guarantee a clean shutdown. + local elapsed=0 + while kill -0 "$agentapi_pid" 2> /dev/null; do + sleep 1 + ((elapsed++)) || true + if ((elapsed % 5 == 0)); then + log "Warning: AgentAPI (pid $agentapi_pid) still running after ${elapsed}s" + fi + done + fi + log "Shutdown complete" } diff --git a/registry/coder/modules/agentapi/scripts/lib.sh b/registry/coder/modules/agentapi/scripts/lib.sh new file mode 100644 index 000000000..8d63d349b --- /dev/null +++ b/registry/coder/modules/agentapi/scripts/lib.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Shared utility functions for agentapi module scripts. + +# version_at_least checks if an actual version meets a minimum requirement. +# Non-semver strings (e.g. "latest", custom builds) always pass. +# Usage: version_at_least +# version_at_least v0.12.0 v0.10.0 # returns 1 (false) +# version_at_least v0.12.0 v0.12.0 # returns 0 (true) +# version_at_least v0.12.0 latest # returns 0 (true) +version_at_least() { + local min="${1#v}" + local actual="${2#v}" + + # Non-semver versions pass through (e.g. "latest", custom builds). + if ! [[ $actual =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + return 0 + fi + + local act_major="${BASH_REMATCH[1]}" + local act_minor="${BASH_REMATCH[2]}" + local act_patch="${BASH_REMATCH[3]}" + + [[ $min =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || return 0 + + local min_major="${BASH_REMATCH[1]}" + local min_minor="${BASH_REMATCH[2]}" + local min_patch="${BASH_REMATCH[3]}" + + if ((act_major != min_major)); then + ((act_major > min_major)) + return + fi + if ((act_minor != min_minor)); then + ((act_minor > min_minor)) + return + fi + ((act_patch >= min_patch)) +} + +# agentapi_version returns the installed agentapi binary version (e.g. "0.11.8"). +# Returns empty string if the binary is missing or doesn't support --version. +agentapi_version() { + agentapi --version 2> /dev/null | awk '{print $NF}' +} diff --git a/registry/coder/modules/agentapi/scripts/main.sh b/registry/coder/modules/agentapi/scripts/main.sh index 63e013eb9..b90f347c5 100644 --- a/registry/coder/modules/agentapi/scripts/main.sh +++ b/registry/coder/modules/agentapi/scripts/main.sh @@ -16,8 +16,14 @@ AGENTAPI_PORT="$ARG_AGENTAPI_PORT" AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}" TASK_ID="${ARG_TASK_ID:-}" TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}" +ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-true}" +STATE_FILE_PATH="${ARG_STATE_FILE_PATH:-}" +PID_FILE_PATH="${ARG_PID_FILE_PATH:-}" set +o nounset +# shellcheck source=lib.sh +source /tmp/agentapi-lib.sh + command_exists() { command -v "$1" > /dev/null 2>&1 } @@ -106,5 +112,18 @@ cd "${WORKDIR}" export AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}" # Disable host header check since AgentAPI is proxied by Coder (which does its own validation) export AGENTAPI_ALLOWED_HOSTS="*" +export AGENTAPI_PID_FILE="${PID_FILE_PATH:-$module_path/agentapi.pid}" +# Only set state env vars when persistence is enabled and the binary supports +# it. State persistence requires agentapi >= v0.12.0. +if [ "${ENABLE_STATE_PERSISTENCE}" = "true" ]; then + actual_version=$(agentapi_version) + if version_at_least 0.12.0 "$actual_version"; then + export AGENTAPI_STATE_FILE="${STATE_FILE_PATH:-$module_path/state.json}" + export AGENTAPI_SAVE_STATE="true" + export AGENTAPI_LOAD_STATE="true" + else + echo "Warning: State persistence requires agentapi >= v0.12.0 (current: ${actual_version:-unknown}), skipping." + fi +fi nohup "$module_path/scripts/agentapi-start.sh" true "${AGENTAPI_PORT}" &> "$module_path/agentapi-start.log" & "$module_path/scripts/agentapi-wait-for-start.sh" "${AGENTAPI_PORT}" diff --git a/registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js b/registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js index c6b0fb7fe..c53a0757a 100644 --- a/registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js +++ b/registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js @@ -3,8 +3,26 @@ // Usage: MESSAGES='[...]' node agentapi-mock-shutdown.js [port] const http = require("http"); +const fs = require("fs"); const port = process.argv[2] || 3284; +// Write PID file for shutdown script. +if (process.env.AGENTAPI_PID_FILE) { + const path = require("path"); + fs.mkdirSync(path.dirname(process.env.AGENTAPI_PID_FILE), { + recursive: true, + }); + fs.writeFileSync(process.env.AGENTAPI_PID_FILE, String(process.pid)); +} + +// Handle SIGUSR1 (state save signal from shutdown script). +process.on("SIGUSR1", () => { + fs.writeFileSync( + "/tmp/sigusr1-received", + `SIGUSR1 received at ${Date.now()}\n`, + ); +}); + // Parse messages from environment or use default let messages = []; if (process.env.MESSAGES) { diff --git a/registry/coder/modules/agentapi/testdata/agentapi-mock.js b/registry/coder/modules/agentapi/testdata/agentapi-mock.js index 72db716a3..84a88c047 100644 --- a/registry/coder/modules/agentapi/testdata/agentapi-mock.js +++ b/registry/coder/modules/agentapi/testdata/agentapi-mock.js @@ -6,12 +6,41 @@ const args = process.argv.slice(2); const portIdx = args.findIndex((arg) => arg === "--port") + 1; const port = portIdx ? args[portIdx] : 3284; +if (args.includes("--version")) { + console.log("agentapi version 99.99.99"); + process.exit(0); +} + console.log(`starting server on port ${port}`); fs.writeFileSync( "/home/coder/agentapi-mock.log", `AGENTAPI_ALLOWED_HOSTS: ${process.env.AGENTAPI_ALLOWED_HOSTS}`, ); +// Log state persistence env vars. +for (const v of [ + "AGENTAPI_STATE_FILE", + "AGENTAPI_PID_FILE", + "AGENTAPI_SAVE_STATE", + "AGENTAPI_LOAD_STATE", +]) { + if (process.env[v]) { + fs.appendFileSync( + "/home/coder/agentapi-mock.log", + `\n${v}: ${process.env[v]}`, + ); + } +} + +// Write PID file for shutdown script. +if (process.env.AGENTAPI_PID_FILE) { + const path = require("path"); + fs.mkdirSync(path.dirname(process.env.AGENTAPI_PID_FILE), { + recursive: true, + }); + fs.writeFileSync(process.env.AGENTAPI_PID_FILE, String(process.pid)); +} + http .createServer(function (_request, response) { response.writeHead(200); From d0cbc01bab4647cd2824787d55754f265ef3108c Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 24 Feb 2026 11:53:57 +0000 Subject: [PATCH 2/8] fix: change state file name --- registry/coder/modules/agentapi/README.md | 2 +- registry/coder/modules/agentapi/main.test.ts | 2 +- registry/coder/modules/agentapi/main.tf | 2 +- registry/coder/modules/agentapi/scripts/main.sh | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/registry/coder/modules/agentapi/README.md b/registry/coder/modules/agentapi/README.md index fffb948c0..89aa27088 100644 --- a/registry/coder/modules/agentapi/README.md +++ b/registry/coder/modules/agentapi/README.md @@ -68,7 +68,7 @@ AgentAPI can save and restore conversation state across workspace restarts. This is enabled by default and requires agentapi binary >= v0.12.0. State and PID files are stored in `$HOME//` alongside other -module files (e.g. `$HOME/.claude-module/state.json`). +module files (e.g. `$HOME/.claude-module/agentapi-state.json`). To disable: diff --git a/registry/coder/modules/agentapi/main.test.ts b/registry/coder/modules/agentapi/main.test.ts index 84fa412a1..5977a756c 100644 --- a/registry/coder/modules/agentapi/main.test.ts +++ b/registry/coder/modules/agentapi/main.test.ts @@ -308,7 +308,7 @@ describe("agentapi", async () => { "/home/coder/agentapi-mock.log", ); expect(mockLog).toContain( - `AGENTAPI_STATE_FILE: /home/coder/${moduleDirName}/state.json`, + `AGENTAPI_STATE_FILE: /home/coder/${moduleDirName}/agentapi-state.json`, ); expect(mockLog).toContain( `AGENTAPI_PID_FILE: /home/coder/${moduleDirName}/agentapi.pid`, diff --git a/registry/coder/modules/agentapi/main.tf b/registry/coder/modules/agentapi/main.tf index 5629e8032..6d80dfae7 100644 --- a/registry/coder/modules/agentapi/main.tf +++ b/registry/coder/modules/agentapi/main.tf @@ -172,7 +172,7 @@ variable "enable_state_persistence" { variable "state_file_path" { type = string - description = "Path to the AgentAPI state file. Defaults to $HOME//state.json." + description = "Path to the AgentAPI state file. Defaults to $HOME//agentapi-state.json." default = "" } diff --git a/registry/coder/modules/agentapi/scripts/main.sh b/registry/coder/modules/agentapi/scripts/main.sh index b90f347c5..7a76f78bd 100644 --- a/registry/coder/modules/agentapi/scripts/main.sh +++ b/registry/coder/modules/agentapi/scripts/main.sh @@ -118,7 +118,7 @@ export AGENTAPI_PID_FILE="${PID_FILE_PATH:-$module_path/agentapi.pid}" if [ "${ENABLE_STATE_PERSISTENCE}" = "true" ]; then actual_version=$(agentapi_version) if version_at_least 0.12.0 "$actual_version"; then - export AGENTAPI_STATE_FILE="${STATE_FILE_PATH:-$module_path/state.json}" + export AGENTAPI_STATE_FILE="${STATE_FILE_PATH:-$module_path/agentapi-state.json}" export AGENTAPI_SAVE_STATE="true" export AGENTAPI_LOAD_STATE="true" else From d871ce897bfaa364a446450741cb21ac3a8bbadd Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 24 Feb 2026 12:26:56 +0000 Subject: [PATCH 3/8] agentapi: default enable_state_persistence to false No consumer modules ship agentapi >= v0.12.0 yet, so the feature was silently skipped at runtime while emitting a misleading warning on every workspace start. Modules can opt in explicitly when ready. --- .../coder/modules/agentapi/agentapi.tftest.hcl | 4 ++-- registry/coder/modules/agentapi/main.test.ts | 15 +++++++++++---- registry/coder/modules/agentapi/main.tf | 2 +- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/registry/coder/modules/agentapi/agentapi.tftest.hcl b/registry/coder/modules/agentapi/agentapi.tftest.hcl index 4d69926b0..87404c625 100644 --- a/registry/coder/modules/agentapi/agentapi.tftest.hcl +++ b/registry/coder/modules/agentapi/agentapi.tftest.hcl @@ -15,8 +15,8 @@ run "default_values" { command = plan assert { - condition = var.enable_state_persistence == true - error_message = "enable_state_persistence should default to true" + condition = var.enable_state_persistence == false + error_message = "enable_state_persistence should default to false" } assert { diff --git a/registry/coder/modules/agentapi/main.test.ts b/registry/coder/modules/agentapi/main.test.ts index 5977a756c..422b2811b 100644 --- a/registry/coder/modules/agentapi/main.test.ts +++ b/registry/coder/modules/agentapi/main.test.ts @@ -281,6 +281,7 @@ describe("agentapi", async () => { test("state-persistence-custom-paths", async () => { const { id } = await setup({ moduleVariables: { + enable_state_persistence: "true", state_file_path: "/home/coder/custom/state.json", pid_file_path: "/home/coder/custom/agentapi.pid", }, @@ -300,7 +301,11 @@ describe("agentapi", async () => { }); test("state-persistence-default-paths", async () => { - const { id } = await setup(); + const { id } = await setup({ + moduleVariables: { + enable_state_persistence: "true", + }, + }); await execModuleScript(id); await expectAgentAPIStarted(id); const mockLog = await readFileContainer( @@ -365,7 +370,7 @@ describe("agentapi", async () => { containerId: string, taskId: string = "test-task", pidFilePath: string = "", - enableStatePersistence: string = "true", + enableStatePersistence: string = "false", ) => { const shutdownScript = await loadTestFile( import.meta.dir, @@ -506,11 +511,13 @@ describe("agentapi", async () => { skipAgentAPIMock: true, }); await setupMocks(id, "normal"); - // Pass a non-existent PID file path + // Pass a non-existent PID file path with persistence enabled to + // exercise the SIGUSR1 path with a missing PID. const result = await runShutdownScript( id, "test-task", "/tmp/nonexistent.pid", + "true", ); expect(result.exitCode).toBe(0); @@ -525,7 +532,7 @@ describe("agentapi", async () => { const pidFile = "/tmp/agentapi-test.pid"; // HTTP 500 will cause snapshot to fail await setupMocks(id, "normal", 500, pidFile); - const result = await runShutdownScript(id, "test-task", pidFile); + const result = await runShutdownScript(id, "test-task", pidFile, "true"); expect(result.exitCode).toBe(0); expect(result.stdout).toContain( diff --git a/registry/coder/modules/agentapi/main.tf b/registry/coder/modules/agentapi/main.tf index 6d80dfae7..8818736d7 100644 --- a/registry/coder/modules/agentapi/main.tf +++ b/registry/coder/modules/agentapi/main.tf @@ -167,7 +167,7 @@ variable "module_dir_name" { variable "enable_state_persistence" { type = bool description = "Enable AgentAPI conversation state persistence across restarts." - default = true + default = false } variable "state_file_path" { From 389955d0569273f950fd98862b68101b0dbd1755 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 24 Feb 2026 12:32:11 +0000 Subject: [PATCH 4/8] fix: change default in scripts too --- registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh | 2 +- registry/coder/modules/agentapi/scripts/main.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh b/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh index 60801516b..1b748836a 100644 --- a/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh +++ b/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh @@ -11,7 +11,7 @@ set -euo pipefail readonly TASK_ID="${ARG_TASK_ID:-}" readonly TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}" readonly AGENTAPI_PORT="${ARG_AGENTAPI_PORT:-3284}" -readonly ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-true}" +readonly ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}" readonly MODULE_DIR_NAME="${ARG_MODULE_DIR_NAME:-}" readonly PID_FILE_PATH="${ARG_PID_FILE_PATH:-${MODULE_DIR_NAME:+$HOME/$MODULE_DIR_NAME/agentapi.pid}}" diff --git a/registry/coder/modules/agentapi/scripts/main.sh b/registry/coder/modules/agentapi/scripts/main.sh index 7a76f78bd..928132c8c 100644 --- a/registry/coder/modules/agentapi/scripts/main.sh +++ b/registry/coder/modules/agentapi/scripts/main.sh @@ -16,7 +16,7 @@ AGENTAPI_PORT="$ARG_AGENTAPI_PORT" AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}" TASK_ID="${ARG_TASK_ID:-}" TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}" -ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-true}" +ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}" STATE_FILE_PATH="${ARG_STATE_FILE_PATH:-}" PID_FILE_PATH="${ARG_PID_FILE_PATH:-}" set +o nounset From a817fa80cc5ce32c4f7112abdd770d68c1fd5d63 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 24 Feb 2026 12:40:19 +0000 Subject: [PATCH 5/8] agentapi: update README for default false state persistence --- registry/coder/modules/agentapi/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/registry/coder/modules/agentapi/README.md b/registry/coder/modules/agentapi/README.md index 89aa27088..c5e9ae423 100644 --- a/registry/coder/modules/agentapi/README.md +++ b/registry/coder/modules/agentapi/README.md @@ -65,17 +65,17 @@ module "agentapi" { ## State Persistence AgentAPI can save and restore conversation state across workspace restarts. -This is enabled by default and requires agentapi binary >= v0.12.0. +This is disabled by default and requires agentapi binary >= v0.12.0. State and PID files are stored in `$HOME//` alongside other module files (e.g. `$HOME/.claude-module/agentapi-state.json`). -To disable: +To enable: ```tf module "agentapi" { # ... other config - enable_state_persistence = false + enable_state_persistence = true } ``` From e7233b36bd1b2e59e48179fcf6ef177c808312ab Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 24 Feb 2026 12:41:28 +0000 Subject: [PATCH 6/8] agentapi: clarify arithmetic exit status in version_at_least --- registry/coder/modules/agentapi/scripts/lib.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/registry/coder/modules/agentapi/scripts/lib.sh b/registry/coder/modules/agentapi/scripts/lib.sh index 8d63d349b..20bdef479 100644 --- a/registry/coder/modules/agentapi/scripts/lib.sh +++ b/registry/coder/modules/agentapi/scripts/lib.sh @@ -26,6 +26,7 @@ version_at_least() { local min_minor="${BASH_REMATCH[2]}" local min_patch="${BASH_REMATCH[3]}" + # Arithmetic expressions set exit status: 0 (true) if non-zero, 1 (false) if zero. if ((act_major != min_major)); then ((act_major > min_major)) return From aa27b0b993d70c952ae77f0b279f4bd5d892ef0c Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 24 Feb 2026 13:03:14 +0000 Subject: [PATCH 7/8] agentapi: fix unbound tmpdir in shutdown EXIT trap The subshell EXIT trap references $tmpdir which is local to capture_task_log_snapshot. When the trap fires during subshell exit, the variable is out of scope, causing a nounset error after the snapshot posts successfully. --- registry/coder/modules/agentapi/main.test.ts | 1 + registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/registry/coder/modules/agentapi/main.test.ts b/registry/coder/modules/agentapi/main.test.ts index 422b2811b..cedf840c2 100644 --- a/registry/coder/modules/agentapi/main.test.ts +++ b/registry/coder/modules/agentapi/main.test.ts @@ -413,6 +413,7 @@ describe("agentapi", async () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Retrieved 5 messages for log snapshot"); expect(result.stdout).toContain("Log snapshot posted successfully"); + expect(result.stdout).not.toContain("Log snapshot capture failed"); const posted = await readFileContainer(id, "/tmp/snapshot-posted.json"); const snapshot = JSON.parse(posted); diff --git a/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh b/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh index 1b748836a..92531e0b9 100644 --- a/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh +++ b/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh @@ -168,9 +168,9 @@ capture_task_log_snapshot() { return 1 fi - local tmpdir + # Not local, must be visible to the EXIT trap after the function returns. tmpdir=$(mktemp -d) - trap 'rm -rf "$tmpdir"' EXIT + trap 'trap - EXIT; rm -rf "$tmpdir"' EXIT local payload_file="${tmpdir}/payload.json" From 695efbb3c492cda2ee0ff2814b3e2bc373405498 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 24 Feb 2026 19:22:14 +0000 Subject: [PATCH 8/8] fix: increase fetch timeout It seems that agentapi can block for longer than 5s sometimes: ``` Shutting down AgentAPI Sending SIGUSR1 to AgentAPI (pid 44876) to save state Fetching messages from AgentAPI on port 3284 curl: (28) Operation timed out after 5001 milliseconds with 0 bytes received Error: Failed to fetch messages from AgentAPI (may not be running) Error: Cannot capture log snapshot without messages Log snapshot capture failed, continuing shutdown Sending SIGTERM to AgentAPI (pid 44876) Warning: AgentAPI (pid 44876) still running after 5s Shutdown complete ``` --- registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh b/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh index 92531e0b9..8de176e44 100644 --- a/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh +++ b/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh @@ -27,7 +27,7 @@ readonly CODER_AGENT_TOKEN="${CODER_AGENT_TOKEN:-}" readonly MAX_PAYLOAD_SIZE=65536 # 64KB readonly MAX_MESSAGE_CONTENT=57344 # 56KB readonly MAX_MESSAGES=10 -readonly FETCH_TIMEOUT=5 +readonly FETCH_TIMEOUT=10 readonly POST_TIMEOUT=10 log() {