Compare commits

...

2 Commits

Author SHA1 Message Date
Mathias Fredriksson 7b245549ec feat(coder/modules/claude-code): add enable_state_persistence variable (#749)
feat(coder/modules/claude-code): add enable_state_persistence variable

Expose the agentapi module's state persistence toggle so users can
control conversation state persistence across workspace restarts.
Enabled by default, set `enable_state_persistence = false` to disable.

Also bumps agentapi dependency from 2.0.0 to 2.2.0 and claude-code
to 4.8.0.

Refs coder/internal#1258
2026-03-03 18:03:57 +02:00
Mathias Fredriksson 2169fb00ee feat(coder/modules/agentapi): add state persistence support (#736)
AgentAPI can now save and restore conversation state across workspace
restarts. The module exports env vars (AGENTAPI_STATE_FILE,
AGENTAPI_SAVE_STATE, AGENTAPI_LOAD_STATE, AGENTAPI_PID_FILE) that the
binary reads directly. No consumer module changes needed.

New variables: enable_state_persistence (default false),
state_file_path, pid_file_path. State and PID files default to
$HOME/<module_dir_name>/.

Requires agentapi >= v0.12.0. A shared version_at_least function in
lib.sh gates the env var exports and SIGUSR1 in the shutdown script.
Old binaries get a warning and graceful skip.

Shutdown script now does SIGUSR1 (state save), log snapshot capture
(existing, now fault-tolerant via subshell), then SIGTERM with wait.

Closes coder/internal#1257
Refs coder/internal#1256
Refs #696
2026-03-03 13:27:23 +02:00
12 changed files with 619 additions and 45 deletions
+28 -1
View File
@@ -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 disabled by default and requires agentapi binary >= v0.12.0.
State and PID files are stored in `$HOME/<module_dir_name>/` alongside other
module files (e.g. `$HOME/.claude-module/agentapi-state.json`).
To enable:
```tf
module "agentapi" {
# ... other config
enable_state_persistence = true
}
```
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).
@@ -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 == false
error_message = "enable_state_persistence should default to false"
}
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"
}
}
+205 -2
View File
@@ -258,11 +258,76 @@ 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: {
enable_state_persistence: "true",
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({
moduleVariables: {
enable_state_persistence: "true",
},
});
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}/agentapi-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 +350,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 +369,25 @@ describe("agentapi", async () => {
const runShutdownScript = async (
containerId: string,
taskId: string = "test-task",
pidFilePath: string = "",
enableStatePersistence: string = "false",
) => {
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 +397,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`,
]);
};
@@ -334,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);
@@ -409,5 +489,128 @@ 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 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);
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, "true");
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");
});
});
});
+26
View File
@@ -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 = false
}
variable "state_file_path" {
type = string
description = "Path to the AgentAPI state file. Defaults to $HOME/<module_dir_name>/agentapi-state.json."
default = ""
}
variable "pid_file_path" {
type = string
description = "Path to the AgentAPI PID file. Defaults to $HOME/<module_dir_name>/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
}
@@ -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:-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}}"
# 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:-}"
@@ -20,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() {
@@ -138,44 +145,45 @@ 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
# 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"
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"
}
@@ -0,0 +1,45 @@
#!/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 <minimum> <actual>
# 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]}"
# 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
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}'
}
@@ -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:-false}"
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/agentapi-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}"
@@ -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) {
@@ -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);
+22 -9
View File
@@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5"
version = "4.8.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
claude_api_key = "xxxx-xxxxx-xxxx"
@@ -36,6 +36,19 @@ module "claude-code" {
By default, Claude Code automatically resumes existing conversations when your workspace restarts. Sessions are tracked per workspace directory, so conversations continue where you left off. If no session exists (first start), your `ai_prompt` will run normally. To disable this behavior and always start fresh, set `continue = false`
## State Persistence
AgentAPI can save and restore its conversation state to disk across workspace restarts. This complements `continue` (which resumes the Claude CLI session) by also preserving the AgentAPI-level context. Enabled by default, requires agentapi >= v0.12.0 (older versions skip it with a warning).
To disable:
```tf
module "claude-code" {
# ... other config
enable_state_persistence = false
}
```
## Examples
### Usage with Agent Boundaries
@@ -47,7 +60,7 @@ By default, when `enable_boundary = true`, the module uses `coder boundary` subc
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5"
version = "4.8.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_boundary = true
@@ -68,7 +81,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5"
version = "4.8.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_aibridge = true
@@ -97,7 +110,7 @@ data "coder_task" "me" {}
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5"
version = "4.8.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
ai_prompt = data.coder_task.me.prompt
@@ -120,7 +133,7 @@ This example shows additional configuration options for version pinning, custom
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5"
version = "4.8.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
@@ -176,7 +189,7 @@ Run and configure Claude Code as a standalone CLI in your workspace.
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5"
version = "4.8.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
install_claude_code = true
@@ -198,7 +211,7 @@ variable "claude_code_oauth_token" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5"
version = "4.8.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
claude_code_oauth_token = var.claude_code_oauth_token
@@ -271,7 +284,7 @@ resource "coder_env" "bedrock_api_key" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5"
version = "4.8.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
@@ -328,7 +341,7 @@ resource "coder_env" "google_application_credentials" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5"
version = "4.8.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "claude-sonnet-4@20250514"
+25 -18
View File
@@ -261,6 +261,12 @@ variable "enable_aibridge" {
}
}
variable "enable_state_persistence" {
type = bool
description = "Enable AgentAPI conversation state persistence across restarts."
default = true
}
resource "coder_env" "claude_code_md_path" {
count = var.claude_md_path == "" ? 0 : 1
agent_id = var.agent_id
@@ -356,25 +362,26 @@ locals {
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "2.0.0"
version = "2.2.0"
agent_id = var.agent_id
web_app_slug = local.app_slug
web_app_order = var.order
web_app_group = var.group
web_app_icon = var.icon
web_app_display_name = var.web_app_display_name
folder = local.workdir
cli_app = var.cli_app
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
agentapi_subdomain = var.subdomain
module_dir_name = local.module_dir_name
install_agentapi = var.install_agentapi
agentapi_version = var.agentapi_version
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
start_script = <<-EOT
agent_id = var.agent_id
web_app_slug = local.app_slug
web_app_order = var.order
web_app_group = var.group
web_app_icon = var.icon
web_app_display_name = var.web_app_display_name
folder = local.workdir
cli_app = var.cli_app
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
agentapi_subdomain = var.subdomain
module_dir_name = local.module_dir_name
install_agentapi = var.install_agentapi
agentapi_version = var.agentapi_version
enable_state_persistence = var.enable_state_persistence
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
start_script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
@@ -387,6 +387,36 @@ run "test_aibridge_disabled_with_api_key" {
}
}
run "test_enable_state_persistence_default" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
}
assert {
condition = var.enable_state_persistence == true
error_message = "enable_state_persistence should default to true"
}
}
run "test_disable_state_persistence" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
enable_state_persistence = false
}
assert {
condition = var.enable_state_persistence == false
error_message = "enable_state_persistence should be false when explicitly disabled"
}
}
run "test_no_api_key_no_env" {
command = plan