mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 11:10:25 +02:00
* MUL-3332: daemon picks up new custom runtime profiles without restart The workspaceSyncLoop's already-tracked branch refreshed only settings and repos via refreshWorkspaceRepos and never re-fetched runtime profiles, so a custom runtime profile created via the web UI / CLI did not become a registered runtime row until the daemon restarted (or a runtimeGone recovery happened to fire). Detect server-side profile drift each sync tick by hashing the workspace's profile list with profileSetSignature(), caching the digest on workspaceState.profileSetSig, and triggering reregisterWorkspaceAfterRuntimeGone when the live signature differs from the cached one. Steady-state syncs cost exactly one extra GetRuntimeProfiles round trip; only real drift fans out to a Register call. The fetch is best-effort: a 404 / network blip preserves the cached signature so a transient failure cannot loop the daemon into spurious re-registrations. Tests in runtime_profile_drift_test.go cover digest stability under reorder, field-by-field drift detection (add / enable-flip / command_name / protocol_family / fixed_args / visibility), the no-drift hot path (no re-register), the new-profile drift path (single re-register + index update + sig converges), and best-effort fetch error handling. Co-authored-by: multica-agent <github@multica.ai> * MUL-3332: split orphan recovery from profile drift; converge to zero Addresses two blocking review concerns on #4225 (raised by GPT-Boy): 1. Profile drift must not kill running tasks on existing runtimes. The first cut reused reregisterWorkspaceAfterRuntimeGone, which after re-register calls /recover-orphans for every returned runtime ID. The server's RecoverOrphanedTasksForRuntime hard-fails every dispatched/running/waiting_local_directory row on that runtime — the correct response when a runtime row was actually deleted server-side, but a catastrophic false positive on profile drift: a built-in runtime still actively executing the user's tasks would have its work killed just because the user added an unrelated sibling custom profile. Fix: extract applyRegisterResponseInPlace as the shared in-place state converger between the two paths, and stop calling /recover-orphans from the drift path. reregisterWorkspaceAfterRuntimeGone keeps the /recover-orphans call because in that path the rows really were gone. 2. Disabling the only profile on a custom-only daemon must converge. The first cut hit registerRuntimesForWorkspace's len(runtimes)==0 guard and bailed out, so the disabled profile's runtime stayed alive in local tracking and on the server (still polling, still heartbeating, still online for the full 150 s stale-heartbeat window). Fix: introduce ErrNoRuntimesToRegister as a sentinel, have registerRuntimesForWorkspace return profileSig even on the empty case (so the drift path can cache the converged-empty signature), and have the drift refresh's error handler take a convergeWorkspaceRuntimesToZero branch that clears local runtimeIDs / runtimeIndex entries and Deregisters the orphaned IDs so the server marks them offline immediately. The same Deregister step also runs on partial drift (a built-in survives, the disabled profile's runtime drops) so the user sees the dropped runtime go offline within the next sync tick instead of after the 150 s sweep. Tests: - TestRefreshWorkspaceRuntimeProfiles_DriftWithRunningRuntimeSkipsOrphanRecovery (mixed built-in + custom, add another profile, asserts zero /recover-orphans calls). - TestRefreshWorkspaceRuntimeProfiles_DisableConvergesCustomOnlyDaemon (custom-only daemon, disable only profile, asserts local state cleared, signature converges to empty digest, Deregister called with the orphaned ID, no recover-orphans, follow-up tick is no-op). - TestRefreshWorkspaceRuntimeProfiles_DisableOneOfManyDeregistersDroppedID (partial drift: only the dropped ID is Deregistered, surviving built-in is left alone and not orphan-recovered). - TestRefreshWorkspaceRuntimeProfiles_NewProfileTriggersReregister extended to also assert no /recover-orphans calls. - TestRegisterRuntimes_SkipsProfileNotOnPath strengthened to assert the ErrNoRuntimesToRegister sentinel and that profileSig is still returned on the empty path. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
615 lines
24 KiB
Go
615 lines
24 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
)
|
|
|
|
// TestProfileSetSignature_StableUnderReorder ensures the digest only
|
|
// captures the *content* of the profile set, not the order the server
|
|
// happened to return profiles in. Without this property the
|
|
// workspaceSyncLoop would re-register on every tick whenever the server
|
|
// shuffled rows (which the API contract does not forbid).
|
|
func TestProfileSetSignature_StableUnderReorder(t *testing.T) {
|
|
a := []RuntimeProfile{
|
|
{ID: "p-a", ProtocolFamily: "codex", CommandName: "a", Enabled: true},
|
|
{ID: "p-b", ProtocolFamily: "claude", CommandName: "b", Enabled: true},
|
|
}
|
|
b := []RuntimeProfile{
|
|
{ID: "p-b", ProtocolFamily: "claude", CommandName: "b", Enabled: true},
|
|
{ID: "p-a", ProtocolFamily: "codex", CommandName: "a", Enabled: true},
|
|
}
|
|
if profileSetSignature(a) != profileSetSignature(b) {
|
|
t.Errorf("digest must be order-independent")
|
|
}
|
|
}
|
|
|
|
// TestProfileSetSignature_DetectsRegistrationAffectingChanges asserts the
|
|
// digest covers exactly the fields the daemon sends in a Register call.
|
|
// Coverage gaps here would mean a real server-side change goes undetected
|
|
// and the user has to restart the daemon — the bug MUL-3332 is about.
|
|
func TestProfileSetSignature_DetectsRegistrationAffectingChanges(t *testing.T) {
|
|
base := []RuntimeProfile{{
|
|
ID: "p1",
|
|
ProtocolFamily: "codex",
|
|
CommandName: "company-codex",
|
|
FixedArgs: []string{"--foo"},
|
|
Visibility: "workspace",
|
|
Enabled: true,
|
|
}}
|
|
baseSig := profileSetSignature(base)
|
|
|
|
// Empty list must hash differently from a one-profile list.
|
|
if profileSetSignature(nil) == baseSig {
|
|
t.Errorf("empty list must hash differently from a populated list")
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
mutate func([]RuntimeProfile) []RuntimeProfile
|
|
}{
|
|
{"add new profile", func(in []RuntimeProfile) []RuntimeProfile {
|
|
return append(in, RuntimeProfile{ID: "p2", ProtocolFamily: "claude", CommandName: "c", Enabled: true})
|
|
}},
|
|
{"flip enabled", func(in []RuntimeProfile) []RuntimeProfile {
|
|
out := append([]RuntimeProfile(nil), in...)
|
|
out[0].Enabled = !out[0].Enabled
|
|
return out
|
|
}},
|
|
{"change command_name", func(in []RuntimeProfile) []RuntimeProfile {
|
|
out := append([]RuntimeProfile(nil), in...)
|
|
out[0].CommandName = "different-bin"
|
|
return out
|
|
}},
|
|
{"change protocol_family", func(in []RuntimeProfile) []RuntimeProfile {
|
|
out := append([]RuntimeProfile(nil), in...)
|
|
out[0].ProtocolFamily = "claude"
|
|
return out
|
|
}},
|
|
{"change fixed_args", func(in []RuntimeProfile) []RuntimeProfile {
|
|
out := append([]RuntimeProfile(nil), in...)
|
|
out[0].FixedArgs = []string{"--foo", "--bar"}
|
|
return out
|
|
}},
|
|
{"change visibility", func(in []RuntimeProfile) []RuntimeProfile {
|
|
out := append([]RuntimeProfile(nil), in...)
|
|
out[0].Visibility = "private"
|
|
return out
|
|
}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := profileSetSignature(tc.mutate(base))
|
|
if got == baseSig {
|
|
t.Errorf("digest must change when %s; baseSig=%s mutatedSig=%s",
|
|
tc.name, baseSig, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// driftFixture wires a Daemon against a fake server whose runtime-profiles
|
|
// response can be swapped at runtime. It also tracks how many times the
|
|
// server saw a /api/daemon/register, /api/daemon/runtimes/:id/recover-orphans,
|
|
// and /api/daemon/deregister call so tests can assert exactly which side
|
|
// effects the drift refresh triggered (or didn't).
|
|
type driftFixture struct {
|
|
daemon *Daemon
|
|
server *httptest.Server
|
|
registerCalls atomic.Int32
|
|
recoverOrphansCalls []string // runtime IDs the server received recover-orphans for, in order
|
|
recoverOrphansMu sync.Mutex
|
|
deregisterCalls [][]string // each entry is one Deregister call's runtime_ids payload, in order
|
|
deregisterMu sync.Mutex
|
|
currentProfiles []RuntimeProfile
|
|
}
|
|
|
|
// setProfiles swaps the profile set returned by the fake server. The
|
|
// next GetRuntimeProfiles call observes the new value. Tests in this file
|
|
// drive the fixture from a single goroutine, so the field is unguarded;
|
|
// add a mutex if a future test publishes profile updates concurrently with
|
|
// daemon background work.
|
|
func (fx *driftFixture) setProfiles(profiles []RuntimeProfile) {
|
|
fx.currentProfiles = profiles
|
|
}
|
|
|
|
// recordedRecoverOrphans returns a copy of the runtime IDs the fake server
|
|
// received /recover-orphans calls for since the fixture was created.
|
|
func (fx *driftFixture) recordedRecoverOrphans() []string {
|
|
fx.recoverOrphansMu.Lock()
|
|
defer fx.recoverOrphansMu.Unlock()
|
|
out := make([]string, len(fx.recoverOrphansCalls))
|
|
copy(out, fx.recoverOrphansCalls)
|
|
return out
|
|
}
|
|
|
|
// recordedDeregisters returns a copy of every Deregister call's runtime_ids
|
|
// payload, in the order the fake server received them.
|
|
func (fx *driftFixture) recordedDeregisters() [][]string {
|
|
fx.deregisterMu.Lock()
|
|
defer fx.deregisterMu.Unlock()
|
|
out := make([][]string, len(fx.deregisterCalls))
|
|
for i, ids := range fx.deregisterCalls {
|
|
cp := make([]string, len(ids))
|
|
copy(cp, ids)
|
|
out[i] = cp
|
|
}
|
|
return out
|
|
}
|
|
|
|
func newDriftFixture(t *testing.T, initial []RuntimeProfile) *driftFixture {
|
|
t.Helper()
|
|
fx := &driftFixture{currentProfiles: initial}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.URL.Path == "/api/daemon/register":
|
|
fx.registerCalls.Add(1)
|
|
var body struct {
|
|
Runtimes []map[string]any `json:"runtimes"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
var resp RegisterResponse
|
|
for i, rt := range body.Runtimes {
|
|
id := "rt-" + strconv.Itoa(i)
|
|
profileID, _ := rt["profile_id"].(string)
|
|
typ, _ := rt["type"].(string)
|
|
resp.Runtimes = append(resp.Runtimes, Runtime{
|
|
ID: id, Name: "n", Provider: typ, Status: "online", ProfileID: profileID,
|
|
})
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
case strings.HasPrefix(r.URL.Path, "/api/daemon/runtimes/") && strings.HasSuffix(r.URL.Path, "/recover-orphans"):
|
|
// /api/daemon/runtimes/<id>/recover-orphans
|
|
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/daemon/runtimes/"), "/")
|
|
runtimeID := parts[0]
|
|
fx.recoverOrphansMu.Lock()
|
|
fx.recoverOrphansCalls = append(fx.recoverOrphansCalls, runtimeID)
|
|
fx.recoverOrphansMu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"orphaned":0,"retried":0}`))
|
|
case r.URL.Path == "/api/daemon/deregister":
|
|
var body struct {
|
|
RuntimeIDs []string `json:"runtime_ids"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
fx.deregisterMu.Lock()
|
|
ids := make([]string, len(body.RuntimeIDs))
|
|
copy(ids, body.RuntimeIDs)
|
|
fx.deregisterCalls = append(fx.deregisterCalls, ids)
|
|
fx.deregisterMu.Unlock()
|
|
w.WriteHeader(http.StatusOK)
|
|
case strings.HasSuffix(r.URL.Path, "/runtime-profiles"):
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(RuntimeProfilesResponse{
|
|
WorkspaceID: "ws-1",
|
|
RuntimeProfiles: fx.currentProfiles,
|
|
})
|
|
default:
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
d := freshDaemon(srv.URL)
|
|
d.profileCommandPaths = make(map[string]string)
|
|
fx.daemon = d
|
|
fx.server = srv
|
|
return fx
|
|
}
|
|
|
|
// TestRefreshWorkspaceRuntimeProfiles_NoDrift_DoesNotReregister verifies the
|
|
// hot-path: when the server's profile set has not changed since the daemon
|
|
// last registered the workspace, the sync tick must NOT fire a re-register.
|
|
// Without this guarantee every quiet sync tick would cost an extra Register
|
|
// HTTP call per workspace.
|
|
func TestRefreshWorkspaceRuntimeProfiles_NoDrift_DoesNotReregister(t *testing.T) {
|
|
t.Cleanup(stubAgentVersion(t))
|
|
stubLookPath(t, map[string]string{"company-codex": "/opt/bin/company-codex"})
|
|
profiles := []RuntimeProfile{{
|
|
ID: "prof-1", WorkspaceID: "ws-1", DisplayName: "Company Codex",
|
|
ProtocolFamily: "codex", CommandName: "company-codex",
|
|
Visibility: "workspace", Enabled: true,
|
|
}}
|
|
fx := newDriftFixture(t, profiles)
|
|
d := fx.daemon
|
|
d.cfg.Agents = map[string]AgentEntry{}
|
|
|
|
// Initial register seeds workspaceState (and ws.profileSetSig).
|
|
resp, profileSig, err := d.registerRuntimesForWorkspace(context.Background(), "ws-1")
|
|
if err != nil {
|
|
t.Fatalf("initial register: %v", err)
|
|
}
|
|
d.workspaces["ws-1"] = newWorkspaceState("ws-1", []string{resp.Runtimes[0].ID}, "", nil, nil)
|
|
d.workspaces["ws-1"].profileSetSig = profileSig
|
|
for _, rt := range resp.Runtimes {
|
|
d.runtimeIndex[rt.ID] = rt
|
|
}
|
|
if fx.registerCalls.Load() != 1 {
|
|
t.Fatalf("setup expected 1 register call, got %d", fx.registerCalls.Load())
|
|
}
|
|
|
|
// Server returns the same profile set: refresh must not re-register.
|
|
if err := d.refreshWorkspaceRuntimeProfiles(context.Background(), "ws-1"); err != nil {
|
|
t.Fatalf("refreshWorkspaceRuntimeProfiles: %v", err)
|
|
}
|
|
if fx.registerCalls.Load() != 1 {
|
|
t.Errorf("no-drift refresh must not re-register; got %d total register calls", fx.registerCalls.Load())
|
|
}
|
|
}
|
|
|
|
// TestRefreshWorkspaceRuntimeProfiles_NewProfileTriggersReregister verifies
|
|
// the user-visible fix for MUL-3332: a profile created via the web UI on an
|
|
// already-tracked workspace becomes a registered runtime within one
|
|
// workspaceSyncLoop tick — no daemon restart required.
|
|
func TestRefreshWorkspaceRuntimeProfiles_NewProfileTriggersReregister(t *testing.T) {
|
|
t.Cleanup(stubAgentVersion(t))
|
|
stubLookPath(t, map[string]string{
|
|
"company-codex": "/opt/bin/company-codex",
|
|
"team-claude": "/opt/bin/team-claude",
|
|
})
|
|
initial := []RuntimeProfile{{
|
|
ID: "prof-1", WorkspaceID: "ws-1", DisplayName: "Company Codex",
|
|
ProtocolFamily: "codex", CommandName: "company-codex",
|
|
Visibility: "workspace", Enabled: true,
|
|
}}
|
|
fx := newDriftFixture(t, initial)
|
|
d := fx.daemon
|
|
d.cfg.Agents = map[string]AgentEntry{}
|
|
|
|
// Initial register with one profile.
|
|
resp, profileSig, err := d.registerRuntimesForWorkspace(context.Background(), "ws-1")
|
|
if err != nil {
|
|
t.Fatalf("initial register: %v", err)
|
|
}
|
|
ids := make([]string, 0, len(resp.Runtimes))
|
|
for _, rt := range resp.Runtimes {
|
|
ids = append(ids, rt.ID)
|
|
d.runtimeIndex[rt.ID] = rt
|
|
}
|
|
d.workspaces["ws-1"] = newWorkspaceState("ws-1", ids, "", nil, nil)
|
|
d.workspaces["ws-1"].profileSetSig = profileSig
|
|
beforeRegisterCalls := fx.registerCalls.Load()
|
|
|
|
// User adds a second profile via the web UI: server's response now
|
|
// includes prof-2.
|
|
fx.setProfiles([]RuntimeProfile{
|
|
{ID: "prof-1", WorkspaceID: "ws-1", DisplayName: "Company Codex",
|
|
ProtocolFamily: "codex", CommandName: "company-codex",
|
|
Visibility: "workspace", Enabled: true},
|
|
{ID: "prof-2", WorkspaceID: "ws-1", DisplayName: "Team Claude",
|
|
ProtocolFamily: "claude", CommandName: "team-claude",
|
|
Visibility: "workspace", Enabled: true},
|
|
})
|
|
|
|
if err := d.refreshWorkspaceRuntimeProfiles(context.Background(), "ws-1"); err != nil {
|
|
t.Fatalf("refreshWorkspaceRuntimeProfiles: %v", err)
|
|
}
|
|
|
|
// Drift must fire a new register call.
|
|
if got := fx.registerCalls.Load(); got != beforeRegisterCalls+1 {
|
|
t.Errorf("new profile must trigger one re-register; before=%d after=%d", beforeRegisterCalls, got)
|
|
}
|
|
|
|
// The daemon's runtimeIndex must now hold a runtime for the new profile.
|
|
d.mu.Lock()
|
|
var seenProf2 bool
|
|
for _, rt := range d.runtimeIndex {
|
|
if rt.ProfileID == "prof-2" {
|
|
seenProf2 = true
|
|
break
|
|
}
|
|
}
|
|
d.mu.Unlock()
|
|
if !seenProf2 {
|
|
t.Errorf("expected runtimeIndex to contain a runtime for prof-2 after refresh")
|
|
}
|
|
|
|
// And the cached signature must now match the new profile set, so a
|
|
// follow-up refresh with no further changes is a no-op.
|
|
stableCalls := fx.registerCalls.Load()
|
|
if err := d.refreshWorkspaceRuntimeProfiles(context.Background(), "ws-1"); err != nil {
|
|
t.Fatalf("second refresh: %v", err)
|
|
}
|
|
if got := fx.registerCalls.Load(); got != stableCalls {
|
|
t.Errorf("steady-state refresh must not re-register; before=%d after=%d", stableCalls, got)
|
|
}
|
|
|
|
// MUL-3332 review (concern 1): the drift path MUST NOT call recover-
|
|
// orphans on any returned runtime. Recover-orphans hard-fails every
|
|
// dispatched/running task on a runtime, so calling it for a built-in
|
|
// runtime that the user had been actively running tasks on would kill
|
|
// real work just because they added an unrelated sibling profile. The
|
|
// runtime_gone recovery path keeps recover-orphans because those rows
|
|
// were truly deleted server-side; drift surfaces existing runtimes.
|
|
if got := fx.recordedRecoverOrphans(); len(got) != 0 {
|
|
t.Errorf("drift path must not trigger recover-orphans for any runtime; got %v", got)
|
|
}
|
|
}
|
|
|
|
// TestRefreshWorkspaceRuntimeProfiles_DriftWithRunningRuntimeSkipsOrphanRecovery
|
|
// is the targeted regression for MUL-3332 review concern #1: a daemon that
|
|
// is actively executing tasks on its existing runtime (built-in or
|
|
// previously-registered profile) must NOT have those tasks killed when
|
|
// the user adds a *new* sibling profile. Adding a profile must surface as
|
|
// a re-register that includes the new profile while LEAVING the existing
|
|
// runtime IDs untouched and recover-orphans untriggered.
|
|
func TestRefreshWorkspaceRuntimeProfiles_DriftWithRunningRuntimeSkipsOrphanRecovery(t *testing.T) {
|
|
t.Cleanup(stubAgentVersion(t))
|
|
stubLookPath(t, map[string]string{
|
|
"company-codex": "/opt/bin/company-codex",
|
|
"team-claude": "/opt/bin/team-claude",
|
|
})
|
|
// Mixed setup: one built-in runtime (claude) plus one custom profile,
|
|
// the closest analogue of "a daemon that is running real work and the
|
|
// user just added another profile". Profile drift fires next.
|
|
initial := []RuntimeProfile{{
|
|
ID: "prof-1", WorkspaceID: "ws-1", DisplayName: "Company Codex",
|
|
ProtocolFamily: "codex", CommandName: "company-codex",
|
|
Visibility: "workspace", Enabled: true,
|
|
}}
|
|
fx := newDriftFixture(t, initial)
|
|
d := fx.daemon
|
|
d.cfg.Agents = map[string]AgentEntry{"claude": {Path: "/usr/bin/true"}}
|
|
|
|
resp, profileSig, err := d.registerRuntimesForWorkspace(context.Background(), "ws-1")
|
|
if err != nil {
|
|
t.Fatalf("initial register: %v", err)
|
|
}
|
|
ids := make([]string, 0, len(resp.Runtimes))
|
|
for _, rt := range resp.Runtimes {
|
|
ids = append(ids, rt.ID)
|
|
d.runtimeIndex[rt.ID] = rt
|
|
}
|
|
d.workspaces["ws-1"] = newWorkspaceState("ws-1", ids, "", nil, nil)
|
|
d.workspaces["ws-1"].profileSetSig = profileSig
|
|
if len(ids) < 2 {
|
|
t.Fatalf("setup expected at least 2 runtimes (built-in + custom); got %d", len(ids))
|
|
}
|
|
|
|
// User adds a second profile.
|
|
fx.setProfiles([]RuntimeProfile{
|
|
{ID: "prof-1", WorkspaceID: "ws-1", DisplayName: "Company Codex",
|
|
ProtocolFamily: "codex", CommandName: "company-codex",
|
|
Visibility: "workspace", Enabled: true},
|
|
{ID: "prof-2", WorkspaceID: "ws-1", DisplayName: "Team Claude",
|
|
ProtocolFamily: "claude", CommandName: "team-claude",
|
|
Visibility: "workspace", Enabled: true},
|
|
})
|
|
|
|
if err := d.refreshWorkspaceRuntimeProfiles(context.Background(), "ws-1"); err != nil {
|
|
t.Fatalf("refreshWorkspaceRuntimeProfiles: %v", err)
|
|
}
|
|
|
|
// Hard regression: zero recover-orphans calls for ANY runtime ID.
|
|
// The whole point of MUL-3332 review concern #1 is that adding a
|
|
// profile must not nuke in-flight work on existing runtimes.
|
|
if got := fx.recordedRecoverOrphans(); len(got) != 0 {
|
|
t.Errorf("drift refresh leaked recover-orphans calls (would fail running tasks on existing runtimes): %v", got)
|
|
}
|
|
}
|
|
|
|
// TestRefreshWorkspaceRuntimeProfiles_DisableConvergesCustomOnlyDaemon is the
|
|
// targeted regression for MUL-3332 review concern #2: when a custom-only
|
|
// daemon (no built-in agents) has its only enabled profile disabled, the
|
|
// daemon must converge to a zero-runtime state — clear local tracking AND
|
|
// tell the server to mark the orphaned runtime row offline immediately,
|
|
// rather than leaving the daemon polling/heartbeating a runtime the user
|
|
// can no longer use.
|
|
func TestRefreshWorkspaceRuntimeProfiles_DisableConvergesCustomOnlyDaemon(t *testing.T) {
|
|
t.Cleanup(stubAgentVersion(t))
|
|
stubLookPath(t, map[string]string{"company-codex": "/opt/bin/company-codex"})
|
|
initial := []RuntimeProfile{{
|
|
ID: "prof-1", WorkspaceID: "ws-1", DisplayName: "Company Codex",
|
|
ProtocolFamily: "codex", CommandName: "company-codex",
|
|
Visibility: "workspace", Enabled: true,
|
|
}}
|
|
fx := newDriftFixture(t, initial)
|
|
d := fx.daemon
|
|
// Custom-only daemon: no built-in agents at all.
|
|
d.cfg.Agents = map[string]AgentEntry{}
|
|
|
|
// Initial register: one custom runtime.
|
|
resp, profileSig, err := d.registerRuntimesForWorkspace(context.Background(), "ws-1")
|
|
if err != nil {
|
|
t.Fatalf("initial register: %v", err)
|
|
}
|
|
if len(resp.Runtimes) != 1 {
|
|
t.Fatalf("setup expected exactly one runtime; got %d", len(resp.Runtimes))
|
|
}
|
|
initialRuntimeID := resp.Runtimes[0].ID
|
|
d.runtimeIndex[initialRuntimeID] = resp.Runtimes[0]
|
|
d.workspaces["ws-1"] = newWorkspaceState("ws-1", []string{initialRuntimeID}, "", nil, nil)
|
|
d.workspaces["ws-1"].profileSetSig = profileSig
|
|
|
|
// User disables the only profile: server's daemon-facing list now
|
|
// returns zero enabled profiles.
|
|
fx.setProfiles(nil)
|
|
|
|
if err := d.refreshWorkspaceRuntimeProfiles(context.Background(), "ws-1"); err != nil {
|
|
t.Fatalf("refreshWorkspaceRuntimeProfiles: %v", err)
|
|
}
|
|
|
|
// Local tracking must be empty: the daemon stops polling/heartbeating
|
|
// runtimes that no longer represent anything the user can run.
|
|
d.mu.Lock()
|
|
gotRuntimeIDs := append([]string(nil), d.workspaces["ws-1"].runtimeIDs...)
|
|
_, stillIndexed := d.runtimeIndex[initialRuntimeID]
|
|
gotSig := d.workspaces["ws-1"].profileSetSig
|
|
d.mu.Unlock()
|
|
if len(gotRuntimeIDs) != 0 {
|
|
t.Errorf("workspaceState.runtimeIDs must be empty after convergence-to-zero; got %v", gotRuntimeIDs)
|
|
}
|
|
if stillIndexed {
|
|
t.Errorf("runtimeIndex must drop the previously-tracked runtime %q after convergence-to-zero", initialRuntimeID)
|
|
}
|
|
if gotSig != profileSetSignature(nil) {
|
|
t.Errorf("converged signature must match the empty-profile-list digest so the next sync tick is a no-op; got %q want %q", gotSig, profileSetSignature(nil))
|
|
}
|
|
|
|
// Server-side cleanup: the daemon must Deregister the orphaned runtime
|
|
// ID so the runtime row goes offline immediately instead of waiting
|
|
// 150 s for the stale-heartbeat sweep.
|
|
deregs := fx.recordedDeregisters()
|
|
if len(deregs) == 0 {
|
|
t.Fatalf("expected one Deregister call for the orphaned runtime ID; got none")
|
|
}
|
|
var sawInitial bool
|
|
for _, ids := range deregs {
|
|
for _, id := range ids {
|
|
if id == initialRuntimeID {
|
|
sawInitial = true
|
|
}
|
|
}
|
|
}
|
|
if !sawInitial {
|
|
t.Errorf("Deregister payload must include the initial runtime ID %q; got %v", initialRuntimeID, deregs)
|
|
}
|
|
|
|
// Convergence-to-zero must NOT call recover-orphans either: the daemon
|
|
// has nothing actively running for the user once they disabled the
|
|
// profile, but if it had, killing those tasks for a profile DISABLE
|
|
// (vs delete) would still be wrong — the user might re-enable.
|
|
if got := fx.recordedRecoverOrphans(); len(got) != 0 {
|
|
t.Errorf("convergence-to-zero must not trigger recover-orphans; got %v", got)
|
|
}
|
|
|
|
// Steady state: a follow-up refresh with the same (empty) profile set
|
|
// is a no-op. No new register / deregister / recover-orphans calls.
|
|
stableRegisters := fx.registerCalls.Load()
|
|
stableDeregs := len(fx.recordedDeregisters())
|
|
if err := d.refreshWorkspaceRuntimeProfiles(context.Background(), "ws-1"); err != nil {
|
|
t.Fatalf("second refresh after convergence: %v", err)
|
|
}
|
|
if got := fx.registerCalls.Load(); got != stableRegisters {
|
|
t.Errorf("converged steady state must not re-register; before=%d after=%d", stableRegisters, got)
|
|
}
|
|
if got := len(fx.recordedDeregisters()); got != stableDeregs {
|
|
t.Errorf("converged steady state must not deregister again; before=%d after=%d", stableDeregs, got)
|
|
}
|
|
}
|
|
|
|
// TestRefreshWorkspaceRuntimeProfiles_DisableOneOfManyDeregistersDroppedID
|
|
// covers the partial-drift case: a daemon hosting both a built-in runtime
|
|
// and a custom profile has its custom profile disabled. The built-in
|
|
// survives (Register call carries it forward), the daemon's runtime set
|
|
// converges to just the built-in, and the dropped custom runtime ID is
|
|
// Deregistered so the server marks it offline immediately.
|
|
func TestRefreshWorkspaceRuntimeProfiles_DisableOneOfManyDeregistersDroppedID(t *testing.T) {
|
|
t.Cleanup(stubAgentVersion(t))
|
|
stubLookPath(t, map[string]string{"company-codex": "/opt/bin/company-codex"})
|
|
initial := []RuntimeProfile{{
|
|
ID: "prof-1", WorkspaceID: "ws-1", DisplayName: "Company Codex",
|
|
ProtocolFamily: "codex", CommandName: "company-codex",
|
|
Visibility: "workspace", Enabled: true,
|
|
}}
|
|
fx := newDriftFixture(t, initial)
|
|
d := fx.daemon
|
|
// Mixed: one built-in + one custom.
|
|
d.cfg.Agents = map[string]AgentEntry{"claude": {Path: "/usr/bin/true"}}
|
|
|
|
resp, profileSig, err := d.registerRuntimesForWorkspace(context.Background(), "ws-1")
|
|
if err != nil {
|
|
t.Fatalf("initial register: %v", err)
|
|
}
|
|
if len(resp.Runtimes) != 2 {
|
|
t.Fatalf("setup expected 2 runtimes; got %d (%+v)", len(resp.Runtimes), resp.Runtimes)
|
|
}
|
|
var customID string
|
|
for _, rt := range resp.Runtimes {
|
|
d.runtimeIndex[rt.ID] = rt
|
|
if rt.ProfileID == "prof-1" {
|
|
customID = rt.ID
|
|
}
|
|
}
|
|
if customID == "" {
|
|
t.Fatalf("setup expected a runtime for prof-1; got %+v", resp.Runtimes)
|
|
}
|
|
initialIDs := []string{resp.Runtimes[0].ID, resp.Runtimes[1].ID}
|
|
d.workspaces["ws-1"] = newWorkspaceState("ws-1", initialIDs, "", nil, nil)
|
|
d.workspaces["ws-1"].profileSetSig = profileSig
|
|
|
|
// User disables the custom profile.
|
|
fx.setProfiles(nil)
|
|
|
|
if err := d.refreshWorkspaceRuntimeProfiles(context.Background(), "ws-1"); err != nil {
|
|
t.Fatalf("refreshWorkspaceRuntimeProfiles: %v", err)
|
|
}
|
|
|
|
// The custom runtime ID must be Deregistered.
|
|
deregs := fx.recordedDeregisters()
|
|
var sawCustom bool
|
|
for _, ids := range deregs {
|
|
for _, id := range ids {
|
|
if id == customID {
|
|
sawCustom = true
|
|
}
|
|
}
|
|
}
|
|
if !sawCustom {
|
|
t.Errorf("Deregister payload must include the dropped custom runtime ID %q; got %v", customID, deregs)
|
|
}
|
|
|
|
// Surviving built-in must NOT be in any deregister payload.
|
|
for _, ids := range deregs {
|
|
for _, id := range ids {
|
|
if id != customID {
|
|
t.Errorf("Deregister leaked surviving runtime ID %q (only %q should be deregistered)", id, customID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// And the still-surviving built-in must NOT have recover-orphans called
|
|
// against it (concern #1 again, partial-drift flavour).
|
|
if got := fx.recordedRecoverOrphans(); len(got) != 0 {
|
|
t.Errorf("partial-drift refresh leaked recover-orphans calls: %v", got)
|
|
}
|
|
}
|
|
|
|
// TestRefreshWorkspaceRuntimeProfiles_FetchErrorIsBestEffort verifies that
|
|
// a network blip or older server (404) does NOT clear the cached signature
|
|
// or trigger a spurious re-register. Without this, a transient 5xx during
|
|
// the workspace sync loop would loop the daemon into re-registering forever.
|
|
func TestRefreshWorkspaceRuntimeProfiles_FetchErrorIsBestEffort(t *testing.T) {
|
|
t.Cleanup(stubAgentVersion(t))
|
|
stubLookPath(t, map[string]string{"company-codex": "/opt/bin/company-codex"})
|
|
profiles := []RuntimeProfile{{
|
|
ID: "prof-1", WorkspaceID: "ws-1", DisplayName: "Company Codex",
|
|
ProtocolFamily: "codex", CommandName: "company-codex",
|
|
Visibility: "workspace", Enabled: true,
|
|
}}
|
|
// Server that returns 404 for the profiles route.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasSuffix(r.URL.Path, "/runtime-profiles") {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
d := freshDaemon(srv.URL)
|
|
d.profileCommandPaths = make(map[string]string)
|
|
knownSig := profileSetSignature(profiles)
|
|
d.workspaces["ws-1"] = newWorkspaceState("ws-1", []string{"rt-1"}, "", nil, nil)
|
|
d.workspaces["ws-1"].profileSetSig = knownSig
|
|
|
|
err := d.refreshWorkspaceRuntimeProfiles(context.Background(), "ws-1")
|
|
if err == nil {
|
|
t.Fatalf("404 must surface as an error so the caller can log it at debug")
|
|
}
|
|
|
|
// Cached signature must be preserved (no overwrite on transient failures).
|
|
d.mu.Lock()
|
|
gotSig := d.workspaces["ws-1"].profileSetSig
|
|
d.mu.Unlock()
|
|
if gotSig != knownSig {
|
|
t.Errorf("transient fetch error must not clobber cached sig; want %q got %q", knownSig, gotSig)
|
|
}
|
|
}
|