mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 14:09:22 +02:00
* fix(daemon): isolate Linux Codex git metadata (MUL-4925) Co-authored-by: multica-agent <github@multica.ai> * refactor(daemon): address isolated-checkout review nits (MUL-4925) - rename sameFilesystemPath -> sameResolvedPath (it compares resolved paths for equality, not same-device), with a clarifying doc comment - prune earlier tasks' agent/* branches when reusing an isolated checkout so a long-lived reused workdir stops accumulating one local branch per checkout; deleteLocalBranches now takes a keepBranch arg and the prune is non-fatal - cover the prune in TestCreateWorktreeReusesIsolatedGitMetadata * fix(repocache): preserve user branches on reuse (MUL-4925) Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
436 lines
13 KiB
Go
436 lines
13 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/multica-ai/multica/server/internal/daemon/repocache"
|
|
)
|
|
|
|
func TestHealthHandlerReportsCLIVersionAndActiveTaskCount(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := &Daemon{
|
|
cfg: Config{
|
|
CLIVersion: "v9.9.9",
|
|
DaemonID: "daemon-test",
|
|
DeviceName: "dev",
|
|
ServerBaseURL: "http://localhost:8080",
|
|
},
|
|
workspaces: map[string]*workspaceState{},
|
|
logger: slog.Default(),
|
|
}
|
|
d.activeTasks.Store(3)
|
|
d.ready.Store(true) // preflight done -> status should be "running"
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
|
rec := httptest.NewRecorder()
|
|
d.healthHandler(time.Now()).ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
|
|
// Decode into a raw map so the test locks in the exact wire-level JSON
|
|
// keys — the desktop TS client depends on snake_case (cli_version,
|
|
// active_task_count), so a silent struct-tag rename must fail here.
|
|
var raw map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil {
|
|
t.Fatalf("decode raw response: %v", err)
|
|
}
|
|
if got, want := raw["cli_version"], "v9.9.9"; got != want {
|
|
t.Errorf("cli_version key: got %v, want %q", got, want)
|
|
}
|
|
// JSON numbers decode to float64 through map[string]any.
|
|
if got, want := raw["active_task_count"], float64(3); got != want {
|
|
t.Errorf("active_task_count key: got %v, want %v", got, want)
|
|
}
|
|
if got, want := raw["status"], "running"; got != want {
|
|
t.Errorf("status key: got %v, want %q", got, want)
|
|
}
|
|
// The desktop relies on the `os` key (runtime.GOOS) to detect a daemon it
|
|
// can't manage (e.g. Linux-in-WSL behind a Windows desktop). A rename or
|
|
// drop would silently re-break #3916, so lock both the key and its value.
|
|
if got, want := raw["os"], runtime.GOOS; got != want {
|
|
t.Errorf("os key: got %v, want %q", got, want)
|
|
}
|
|
|
|
// Also round-trip into the typed struct as a separate check that the
|
|
// field values match, independent of key naming.
|
|
var resp HealthResponse
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode typed response: %v", err)
|
|
}
|
|
if resp.CLIVersion != "v9.9.9" {
|
|
t.Errorf("CLIVersion: got %q, want %q", resp.CLIVersion, "v9.9.9")
|
|
}
|
|
if resp.ActiveTaskCount != 3 {
|
|
t.Errorf("ActiveTaskCount: got %d, want 3", resp.ActiveTaskCount)
|
|
}
|
|
}
|
|
|
|
// TestHealthHandlerReportsStartingUntilReady pins the liveness/readiness split:
|
|
// the health server binds and answers before preflight finishes, but it must
|
|
// report "starting" until d.ready is set, and only then "running". Otherwise a
|
|
// slow or failing preflight would be misreported to `daemon start` (and the
|
|
// desktop) as a fully started daemon.
|
|
func TestHealthHandlerReportsStartingUntilReady(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := &Daemon{
|
|
cfg: Config{CLIVersion: "v1.0.0"},
|
|
workspaces: map[string]*workspaceState{},
|
|
logger: slog.Default(),
|
|
}
|
|
handler := d.healthHandler(time.Now())
|
|
|
|
readStatus := func() string {
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil))
|
|
var resp HealthResponse
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
return resp.Status
|
|
}
|
|
|
|
if got := readStatus(); got != "starting" {
|
|
t.Fatalf("status before ready: got %q, want \"starting\"", got)
|
|
}
|
|
|
|
d.ready.Store(true)
|
|
|
|
if got := readStatus(); got != "running" {
|
|
t.Fatalf("status after ready: got %q, want \"running\"", got)
|
|
}
|
|
}
|
|
|
|
func TestHealthHandlerActiveTaskCountTracksCounter(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := &Daemon{
|
|
cfg: Config{CLIVersion: "v1.0.0"},
|
|
workspaces: map[string]*workspaceState{},
|
|
logger: slog.Default(),
|
|
}
|
|
handler := d.healthHandler(time.Now())
|
|
|
|
// Simulate the pollLoop increment/decrement protocol.
|
|
d.activeTasks.Add(1)
|
|
d.activeTasks.Add(1)
|
|
assertActiveTaskCount(t, handler, 2)
|
|
|
|
d.activeTasks.Add(-1)
|
|
assertActiveTaskCount(t, handler, 1)
|
|
|
|
d.activeTasks.Add(-1)
|
|
assertActiveTaskCount(t, handler, 0)
|
|
}
|
|
|
|
func TestShutdownHandlerPostCancelsDaemonContext(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
d := &Daemon{cancelFunc: cancel}
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/shutdown", nil)
|
|
d.shutdownHandler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
case <-time.After(time.Second):
|
|
t.Fatal("daemon context was not cancelled after POST /shutdown")
|
|
}
|
|
}
|
|
|
|
func TestShutdownHandlerRejectsNonPost(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cancelled := false
|
|
d := &Daemon{cancelFunc: func() { cancelled = true }}
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/shutdown", nil)
|
|
d.shutdownHandler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("expected 405, got %d", rec.Code)
|
|
}
|
|
// Give the handler's deferred cancel goroutine a moment to fire
|
|
// in case a bug causes it to run anyway.
|
|
time.Sleep(10 * time.Millisecond)
|
|
if cancelled {
|
|
t.Fatal("GET request should not trigger cancellation")
|
|
}
|
|
}
|
|
|
|
func TestHealthHandlerRespondsWhileTaskRepoLookupWaits(t *testing.T) {
|
|
const workspaceID = "ws-health"
|
|
const repoURL = "https://github.com/org/repo.git"
|
|
cache := newBlockingLookupRepoCache("/cache/org/repo.git")
|
|
d := &Daemon{
|
|
cfg: Config{CLIVersion: "v1.0.0"},
|
|
workspaces: map[string]*workspaceState{
|
|
workspaceID: {
|
|
workspaceID: workspaceID,
|
|
runtimeIDs: []string{"rt-1"},
|
|
allowedRepoURLs: map[string]struct{}{repoURL: {}},
|
|
taskRepoURLs: map[string]struct{}{},
|
|
},
|
|
},
|
|
repoCache: cache,
|
|
logger: slog.Default(),
|
|
}
|
|
defer cache.release()
|
|
|
|
registerDone := make(chan struct{})
|
|
go func() {
|
|
d.registerTaskRepos(workspaceID, "task-health", []RepoData{{URL: repoURL}})
|
|
close(registerDone)
|
|
}()
|
|
cache.waitForLookup(t)
|
|
|
|
rec := httptest.NewRecorder()
|
|
healthDone := make(chan struct{})
|
|
go func() {
|
|
d.healthHandler(time.Now()).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil))
|
|
close(healthDone)
|
|
}()
|
|
|
|
select {
|
|
case <-healthDone:
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("/health blocked behind task repo cache lookup")
|
|
}
|
|
|
|
cache.release()
|
|
select {
|
|
case <-registerDone:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("registerTaskRepos did not unblock after repo lookup finished")
|
|
}
|
|
}
|
|
|
|
func TestRepoCheckoutUsesTaskScopedProjectRefByDefault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const workspaceID = "ws-checkout"
|
|
const repoURL = "https://github.com/org/repo.git"
|
|
cache := &recordingRepoCache{lookupPath: "/cache/org/repo.git"}
|
|
d := newRepoCheckoutTestDaemon(t, workspaceID, repoURL, cache)
|
|
d.registerTaskRepos(workspaceID, "task-1", []RepoData{{URL: repoURL, Ref: "release/v2"}})
|
|
|
|
rec := httptest.NewRecorder()
|
|
body := strings.NewReader(`{"url":"` + repoURL + `","workspace_id":"` + workspaceID + `","workdir":"/tmp/work","task_id":"task-1"}`)
|
|
d.repoCheckoutHandler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/repo/checkout", body))
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if got := cache.lastCreateParams().Ref; got != "release/v2" {
|
|
t.Fatalf("CreateWorktree Ref = %q, want release/v2", got)
|
|
}
|
|
}
|
|
|
|
func TestRepoCheckoutExplicitRefOverridesProjectDefault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const workspaceID = "ws-checkout"
|
|
const repoURL = "https://github.com/org/repo.git"
|
|
cache := &recordingRepoCache{lookupPath: "/cache/org/repo.git"}
|
|
d := newRepoCheckoutTestDaemon(t, workspaceID, repoURL, cache)
|
|
d.registerTaskRepos(workspaceID, "task-1", []RepoData{{URL: repoURL, Ref: "release/v2"}})
|
|
|
|
rec := httptest.NewRecorder()
|
|
body := strings.NewReader(`{"url":"` + repoURL + `","workspace_id":"` + workspaceID + `","workdir":"/tmp/work","task_id":"task-1","ref":"hotfix"}`)
|
|
d.repoCheckoutHandler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/repo/checkout", body))
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if got := cache.lastCreateParams().Ref; got != "hotfix" {
|
|
t.Fatalf("CreateWorktree Ref = %q, want explicit hotfix", got)
|
|
}
|
|
}
|
|
|
|
func TestRepoCheckoutForwardsIsolatedMode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const workspaceID = "ws-checkout"
|
|
const repoURL = "https://github.com/org/repo.git"
|
|
cache := &recordingRepoCache{lookupPath: "/cache/org/repo.git"}
|
|
d := newRepoCheckoutTestDaemon(t, workspaceID, repoURL, cache)
|
|
|
|
rec := httptest.NewRecorder()
|
|
body := strings.NewReader(`{"url":"` + repoURL + `","workspace_id":"` + workspaceID + `","workdir":"/tmp/work","task_id":"task-1","checkout_mode":"isolated"}`)
|
|
d.repoCheckoutHandler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/repo/checkout", body))
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if !cache.lastCreateParams().IsolatedGitMetadata {
|
|
t.Fatal("isolated checkout_mode was not forwarded to repo cache")
|
|
}
|
|
}
|
|
|
|
func TestRepoCheckoutRejectsUnknownMode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const workspaceID = "ws-checkout"
|
|
const repoURL = "https://github.com/org/repo.git"
|
|
cache := &recordingRepoCache{lookupPath: "/cache/org/repo.git"}
|
|
d := newRepoCheckoutTestDaemon(t, workspaceID, repoURL, cache)
|
|
|
|
rec := httptest.NewRecorder()
|
|
body := strings.NewReader(`{"url":"` + repoURL + `","workspace_id":"` + workspaceID + `","workdir":"/tmp/work","task_id":"task-1","checkout_mode":"unsafe"}`)
|
|
d.repoCheckoutHandler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/repo/checkout", body))
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if got := cache.lastCreateParams(); got != (repocache.WorktreeParams{}) {
|
|
t.Fatalf("invalid checkout mode reached repo cache: %+v", got)
|
|
}
|
|
}
|
|
|
|
func newRepoCheckoutTestDaemon(t *testing.T, workspaceID, repoURL string, cache *recordingRepoCache) *Daemon {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet || r.URL.Path != "/api/daemon/workspaces/"+workspaceID+"/repos" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(WorkspaceReposResponse{
|
|
WorkspaceID: workspaceID,
|
|
Repos: []RepoData{{URL: repoURL}},
|
|
ReposVersion: "v1",
|
|
})
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return &Daemon{
|
|
cfg: Config{CLIVersion: "v1.0.0"},
|
|
client: NewClient(srv.URL),
|
|
repoCache: cache,
|
|
workspaces: map[string]*workspaceState{
|
|
workspaceID: newWorkspaceState(workspaceID, nil, "", []RepoData{{URL: repoURL}}, nil),
|
|
},
|
|
logger: slog.Default(),
|
|
}
|
|
}
|
|
|
|
type blockingLookupRepoCache struct {
|
|
path string
|
|
lookupSeen chan struct{}
|
|
releaseLookup chan struct{}
|
|
releaseOnce sync.Once
|
|
}
|
|
|
|
func newBlockingLookupRepoCache(path string) *blockingLookupRepoCache {
|
|
return &blockingLookupRepoCache{
|
|
path: path,
|
|
lookupSeen: make(chan struct{}),
|
|
releaseLookup: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
func (c *blockingLookupRepoCache) Lookup(_, _ string) string {
|
|
select {
|
|
case <-c.lookupSeen:
|
|
default:
|
|
close(c.lookupSeen)
|
|
}
|
|
<-c.releaseLookup
|
|
return c.path
|
|
}
|
|
|
|
func (c *blockingLookupRepoCache) Sync(string, []repocache.RepoInfo) error {
|
|
return nil
|
|
}
|
|
|
|
func (c *blockingLookupRepoCache) WithRepoLock(_ string, fn func() error) error {
|
|
return fn()
|
|
}
|
|
|
|
func (c *blockingLookupRepoCache) CreateWorktree(repocache.WorktreeParams) (*repocache.WorktreeResult, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
type recordingRepoCache struct {
|
|
lookupPath string
|
|
mu sync.Mutex
|
|
params []repocache.WorktreeParams
|
|
}
|
|
|
|
func (c *recordingRepoCache) Lookup(_, _ string) string {
|
|
return c.lookupPath
|
|
}
|
|
|
|
func (c *recordingRepoCache) Sync(string, []repocache.RepoInfo) error {
|
|
return nil
|
|
}
|
|
|
|
func (c *recordingRepoCache) WithRepoLock(_ string, fn func() error) error {
|
|
return fn()
|
|
}
|
|
|
|
func (c *recordingRepoCache) CreateWorktree(params repocache.WorktreeParams) (*repocache.WorktreeResult, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.params = append(c.params, params)
|
|
return &repocache.WorktreeResult{Path: params.WorkDir, BranchName: "agent/test"}, nil
|
|
}
|
|
|
|
func (c *recordingRepoCache) lastCreateParams() repocache.WorktreeParams {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if len(c.params) == 0 {
|
|
return repocache.WorktreeParams{}
|
|
}
|
|
return c.params[len(c.params)-1]
|
|
}
|
|
|
|
func (c *blockingLookupRepoCache) waitForLookup(t *testing.T) {
|
|
t.Helper()
|
|
select {
|
|
case <-c.lookupSeen:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("registerTaskRepos did not call repo lookup")
|
|
}
|
|
}
|
|
|
|
func (c *blockingLookupRepoCache) release() {
|
|
c.releaseOnce.Do(func() {
|
|
close(c.releaseLookup)
|
|
})
|
|
}
|
|
|
|
func assertActiveTaskCount(t *testing.T, h http.HandlerFunc, want int64) {
|
|
t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil))
|
|
var resp HealthResponse
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp.ActiveTaskCount != want {
|
|
t.Errorf("active_task_count: got %d, want %d", resp.ActiveTaskCount, want)
|
|
}
|
|
}
|