Skip to content

Commit 1435939

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 1435939

9 files changed

Lines changed: 531 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: 197 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,11 +258,71 @@ 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: "/home/coder/custom/state.json",
285+
pid_file_path: "/home/coder/custom/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(
295+
"AGENTAPI_STATE_FILE: /home/coder/custom/state.json",
296+
);
297+
expect(mockLog).toContain(
298+
"AGENTAPI_PID_FILE: /home/coder/custom/agentapi.pid",
299+
);
300+
});
301+
302+
test("state-persistence-default-paths", async () => {
303+
const { id } = await setup();
304+
await execModuleScript(id);
305+
await expectAgentAPIStarted(id);
306+
const mockLog = await readFileContainer(
307+
id,
308+
"/home/coder/agentapi-mock.log",
309+
);
310+
expect(mockLog).toContain(
311+
`AGENTAPI_STATE_FILE: /home/coder/${moduleDirName}/state.json`,
312+
);
313+
expect(mockLog).toContain(
314+
`AGENTAPI_PID_FILE: /home/coder/${moduleDirName}/agentapi.pid`,
315+
);
316+
expect(mockLog).toContain("AGENTAPI_SAVE_STATE: true");
317+
expect(mockLog).toContain("AGENTAPI_LOAD_STATE: true");
318+
});
319+
261320
describe("shutdown script", async () => {
262321
const setupMocks = async (
263322
containerId: string,
264323
agentapiPreset: string,
265324
httpCode: number = 204,
325+
pidFilePath: string = "",
266326
) => {
267327
const agentapiMock = await loadTestFile(
268328
import.meta.dir,
@@ -285,10 +345,11 @@ describe("agentapi", async () => {
285345
content: coderMock,
286346
});
287347

348+
const pidFileEnv = pidFilePath ? `AGENTAPI_PID_FILE=${pidFilePath}` : "";
288349
await execContainer(containerId, [
289350
"bash",
290351
"-c",
291-
`PRESET=${agentapiPreset} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`,
352+
`PRESET=${agentapiPreset} ${pidFileEnv} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`,
292353
]);
293354

294355
await execContainer(containerId, [
@@ -303,12 +364,25 @@ describe("agentapi", async () => {
303364
const runShutdownScript = async (
304365
containerId: string,
305366
taskId: string = "test-task",
367+
pidFilePath: string = "",
368+
enableStatePersistence: string = "true",
306369
) => {
307370
const shutdownScript = await loadTestFile(
308371
import.meta.dir,
309372
"../scripts/agentapi-shutdown.sh",
310373
);
311374

375+
const libScript = await loadTestFile(
376+
import.meta.dir,
377+
"../scripts/lib.sh",
378+
);
379+
380+
await writeExecutable({
381+
containerId,
382+
filePath: "/tmp/agentapi-lib.sh",
383+
content: libScript,
384+
});
385+
312386
await writeExecutable({
313387
containerId,
314388
filePath: "/tmp/shutdown.sh",
@@ -318,7 +392,7 @@ describe("agentapi", async () => {
318392
return await execContainer(containerId, [
319393
"bash",
320394
"-c",
321-
`ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
395+
`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`,
322396
]);
323397
};
324398

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

0 commit comments

Comments
 (0)