Skip to content

Commit 955441d

Browse files
committed
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/<module_dir_name>/state.json) - pid_file_path (string, defaults to $HOME/<module_dir_name>/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
1 parent 8defcb2 commit 955441d

9 files changed

Lines changed: 523 additions & 16 deletions

File tree

registry/coder/modules/agentapi/README.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI
1616
```tf
1717
module "agentapi" {
1818
source = "registry.coder.com/coder/agentapi/coder"
19-
version = "2.1.1"
19+
version = "2.2.0"
2020
2121
agent_id = var.agent_id
2222
web_app_slug = local.app_slug
@@ -62,6 +62,33 @@ module "agentapi" {
6262
}
6363
```
6464

65+
## State Persistence
66+
67+
AgentAPI can save and restore conversation state across workspace restarts.
68+
This is enabled by default and requires agentapi binary >= v0.12.0.
69+
70+
State and PID files are stored in `$HOME/<module_dir_name>/` alongside other
71+
module files (e.g. `$HOME/.claude-module/state.json`).
72+
73+
To disable:
74+
75+
```tf
76+
module "agentapi" {
77+
# ... other config
78+
enable_state_persistence = false
79+
}
80+
```
81+
82+
To override file paths:
83+
84+
```tf
85+
module "agentapi" {
86+
# ... other config
87+
state_file_path = "/custom/path/state.json"
88+
pid_file_path = "/custom/path/agentapi.pid"
89+
}
90+
```
91+
6592
## For module developers
6693

6794
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).
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
mock_provider "coder" {}
2+
3+
variables {
4+
agent_id = "test-agent"
5+
web_app_icon = "/icon/test.svg"
6+
web_app_display_name = "Test"
7+
web_app_slug = "test"
8+
cli_app_display_name = "Test CLI"
9+
cli_app_slug = "test-cli"
10+
start_script = "echo test"
11+
module_dir_name = ".test-module"
12+
}
13+
14+
run "default_values" {
15+
command = plan
16+
17+
assert {
18+
condition = var.enable_state_persistence == true
19+
error_message = "enable_state_persistence should default to true"
20+
}
21+
22+
assert {
23+
condition = var.state_file_path == ""
24+
error_message = "state_file_path should default to empty string"
25+
}
26+
27+
assert {
28+
condition = var.pid_file_path == ""
29+
error_message = "pid_file_path should default to empty string"
30+
}
31+
32+
# Verify start script contains state persistence ARG_ vars.
33+
assert {
34+
condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE", coder_script.agentapi.script))
35+
error_message = "start script should contain ARG_ENABLE_STATE_PERSISTENCE"
36+
}
37+
38+
assert {
39+
condition = can(regex("ARG_STATE_FILE_PATH", coder_script.agentapi.script))
40+
error_message = "start script should contain ARG_STATE_FILE_PATH"
41+
}
42+
43+
assert {
44+
condition = can(regex("ARG_PID_FILE_PATH", coder_script.agentapi.script))
45+
error_message = "start script should contain ARG_PID_FILE_PATH"
46+
}
47+
48+
# Verify shutdown script contains PID-related ARG_ vars.
49+
assert {
50+
condition = can(regex("ARG_PID_FILE_PATH", coder_script.agentapi_shutdown.script))
51+
error_message = "shutdown script should contain ARG_PID_FILE_PATH"
52+
}
53+
54+
assert {
55+
condition = can(regex("ARG_MODULE_DIR_NAME", coder_script.agentapi_shutdown.script))
56+
error_message = "shutdown script should contain ARG_MODULE_DIR_NAME"
57+
}
58+
59+
assert {
60+
condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE", coder_script.agentapi_shutdown.script))
61+
error_message = "shutdown script should contain ARG_ENABLE_STATE_PERSISTENCE"
62+
}
63+
}
64+
65+
run "state_persistence_disabled" {
66+
command = plan
67+
68+
variables {
69+
enable_state_persistence = false
70+
}
71+
72+
assert {
73+
condition = var.enable_state_persistence == false
74+
error_message = "enable_state_persistence should be false"
75+
}
76+
77+
# Even when disabled, the ARG_ vars should still be in the script
78+
# (the shell script handles the conditional logic).
79+
assert {
80+
condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE='false'", coder_script.agentapi.script))
81+
error_message = "start script should contain ARG_ENABLE_STATE_PERSISTENCE='false'"
82+
}
83+
}
84+
85+
run "custom_paths" {
86+
command = plan
87+
88+
variables {
89+
state_file_path = "/custom/state.json"
90+
pid_file_path = "/custom/agentapi.pid"
91+
}
92+
93+
assert {
94+
condition = can(regex("/custom/state.json", coder_script.agentapi.script))
95+
error_message = "start script should contain custom state_file_path"
96+
}
97+
98+
assert {
99+
condition = can(regex("/custom/agentapi.pid", coder_script.agentapi.script))
100+
error_message = "start script should contain custom pid_file_path"
101+
}
102+
103+
# Verify custom paths also appear in shutdown script.
104+
assert {
105+
condition = can(regex("/custom/agentapi.pid", coder_script.agentapi_shutdown.script))
106+
error_message = "shutdown script should contain custom pid_file_path"
107+
}
108+
}

