ACP / MCP 完善

This commit is contained in:
2026-06-11 12:43:18 +08:00
parent 1415d3b43b
commit 0561e126cd
22 changed files with 786 additions and 219 deletions
+90 -5
View File
@@ -12,6 +12,7 @@ package acp_test
import (
"context"
"encoding/json"
"sync"
"testing"
"time"
@@ -25,12 +26,13 @@ import (
"github.com/yan1h/agent-coding-workflow/internal/audit"
"github.com/yan1h/agent-coding-workflow/internal/infra/notify"
"github.com/yan1h/agent-coding-workflow/internal/mcp"
"github.com/yan1h/agent-coding-workflow/internal/workspace"
)
// 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, mcpTokens mcp.TokenService) *acp.Supervisor {
func newSupervisorForTest(t *testing.T, pool *pgxpool.Pool, repo acp.Repository, withAudit bool, mcpTokens mcp.TokenService, wtSvc workspace.WorktreeService) *acp.Supervisor {
t.Helper()
var rec audit.Recorder
if withAudit {
@@ -39,7 +41,7 @@ func newSupervisorForTest(t *testing.T, pool *pgxpool.Pool, repo acp.Repository,
disp := notify.NewDispatcher(notifyRepoStub{}, slogDiscard())
return acp.NewSupervisor(
repo, rec, disp,
nil, nil, // wtSvc / wsRepo: tests 用 IsMain=true,走 repo.ReleaseMainWorktree
wtSvc, nil, // wtSvc: IsMain=false 测试注入 fake;wsRepo: 测试不走该路径
mcpTokens,
testEncryptor(t),
acp.SupervisorConfig{
@@ -88,7 +90,7 @@ func TestSupervisor_HappyPath_Spawn_Handshake(t *testing.T) {
require.NoError(t, err)
require.True(t, ok)
sup := newSupervisorForTest(t, pool, repo, true, nil)
sup := newSupervisorForTest(t, pool, repo, true, nil, nil)
proc, err := sup.Spawn(ctx, sess, k, nil)
require.NoError(t, err)
require.NotNil(t, proc)
@@ -217,7 +219,7 @@ func TestSupervisor_AgentCrashes(t *testing.T) {
require.NoError(t, err)
require.True(t, ok)
sup := newSupervisorForTest(t, pool, repo, true, nil)
sup := newSupervisorForTest(t, pool, repo, true, nil, nil)
proc, err := sup.Spawn(ctx, sess, k, nil)
require.NoError(t, err)
require.NotNil(t, proc)
@@ -306,7 +308,7 @@ func TestSupervisor_OnExit_RevokesMCPToken(t *testing.T) {
require.True(t, ok)
fakeTokens := &fakeSupMCPTokens{}
sup := newSupervisorForTest(t, pool, repo, true, fakeTokens)
sup := newSupervisorForTest(t, pool, repo, true, fakeTokens, nil)
proc, err := sup.Spawn(ctx, sess, k, nil)
require.NoError(t, err)
require.NotNil(t, proc)
@@ -332,3 +334,86 @@ func TestSupervisor_OnExit_RevokesMCPToken(t *testing.T) {
require.Len(t, fakeTokens.revoked, 1, "expected exactly one RevokeBySession call")
assert.Equal(t, sess.ID, fakeTokens.revoked[0], "RevokeBySession should receive the session ID")
}
// fakeWorktreeSvc 实现 workspace.WorktreeService,仅记录 ReleaseByHolder 收到的
// holder 字符串,用于验证 onExit 的子 worktree 释放路径。
type fakeWorktreeSvc struct {
mu sync.Mutex
holders []string
}
func (f *fakeWorktreeSvc) Create(_ context.Context, _ workspace.Caller, _ uuid.UUID, _ workspace.CreateWorktreeInput) (*workspace.Worktree, error) {
return nil, nil
}
func (f *fakeWorktreeSvc) List(_ context.Context, _ workspace.Caller, _ uuid.UUID) ([]*workspace.Worktree, error) {
return nil, nil
}
func (f *fakeWorktreeSvc) Delete(_ context.Context, _ workspace.Caller, _ uuid.UUID) error {
return nil
}
func (f *fakeWorktreeSvc) Acquire(_ context.Context, _ workspace.Caller, _ uuid.UUID) (*workspace.Worktree, error) {
return nil, nil
}
func (f *fakeWorktreeSvc) Release(_ context.Context, _ workspace.Caller, _ uuid.UUID) (*workspace.Worktree, error) {
return nil, nil
}
func (f *fakeWorktreeSvc) ReleaseByHolder(_ context.Context, holder string) ([]*workspace.Worktree, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.holders = append(f.holders, holder)
return nil, nil
}
// holdersSnapshot 拷贝当前已记录的 holder 列表(onExit 在 monitor goroutine 中调用)。
func (f *fakeWorktreeSvc) holdersSnapshot() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.holders...)
}
// TestSupervisor_OnExit_ReleasesSubWorktree 验证:IsMainWorktree=false 的 session
// 退出后,onExit 通过 wtSvc.ReleaseByHolder("session:<sid>") 释放子 worktree。
func TestSupervisor_OnExit_ReleasesSubWorktree(t *testing.T) {
t.Parallel()
ctx := context.Background()
repo, pool := setupRepo(t)
uid := mustInsertUser(t, ctx, pool, "sup-wt@local", false)
pid, wsid := mustInsertProjectWorkspace(t, ctx, pool, uid)
bin := fakeAgentBinary(t)
k, err := repo.CreateAgentKind(ctx, &acp.AgentKind{
ID: uuid.New(), Name: "fake-wt", DisplayName: "Fake",
BinaryPath: bin, Args: []string{},
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: "feat-x", CwdPath: t.TempDir(), IsMainWorktree: false, Status: acp.SessionStarting,
})
require.NoError(t, err)
fakeWt := &fakeWorktreeSvc{}
sup := newSupervisorForTest(t, pool, repo, true, nil, fakeWt)
proc, err := sup.Spawn(ctx, sess, k, nil)
require.NoError(t, err)
require.NotNil(t, proc)
sup.Kill(ctx, sess.ID, 1*time.Second)
// onExit 在 monitor goroutine 中异步执行;轮询 fake 直到记录到 release 调用。
deadline := time.After(5 * time.Second)
for len(fakeWt.holdersSnapshot()) == 0 {
select {
case <-deadline:
t.Fatal("ReleaseByHolder never called after process exit")
case <-time.After(50 * time.Millisecond):
}
}
holders := fakeWt.holdersSnapshot()
require.Len(t, holders, 1, "expected exactly one ReleaseByHolder call")
assert.Equal(t, "session:"+sess.ID.String(), holders[0])
}