Skip to content

Commit 7e26655

Browse files
authored
feat: add mailman stop command for graceful daemon shutdown (#34)
Implement file-based signaling mechanism to stop the mailman daemon: - Add `agentmail mailman stop` subcommand that creates .agentmail/.stop file - Daemon's file watcher detects .stop file and initiates graceful shutdown - Daemon removes both .stop and .pid files during shutdown - Uses atomic file creation (O_CREATE|O_EXCL) to detect "stop already pending" Exit codes: 0 (success), 1 (error/already pending)
1 parent 6c1eb33 commit 7e26655

15 files changed

Lines changed: 1422 additions & 11 deletions

File tree

‎CLAUDE.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ Templates are stored in `.specify/templates/` and project constitution in `.spec
102102
- JSONL files in `.agentmail/` directory (existing infrastructure), MCP server via STDIO transport (010-mcp-server)
103103
- Go 1.21+ (per constitution IC-001, project uses Go 1.25.3) + Standard library only (os/exec, encoding/json, syscall, time, os) (011-cleanup)
104104
- JSONL files in `.agentmail/` directory (recipients.jsonl, mailboxes/*.jsonl) (011-cleanup)
105+
- Go 1.25.5 (minimum 1.21+ per IC-001) + Standard library only (os, os/exec, syscall, strconv, strings) (012-mailman-stop)
106+
- `.agentmail/mailman.pid` (existing PID file from 006-mailman-daemon) (012-mailman-stop)
105107

106108
## Recent Changes
107109
- 001-agent-mail-structure: Added Go 1.21+ (per IC-001) + Standard library only (os/exec for tmux, encoding/json for JSONL)

‎cmd/agentmail/main.go‎

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -178,26 +178,59 @@ Examples:
178178
var daemonMode bool
179179
mailmanFlagSet.BoolVar(&daemonMode, "daemon", false, "run in background (daemonize)")
180180

181+
// Mailman stop subcommand
182+
stopFlagSet := flag.NewFlagSet("agentmail mailman stop", flag.ContinueOnError)
183+
stopCmd := &ffcli.Command{
184+
Name: "stop",
185+
ShortUsage: "agentmail mailman stop",
186+
ShortHelp: "Stop the mailman daemon",
187+
LongHelp: `Stop the running mailman daemon using file-based signaling.
188+
189+
Creates a .stop file in .agentmail/ that the daemon detects and
190+
initiates graceful shutdown.
191+
192+
Exit codes:
193+
0 Success (stop signal sent)
194+
1 Error (stop already pending or filesystem error)
195+
196+
Examples:
197+
agentmail mailman stop`,
198+
FlagSet: stopFlagSet,
199+
Exec: func(ctx context.Context, args []string) error {
200+
exitCode := cli.MailmanStop(os.Stdout, os.Stderr, cli.MailmanStopOptions{})
201+
if exitCode != 0 {
202+
os.Exit(exitCode)
203+
}
204+
return nil
205+
},
206+
}
207+
181208
mailmanCmd := &ffcli.Command{
182209
Name: "mailman",
183-
ShortUsage: "agentmail mailman [--daemon]",
184-
ShortHelp: "Start the mailman daemon",
185-
LongHelp: `Start the mailman daemon for message delivery notifications.
210+
ShortUsage: "agentmail mailman [--daemon] | agentmail mailman stop",
211+
ShortHelp: "Start or stop the mailman daemon",
212+
LongHelp: `Start or stop the mailman daemon for message delivery notifications.
186213
187214
The mailman daemon monitors mailboxes and can notify agents when new
188215
messages arrive.
189216
217+
Commands:
218+
stop Stop the running mailman daemon
219+
190220
Flags:
191221
--daemon Run in background (daemonize)
192222
193223
Exit codes:
194224
0 Success
195-
2 Daemon already running
225+
1 Error
226+
2 Daemon already running (start only)
196227
197228
Examples:
198229
agentmail mailman # Run in foreground
199-
agentmail mailman --daemon # Run in background`,
200-
FlagSet: mailmanFlagSet,
230+
agentmail mailman --daemon # Run in background
231+
agentmail mailman stop # Stop the daemon`,
232+
FlagSet: mailmanFlagSet,
233+
Subcommands: []*ffcli.Command{stopCmd},
201234
Exec: func(ctx context.Context, args []string) error {
202235
exitCode := cli.Mailman(os.Stdout, os.Stderr, cli.MailmanOptions{
203236
Daemonize: daemonMode,

‎internal/cli/mailman_stop.go‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"os"
7+
8+
"agentmail/internal/daemon"
9+
"agentmail/internal/mail"
10+
)
11+
12+
// MailmanStopOptions configures the MailmanStop command behavior.
13+
type MailmanStopOptions struct {
14+
RepoRoot string // Repository root (defaults to finding git root)
15+
}
16+
17+
// MailmanStop implements the agentmail mailman stop command.
18+
// Creates a .stop file to signal the daemon to shut down.
19+
//
20+
// Exit codes:
21+
// - 0: Success (stop signal sent)
22+
// - 1: Error (file exists or filesystem error)
23+
func MailmanStop(stdout, stderr io.Writer, opts MailmanStopOptions) int {
24+
// Find repository root
25+
repoRoot := opts.RepoRoot
26+
if repoRoot == "" {
27+
var err error
28+
repoRoot, err = mail.FindGitRoot()
29+
if err != nil {
30+
repoRoot, _ = os.Getwd()
31+
}
32+
}
33+
34+
stopPath := daemon.StopFilePath(repoRoot)
35+
36+
// Atomic create - fails if file exists (O_CREATE|O_EXCL)
37+
f, err := os.OpenFile(stopPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) // #nosec G304 - stopPath is constructed from constants
38+
if err != nil {
39+
if os.IsExist(err) {
40+
fmt.Fprintln(stderr, "Stop already pending")
41+
return 1
42+
}
43+
fmt.Fprintf(stderr, "Failed to send stop signal: %v\n", err)
44+
return 1
45+
}
46+
_ = f.Close() // G104: file was just created successfully, close error is non-critical
47+
48+
fmt.Fprintln(stdout, "Stop signal sent")
49+
return 0
50+
}

‎internal/cli/mailman_stop_test.go‎

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"agentmail/internal/daemon"
10+
)
11+
12+
// =============================================================================
13+
// T003-T004: Tests for MailmanStop - successful stop file creation
14+
// =============================================================================
15+
16+
func TestMailmanStop_CreatesStopFile(t *testing.T) {
17+
// Create temp directory for test
18+
tmpDir, err := os.MkdirTemp("", "agentmail-stop-test-*")
19+
if err != nil {
20+
t.Fatalf("Failed to create temp dir: %v", err)
21+
}
22+
defer os.RemoveAll(tmpDir)
23+
24+
// Create .agentmail directory (simulating existing daemon setup)
25+
agentmailDir := filepath.Join(tmpDir, ".agentmail")
26+
if err := os.MkdirAll(agentmailDir, 0755); err != nil {
27+
t.Fatalf("Failed to create .agentmail dir: %v", err)
28+
}
29+
30+
var stdout, stderr bytes.Buffer
31+
32+
// Run stop command
33+
exitCode := MailmanStop(&stdout, &stderr, MailmanStopOptions{
34+
RepoRoot: tmpDir,
35+
})
36+
37+
// Verify exit code 0
38+
if exitCode != 0 {
39+
t.Errorf("Expected exit code 0, got %d. stderr: %s", exitCode, stderr.String())
40+
}
41+
42+
// Verify stop file was created
43+
stopPath := daemon.StopFilePath(tmpDir)
44+
if _, err := os.Stat(stopPath); os.IsNotExist(err) {
45+
t.Error("Stop file was not created")
46+
}
47+
}
48+
49+
func TestMailmanStop_OutputsSuccessMessage(t *testing.T) {
50+
// Create temp directory for test
51+
tmpDir, err := os.MkdirTemp("", "agentmail-stop-test-*")
52+
if err != nil {
53+
t.Fatalf("Failed to create temp dir: %v", err)
54+
}
55+
defer os.RemoveAll(tmpDir)
56+
57+
// Create .agentmail directory
58+
agentmailDir := filepath.Join(tmpDir, ".agentmail")
59+
if err := os.MkdirAll(agentmailDir, 0755); err != nil {
60+
t.Fatalf("Failed to create .agentmail dir: %v", err)
61+
}
62+
63+
var stdout, stderr bytes.Buffer
64+
65+
// Run stop command
66+
exitCode := MailmanStop(&stdout, &stderr, MailmanStopOptions{
67+
RepoRoot: tmpDir,
68+
})
69+
70+
// Verify exit code 0
71+
if exitCode != 0 {
72+
t.Errorf("Expected exit code 0, got %d", exitCode)
73+
}
74+
75+
// Verify success message
76+
expectedMsg := "Stop signal sent\n"
77+
if stdout.String() != expectedMsg {
78+
t.Errorf("Expected stdout %q, got %q", expectedMsg, stdout.String())
79+
}
80+
81+
// Verify no stderr output
82+
if stderr.String() != "" {
83+
t.Errorf("Expected empty stderr, got %q", stderr.String())
84+
}
85+
}
86+
87+
// =============================================================================
88+
// T015-T016: Tests for MailmanStop - stop already pending
89+
// =============================================================================
90+
91+
func TestMailmanStop_StopAlreadyPending_ReturnsError(t *testing.T) {
92+
// Create temp directory for test
93+
tmpDir, err := os.MkdirTemp("", "agentmail-stop-test-*")
94+
if err != nil {
95+
t.Fatalf("Failed to create temp dir: %v", err)
96+
}
97+
defer os.RemoveAll(tmpDir)
98+
99+
// Create .agentmail directory
100+
agentmailDir := filepath.Join(tmpDir, ".agentmail")
101+
if err := os.MkdirAll(agentmailDir, 0755); err != nil {
102+
t.Fatalf("Failed to create .agentmail dir: %v", err)
103+
}
104+
105+
// Pre-create the stop file to simulate pending stop
106+
stopPath := daemon.StopFilePath(tmpDir)
107+
if err := os.WriteFile(stopPath, []byte{}, 0600); err != nil {
108+
t.Fatalf("Failed to create stop file: %v", err)
109+
}
110+
111+
var stdout, stderr bytes.Buffer
112+
113+
// Run stop command
114+
exitCode := MailmanStop(&stdout, &stderr, MailmanStopOptions{
115+
RepoRoot: tmpDir,
116+
})
117+
118+
// Verify exit code 1
119+
if exitCode != 1 {
120+
t.Errorf("Expected exit code 1, got %d", exitCode)
121+
}
122+
123+
// Verify error message
124+
expectedMsg := "Stop already pending\n"
125+
if stderr.String() != expectedMsg {
126+
t.Errorf("Expected stderr %q, got %q", expectedMsg, stderr.String())
127+
}
128+
129+
// Verify no stdout output
130+
if stdout.String() != "" {
131+
t.Errorf("Expected empty stdout, got %q", stdout.String())
132+
}
133+
}
134+
135+
func TestMailmanStop_FilesystemError_ReturnsError(t *testing.T) {
136+
// Create temp directory for test
137+
tmpDir, err := os.MkdirTemp("", "agentmail-stop-test-*")
138+
if err != nil {
139+
t.Fatalf("Failed to create temp dir: %v", err)
140+
}
141+
defer os.RemoveAll(tmpDir)
142+
143+
// Do NOT create .agentmail directory - this should cause a filesystem error
144+
// when trying to create the stop file
145+
146+
var stdout, stderr bytes.Buffer
147+
148+
// Run stop command
149+
exitCode := MailmanStop(&stdout, &stderr, MailmanStopOptions{
150+
RepoRoot: tmpDir,
151+
})
152+
153+
// Verify exit code 1
154+
if exitCode != 1 {
155+
t.Errorf("Expected exit code 1, got %d", exitCode)
156+
}
157+
158+
// Verify error message contains expected prefix
159+
expectedPrefix := "Failed to send stop signal:"
160+
if len(stderr.String()) < len(expectedPrefix) || stderr.String()[:len(expectedPrefix)] != expectedPrefix {
161+
t.Errorf("Expected stderr to start with %q, got %q", expectedPrefix, stderr.String())
162+
}
163+
164+
// Verify no stdout output
165+
if stdout.String() != "" {
166+
t.Errorf("Expected empty stdout, got %q", stdout.String())
167+
}
168+
}

‎internal/daemon/daemon.go‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ import (
1818
// PIDFile is the filename for the mailman daemon PID file within .agentmail/
1919
const PIDFile = "mailman.pid"
2020

21+
// StopFile is the filename for the mailman daemon stop signal within .agentmail/
22+
const StopFile = ".stop"
23+
2124
// DaemonStatus represents the status of an existing daemon process.
2225
type DaemonStatus int
2326

@@ -45,6 +48,11 @@ func PIDFilePath(repoRoot string) string {
4548
return filepath.Join(repoRoot, mail.RootDir, PIDFile)
4649
}
4750

51+
// StopFilePath returns the full path to the stop signal file for a given repository root.
52+
func StopFilePath(repoRoot string) string {
53+
return filepath.Join(repoRoot, mail.RootDir, StopFile)
54+
}
55+
4856
// ReadPID reads the PID from the mailman.pid file.
4957
// Returns 0 if the file doesn't exist (not an error).
5058
// Returns an error if the file exists but contains invalid content.
@@ -246,26 +254,33 @@ func runForeground(repoRoot string, stdout, stderr io.Writer) int {
246254
close(loopDone)
247255
}()
248256

249-
// Wait for shutdown signal or test stop
257+
// Wait for shutdown signal, test stop, or file-based stop
250258
if stopChan != nil {
251-
// Test mode: wait on either signal or stop channel
259+
// Test mode: wait on signal, stop channel, or file-based stop
252260
select {
253261
case <-sigChan:
254262
case <-stopChan:
263+
case <-fileWatcher.StopChan():
255264
}
256265
} else {
257-
// Production mode: wait only on signals
258-
<-sigChan
266+
// Production mode: wait on signals or file-based stop
267+
select {
268+
case <-sigChan:
269+
case <-fileWatcher.StopChan():
270+
}
259271
}
260272

273+
// Remove stop file if it exists (FR-007 from 012-mailman-stop)
274+
_ = os.Remove(StopFilePath(repoRoot)) // G104: best-effort cleanup
275+
261276
// Close file watcher to stop the notification loop
262277
if fileWatcher != nil {
263278
_ = fileWatcher.Close() // G104: best-effort cleanup
264279
}
265280

266281
<-loopDone // Wait for loop to finish
267282

268-
// Clean up PID file on shutdown
283+
// Clean up PID file on shutdown (FR-008 from 012-mailman-stop)
269284
_ = DeletePID(repoRoot) // G104: best-effort cleanup, errors don't affect exit status
270285

271286
return 0

0 commit comments

Comments
 (0)