registry/coder/modules/agentapi/main.test.ts

Lines changed: 193 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,11 +258,67 @@ describe("agentapi", async () => {
258258
expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *");
259259
});
260260

261+
test("state-persistence-disabled", async () => {
262+
const { id } = await setup({
263+
moduleVariables: {
264+
enable_state_persistence: "false",
265+
},
266+
});
267+
await execModuleScript(id);
268+
await expectAgentAPIStarted(id);
269+
const mockLog = await readFileContainer(
270+
id,
271+
"/home/coder/agentapi-mock.log",
272+
);
273+
// PID file should always be exported
274+
expect(mockLog).toContain("AGENTAPI_PID_FILE:");
275+
// State vars should NOT be present when disabled
276+
expect(mockLog).not.toContain("AGENTAPI_STATE_FILE:");
277+
expect(mockLog).not.toContain("AGENTAPI_SAVE_STATE:");
278+
expect(mockLog).not.toContain("AGENTAPI_LOAD_STATE:");
279+
});
280+
281+
test("state-persistence-custom-paths", async () => {
282+
const { id } = await setup({
283+
moduleVariables: {
284+
state_file_path: "/custom/path/state.json",
285+
pid_file_path: "/custom/path/agentapi.pid",
286+
},
287+
});
288+
await execModuleScript(id);
289+
await expectAgentAPIStarted(id);
290+
const mockLog = await readFileContainer(
291+
id,
292+
"/home/coder/agentapi-mock.log",
293+
);
294+
expect(mockLog).toContain("AGENTAPI_STATE_FILE: /custom/path/state.json");
295+
expect(mockLog).toContain("AGENTAPI_PID_FILE: /custom/path/agentapi.pid");
296+
});
297+
298+
test("state-persistence-default-paths", async () => {
299+
const { id } = await setup();
300+
await execModuleScript(id);
301+
await expectAgentAPIStarted(id);
302+
const mockLog = await readFileContainer(
303+
id,
304+
"/home/coder/agentapi-mock.log",
305+
);
306+
expect(mockLog).toContain(
307+
`AGENTAPI_STATE_FILE: /home/coder/${moduleDirName}/state.json`,
308+
);
309+
expect(mockLog).toContain(
310+
`AGENTAPI_PID_FILE: /home/coder/${moduleDirName}/agentapi.pid`,
311+
);
312+
expect(mockLog).toContain("AGENTAPI_SAVE_STATE: true");
313+
expect(mockLog).toContain("AGENTAPI_LOAD_STATE: true");
314+
});
315+
261316
describe("shutdown script", async () => {
262317
const setupMocks = async (
263318
containerId: string,
264319
agentapiPreset: string,
265320
httpCode: number = 204,
321+
pidFilePath: string = "",
266322
) => {
267323
const agentapiMock = await loadTestFile(
268324
import.meta.dir,
@@ -285,10 +341,11 @@ describe("agentapi", async () => {
285341
content: coderMock,
286342
});
287343

344+
const pidFileEnv = pidFilePath ? `AGENTAPI_PID_FILE=${pidFilePath}` : "";
288345
await execContainer(containerId, [
289346
"bash",
290347
"-c",
291-
`PRESET=${agentapiPreset} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`,
348+
`PRESET=${agentapiPreset} ${pidFileEnv} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`,
292349
]);
293350

294351
await execContainer(containerId, [
@@ -303,12 +360,25 @@ describe("agentapi", async () => {
303360
const runShutdownScript = async (
304361
containerId: string,
305362
taskId: string = "test-task",
363+
pidFilePath: string = "",
364+
enableStatePersistence: string = "true",
306365
) => {
307366
const shutdownScript = await loadTestFile(
308367
import.meta.dir,
309368
"../scripts/agentapi-shutdown.sh",
310369
);
311370

371+
const libScript = await loadTestFile(
372+
import.meta.dir,
373+
"../scripts/lib.sh",
374+
);
375+
376+
await writeExecutable({
377+
containerId,
378+
filePath: "/tmp/agentapi-lib.sh",
379+
content: libScript,
380+
});
381+
312382
await writeExecutable({
313383
containerId,
314384
filePath: "/tmp/shutdown.sh",
@@ -318,7 +388,7 @@ describe("agentapi", async () => {
318388
return await execContainer(containerId, [
319389
"bash",
320390
"-c",
321-
`ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
391+
`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`,
322392
]);
323393
};
324394

@@ -409,5 +479,126 @@ describe("agentapi", async () => {
409479
"Log snapshot endpoint not supported by this Coder version",
410480
);
411481
});
482+
483+
test("sends SIGUSR1 before shutdown", async () => {
484+
const { id } = await setup({
485+
moduleVariables: {},
486+
skipAgentAPIMock: true,
487+
});
488+
const pidFile = "/tmp/agentapi-test.pid";
489+
await setupMocks(id, "normal", 204, pidFile);
490+
const result = await runShutdownScript(id, "test-task", pidFile, "true");
491+
492+
expect(result.exitCode).toBe(0);
493+
expect(result.stdout).toContain("Sending SIGUSR1 to AgentAPI");
494+
495+
const sigusr1Log = await readFileContainer(id, "/tmp/sigusr1-received");
496+
expect(sigusr1Log).toContain("SIGUSR1 received");
497+
});
498+
499+
test("handles missing PID file gracefully", async () => {
500+
const { id } = await setup({
501+
moduleVariables: {},
502+
skipAgentAPIMock: true,
503+
});
504+
await setupMocks(id, "normal");
505+
// Pass a non-existent PID file path
506+
const result = await runShutdownScript(
507+
id,
508+
"test-task",
509+
"/tmp/nonexistent.pid",
510+
);
511+
512+
expect(result.exitCode).toBe(0);
513+
expect(result.stdout).toContain("Shutdown complete");
514+
});
515+
516+
test("sends SIGTERM even when snapshot fails", async () => {
517+
const { id } = await setup({
518+
moduleVariables: {},
519+
skipAgentAPIMock: true,
520+
});
521+
const pidFile = "/tmp/agentapi-test.pid";
522+
// HTTP 500 will cause snapshot to fail
523+
await setupMocks(id, "normal", 500, pidFile);
524+
const result = await runShutdownScript(id, "test-task", pidFile);
525+
526+
expect(result.exitCode).toBe(0);
527+
expect(result.stdout).toContain(
528+
"Log snapshot capture failed, continuing shutdown",
529+
);
530+
expect(result.stdout).toContain("Sending SIGTERM to AgentAPI");
531+
});
532+
533+
test("resolves default PID path from MODULE_DIR_NAME", async () => {
534+
const { id } = await setup({
535+
moduleVariables: {},
536+
skipAgentAPIMock: true,
537+
});
538+
// Start mock with PID file at the module_dir_name default location.
539+
const defaultPidPath = `/home/coder/${moduleDirName}/agentapi.pid`;
540+
await setupMocks(id, "normal", 204, defaultPidPath);
541+
// Don't pass pidFilePath - let shutdown script compute it from MODULE_DIR_NAME.
542+
const shutdownScript = await loadTestFile(
543+
import.meta.dir,
544+
"../scripts/agentapi-shutdown.sh",
545+
);
546+
const libScript = await loadTestFile(
547+
import.meta.dir,
548+
"../scripts/lib.sh",
549+
);
550+
await writeExecutable({
551+
containerId: id,
552+
filePath: "/tmp/agentapi-lib.sh",
553+
content: libScript,
554+
});
555+
await writeExecutable({
556+
containerId: id,
557+
filePath: "/tmp/shutdown.sh",
558+
content: shutdownScript,
559+
});
560+
const result = await execContainer(id, [
561+
"bash",
562+
"-c",
563+
`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`,
564+
]);
565+
566+
expect(result.exitCode).toBe(0);
567+
expect(result.stdout).toContain("Sending SIGUSR1 to AgentAPI");
568+
expect(result.stdout).toContain("Sending SIGTERM to AgentAPI");
569+
});
570+
571+
test("skips SIGUSR1 when no PID file available", async () => {
572+
const { id } = await setup({
573+
moduleVariables: {},
574+
skipAgentAPIMock: true,
575+
});
576+
await setupMocks(id, "normal", 204);
577+
// No pidFilePath and no MODULE_DIR_NAME, so no PID file can be resolved.
578+
const result = await runShutdownScript(id, "test-task", "", "false");
579+
580+
expect(result.exitCode).toBe(0);
581+
// Should not send SIGUSR1 or SIGTERM (no PID to signal).
582+
expect(result.stdout).not.toContain("Sending SIGUSR1");
583+
expect(result.stdout).not.toContain("Sending SIGTERM");
584+
expect(result.stdout).toContain("Shutdown complete");
585+
});
586+
587+
test("skips SIGUSR1 when state persistence disabled", async () => {
588+
const { id } = await setup({
589+
moduleVariables: {},
590+
skipAgentAPIMock: true,
591+
});
592+
const pidFile = "/tmp/agentapi-test.pid";
593+
await setupMocks(id, "normal", 204, pidFile);
594+
// PID file exists but state persistence is disabled.
595+
const result = await runShutdownScript(id, "test-task", pidFile, "false");
596+
597+
expect(result.exitCode).toBe(0);
598+
// Should NOT send SIGUSR1 (persistence disabled).
599+
expect(result.stdout).not.toContain("Sending SIGUSR1");
600+
// Should still send SIGTERM (graceful shutdown always happens).
601+
expect(result.stdout).toContain("Sending SIGTERM to AgentAPI");
602+
});
412603
});
413604
});

0 commit comments

Comments
 (0)