* 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>