diff --git a/internal/acp/supervisor_test.go b/internal/acp/supervisor_test.go new file mode 100644 index 0000000..06d6244 --- /dev/null +++ b/internal/acp/supervisor_test.go @@ -0,0 +1,245 @@ +//go:build integration + +// supervisor_test.go 集成测试 supervisor 的 spawn / handshake / kill / monitor / +// onExit 全链路。复用 cmd/fake-acp-agent 子进程: +// - HappyPath:正常 spawn → handshake → 正常 Kill → 写 SessionExited +// - HangAgent:FAKE_ACP_HANG=true 让 agent 永不响应 → handshake 超时 → Spawn 返错 +// - AgentCrashes:FAKE_ACP_CRASH_AFTER_PROMPTS=1 让 agent 处理一次 prompt 后 exit(1) +// +// 全部走 testcontainers PG,每测试一个独立容器(setupRepo 内 t.Cleanup)。 +package acp_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/acp" + "github.com/yan1h/agent-coding-workflow/internal/audit" + "github.com/yan1h/agent-coding-workflow/internal/infra/notify" +) + +// newSupervisorForTest 构造一个绑定到 PG 真实 audit Recorder 的 Supervisor, +// 使用静音 logger + 默认 SupervisorConfig(5s SpawnTimeout 给 fake agent 留 buffer)。 +// withAudit=false 时 Recorder 为 nil,对应 onExit 跳过 audit 路径。 +func newSupervisorForTest(t *testing.T, pool *pgxpool.Pool, repo acp.Repository, withAudit bool) *acp.Supervisor { + t.Helper() + var rec audit.Recorder + if withAudit { + rec = audit.NewPostgresRecorder(pool) + } + disp := notify.NewDispatcher(notifyRepoStub{}, slogDiscard()) + return acp.NewSupervisor( + repo, rec, disp, + nil, nil, // wtSvc / wsRepo: tests 用 IsMain=true,走 repo.ReleaseMainWorktree + testEncryptor(t), + acp.SupervisorConfig{ + SpawnTimeout: 5 * time.Second, + KillGrace: 1 * time.Second, + StderrBufferLines: 50, + StderrTailBytes: 1000, + EventMaxPayload: 65536, + EventTruncateField: 4096, + ShutdownGrace: 5 * time.Second, + }, + slogDiscard(), + ) +} + +// TestSupervisor_HappyPath_Spawn_Handshake 验证: +// 1. Spawn 启动 fake-acp-agent 并完成 initialize + session/new +// 2. UpdateSessionRunning 把 agent_session_id="fake-session-id" + pid 写入 DB +// 3. Kill 触发 monitorProcess + onExit,session 落到 SessionExited +func TestSupervisor_HappyPath_Spawn_Handshake(t *testing.T) { + t.Parallel() + ctx := context.Background() + repo, pool := setupRepo(t) + + uid := mustInsertUser(t, ctx, pool, "sup1@local", false) + pid, wsid := mustInsertProjectWorkspace(t, ctx, pool, uid) + + bin := fakeAgentBinary(t) + k, err := repo.CreateAgentKind(ctx, &acp.AgentKind{ + ID: uuid.New(), Name: "fake1", DisplayName: "Fake", + BinaryPath: bin, Args: []string{}, + Enabled: true, CreatedBy: uid, + }) + require.NoError(t, err) + + cwd := t.TempDir() + sess, err := repo.InsertSession(ctx, &acp.Session{ + ID: uuid.New(), WorkspaceID: wsid, ProjectID: pid, + AgentKindID: k.ID, UserID: uid, + Branch: "main", CwdPath: cwd, IsMainWorktree: true, Status: acp.SessionStarting, + }) + require.NoError(t, err) + + // onExit 会调 ReleaseMainWorktree → 必须先 Acquire 才能 Release(CAS 语义)。 + ok, err := repo.AcquireMainWorktree(ctx, sess.ID, wsid) + require.NoError(t, err) + require.True(t, ok) + + sup := newSupervisorForTest(t, pool, repo, true) + proc, err := sup.Spawn(ctx, sess, k) + require.NoError(t, err) + require.NotNil(t, proc) + + got, err := repo.GetSessionByID(ctx, sess.ID) + require.NoError(t, err) + assert.Equal(t, acp.SessionRunning, got.Status) + require.NotNil(t, got.AgentSessionID) + assert.Equal(t, "fake-session-id", *got.AgentSessionID) + require.NotNil(t, got.PID) + assert.NotZero(t, *got.PID) + + sup.Kill(ctx, sess.ID, 1*time.Second) + + // monitorProcess + onExit 在 goroutine 里跑;轮询直到 DB 反映终态。 + deadline := time.After(5 * time.Second) + for { + got, err = repo.GetSessionByID(ctx, sess.ID) + require.NoError(t, err) + if got.Status == acp.SessionExited || got.Status == acp.SessionCrashed { + break + } + select { + case <-deadline: + t.Fatalf("session never reached terminal state, last status=%s", got.Status) + case <-time.After(50 * time.Millisecond): + } + } + assert.Equal(t, acp.SessionExited, got.Status) +} + +// TestSupervisor_HangAgent_HandshakeTimeout 验证:FAKE_ACP_HANG agent 永远不响应; +// Spawn 在 SpawnTimeout 内返回 error,Kill 在内部触发 monitor → onExit → DB 终态。 +func TestSupervisor_HangAgent_HandshakeTimeout(t *testing.T) { + t.Parallel() + ctx := context.Background() + repo, pool := setupRepo(t) + + uid := mustInsertUser(t, ctx, pool, "sup2@local", false) + pid, wsid := mustInsertProjectWorkspace(t, ctx, pool, uid) + + bin := fakeAgentBinary(t) + k, err := repo.CreateAgentKind(ctx, &acp.AgentKind{ + ID: uuid.New(), Name: "fake-hang", DisplayName: "Fake", + BinaryPath: bin, Args: []string{}, + EncryptedEnv: encryptForTest(t, map[string]string{"FAKE_ACP_HANG": "true"}), + Enabled: true, CreatedBy: uid, + }) + require.NoError(t, err) + + sess, err := repo.InsertSession(ctx, &acp.Session{ + ID: uuid.New(), WorkspaceID: wsid, ProjectID: pid, + AgentKindID: k.ID, UserID: uid, + Branch: "main", CwdPath: t.TempDir(), IsMainWorktree: true, Status: acp.SessionStarting, + }) + require.NoError(t, err) + ok, err := repo.AcquireMainWorktree(ctx, sess.ID, wsid) + require.NoError(t, err) + require.True(t, ok) + + // 用更短的 SpawnTimeout 加速测试(默认 5s 够,这里 1s 避免 CI 等太久)。 + disp := notify.NewDispatcher(notifyRepoStub{}, slogDiscard()) + sup := acp.NewSupervisor( + repo, audit.NewPostgresRecorder(pool), disp, + nil, nil, testEncryptor(t), + acp.SupervisorConfig{ + SpawnTimeout: 1 * time.Second, + KillGrace: 500 * time.Millisecond, + StderrBufferLines: 10, + StderrTailBytes: 500, + EventMaxPayload: 65536, + EventTruncateField: 4096, + ShutdownGrace: 2 * time.Second, + }, + slogDiscard(), + ) + + _, err = sup.Spawn(ctx, sess, k) + require.Error(t, err, "hang agent must trigger handshake timeout") + + deadline := time.After(5 * time.Second) + for { + got, gerr := repo.GetSessionByID(ctx, sess.ID) + require.NoError(t, gerr) + if got.Status == acp.SessionCrashed || got.Status == acp.SessionExited { + // SIGKILL on hang agent 不属于 \"agent crashed by itself\", + // supervisor 会标 KilledByUs → exited。两者都接受。 + assert.Contains(t, []acp.SessionStatus{acp.SessionCrashed, acp.SessionExited}, got.Status) + return + } + select { + case <-deadline: + t.Fatalf("session never reached terminal state, last status=%s", got.Status) + case <-time.After(50 * time.Millisecond): + } + } +} + +// TestSupervisor_AgentCrashes 验证:fake agent 在处理 1 次 prompt 后 os.Exit(1); +// monitorProcess 检测到非主动 Kill → status=SessionCrashed + exit_code=1, +// onExit 写 DB + 调 Dispatcher.Dispatch + audit.Record。 +func TestSupervisor_AgentCrashes(t *testing.T) { + t.Parallel() + ctx := context.Background() + repo, pool := setupRepo(t) + + uid := mustInsertUser(t, ctx, pool, "sup3@local", false) + pid, wsid := mustInsertProjectWorkspace(t, ctx, pool, uid) + + bin := fakeAgentBinary(t) + k, err := repo.CreateAgentKind(ctx, &acp.AgentKind{ + ID: uuid.New(), Name: "fake-crash", DisplayName: "Fake", + BinaryPath: bin, Args: []string{}, + EncryptedEnv: encryptForTest(t, map[string]string{"FAKE_ACP_CRASH_AFTER_PROMPTS": "1"}), + Enabled: true, CreatedBy: uid, + }) + require.NoError(t, err) + + sess, err := repo.InsertSession(ctx, &acp.Session{ + ID: uuid.New(), WorkspaceID: wsid, ProjectID: pid, + AgentKindID: k.ID, UserID: uid, + Branch: "main", CwdPath: t.TempDir(), IsMainWorktree: true, Status: acp.SessionStarting, + }) + require.NoError(t, err) + ok, err := repo.AcquireMainWorktree(ctx, sess.ID, wsid) + require.NoError(t, err) + require.True(t, ok) + + sup := newSupervisorForTest(t, pool, repo, true) + proc, err := sup.Spawn(ctx, sess, k) + require.NoError(t, err) + require.NotNil(t, proc) + + // 通过 relay.SendClient 发一条 prompt → fake agent 处理后 exit(1)。 + idJSON, _ := json.Marshal("client-1") + err = proc.Relay.SendClient(&acp.Message{ + JSONRPC: "2.0", ID: idJSON, Method: "session/prompt", + Params: json.RawMessage(`{"sessionId":"fake-session-id","prompt":[{"type":"text","text":"x"}]}`), + }) + require.NoError(t, err) + + deadline := time.After(10 * time.Second) + for { + got, gerr := repo.GetSessionByID(ctx, sess.ID) + require.NoError(t, gerr) + if got.Status == acp.SessionCrashed { + require.NotNil(t, got.ExitCode) + assert.Equal(t, int32(1), *got.ExitCode) + return + } + select { + case <-deadline: + t.Fatalf("crash not detected, last status=%s", got.Status) + case <-time.After(100 * time.Millisecond): + } + } +} diff --git a/internal/acp/test_helpers_test.go b/internal/acp/test_helpers_test.go new file mode 100644 index 0000000..7bbb6d9 --- /dev/null +++ b/internal/acp/test_helpers_test.go @@ -0,0 +1,86 @@ +//go:build integration + +// test_helpers_test.go 是 supervisor_test.go 的共享脚手架: +// - notifyRepoStub:满足 notify.Repository(5 方法),让 dispatcher 落库变成空操作 +// - slogDiscard:丢弃日志的 logger +// - encryptForTest:用与 AgentKindService 相同格式产出加密 env(map → JSON → AES-GCM) +// - fakeAgentBinary:构建 cmd/fake-acp-agent 二进制供子进程 spawn +// +// 集成测试专用,不参与 go test 默认 build。 +package acp_test + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/infra/notify" +) + +// notifyRepoStub 实现 notify.Repository 全部 5 方法。supervisor.onExit +// 会通过 dispatcher 落库一条 crashed 通知,落库走 stub → 永远成功 + 不存。 +type notifyRepoStub struct{} + +func (notifyRepoStub) Insert(context.Context, notify.Message) error { return nil } +func (notifyRepoStub) List(context.Context, uuid.UUID, bool, int) ([]notify.Message, error) { + return nil, nil +} +func (notifyRepoStub) CountUnread(context.Context, uuid.UUID) (int, error) { return 0, nil } +func (notifyRepoStub) MarkRead(context.Context, uuid.UUID, uuid.UUID) error { return nil } +func (notifyRepoStub) MarkAllRead(context.Context, uuid.UUID) error { return nil } + +// slogDiscard 返回静音 logger,避免测试输出污染。 +func slogDiscard() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } + +// encryptForTest 以 AgentKindService 同格式(JSON marshal → AES-GCM)产出 +// EncryptedEnv 字节,让 supervisor.decryptEnv 还原为同一 map。 +func encryptForTest(t *testing.T, m map[string]string) []byte { + t.Helper() + enc := testEncryptor(t) + plain, err := json.Marshal(m) + require.NoError(t, err) + out, err := enc.Encrypt(plain) + require.NoError(t, err) + return out +} + +// repoRoot 从测试 cwd 向上找 go.mod,返回仓库根目录。t.TempDir + go build 用。 +func repoRoot(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + require.NoError(t, err) + for { + if _, err := os.Stat(filepath.Join(wd, "go.mod")); err == nil { + return wd + } + parent := filepath.Dir(wd) + if parent == wd { + t.Fatal("go.mod not found upward") + } + wd = parent + } +} + +// fakeAgentBinary 编译 cmd/fake-acp-agent 到 t.TempDir() 并返回路径。 +// 测试结束后 t.TempDir 自动清理。 +func fakeAgentBinary(t *testing.T) string { + t.Helper() + bin := filepath.Join(t.TempDir(), "fake-acp-agent") + if runtime.GOOS == "windows" { + bin += ".exe" + } + cmd := exec.Command("go", "build", "-o", bin, "./cmd/fake-acp-agent") + cmd.Dir = repoRoot(t) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "build fake-acp-agent: %s", out) + return bin +}