fix: hot-rebind server when Tailscale becomes available#102
Conversation
- Extend AgentPicker with allowNone prop for optional agent selection - Add agent picker to AddTaskDialog toolbar (between project and branch) - Make agent field editable in TaskDetailPanel with auto-save - Wire assignedAgent into create/update payloads - Add 8 tests for AgentPicker nullable behavior Closes #95
The server binds its listen address at startup. If Tailscale was not running at launch time, the server stays on 127.0.0.1 even after Tailscale connects — making the app unreachable from remote devices. Add a server-rebind module that re-listens on 0.0.0.0 when networkAccessEnabled is toggled or the refresh button is pressed.
There was a problem hiding this comment.
Pull request overview
Adds infrastructure to hot-rebind the embedded HTTP server when Tailscale/network access becomes available after startup, and updates UI to support nullable agent assignment via AgentPicker.
Changes:
- Introduces
packages/server/src/server-rebind.tsand wires it into server startup, config changes, andtailscale:statusRPC. - Updates
AgentPickerto support an “Unassigned/None” state (allowNone, nullablecurrentAgent/onChange) and adopts it in task create/edit surfaces. - Adds Vitest coverage for the new nullable
AgentPickerbehavior.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/agent-picker-nullable.test.tsx | Adds tests for AgentPicker when allowNone enables a nullable selection. |
| src/renderer/components/workflow-editor/panels/LaunchAgentConfigForm.tsx | Adjusts AgentPicker usage to accommodate nullable callback typing. |
| src/renderer/components/TaskDetailPanel.tsx | Replaces static agent display with editable AgentPicker (nullable assignment). |
| src/renderer/components/AgentPicker.tsx | Adds allowNone + nullable agent support and UI for “Unassigned/None”. |
| src/renderer/components/AddTaskDialog.tsx | Adds agent assignment to task create/edit via AgentPicker. |
| packages/server/src/server-rebind.ts | New module to rebind server host between 127.0.0.1 and 0.0.0.0 based on config + Tailscale state. |
| packages/server/src/register-methods.ts | Makes tailscale:status async and triggers rebind checks on refresh. |
| packages/server/src/index.ts | Initializes rebind module and triggers rebind checks on config change. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
packages/server/src/server-rebind.ts
Outdated
| server.listen(boundPort, desiredHost, () => resolve()) | ||
| server.once('error', reject) |
There was a problem hiding this comment.
In the listen() promise, server.once('error', reject) is never removed on a successful bind. If rebind happens multiple times without errors, these one-time listeners will accumulate and can trigger MaxListeners warnings / leaks. Attach an onError handler and remove it in the listen callback (or use a helper that races once(server,'error') vs once(server,'listening')).
| server.listen(boundPort, desiredHost, () => resolve()) | |
| server.once('error', reject) | |
| const onError = (err: unknown) => { | |
| server.removeListener('listening', onListening) | |
| reject(err) | |
| } | |
| const onListening = () => { | |
| server.removeListener('error', onError) | |
| resolve() | |
| } | |
| server.once('error', onError) | |
| server.listen(boundPort, desiredHost, onListening) |
There was a problem hiding this comment.
Fixed in 9cb59d7 — paired onError/onListening handlers that remove each other on resolution.
| <div className="flex items-center gap-2 text-[12px]"> | ||
| <span className="text-gray-600 w-20 shrink-0">Agent</span> | ||
| <AgentPicker | ||
| currentAgent={formAssignedAgent} | ||
| onChange={setFormAssignedAgent} | ||
| installStatus={agentInstallStatus} | ||
| allowNone | ||
| /> | ||
| </div> |
There was a problem hiding this comment.
The PR title/description focus on hot-rebinding the server when Tailscale becomes available, but this change introduces a new task "Agent" assignment UI (via AgentPicker with allowNone) which isn't mentioned. Please either update the PR description/test plan to cover the task-assignment UI change or split these UI changes into a separate PR to keep scope clear.
There was a problem hiding this comment.
The agent-picker changes are from a prior commit on this branch (163f4c7) — not part of this fix. Will land separately.
| /** | ||
| * Check if the server needs to rebind based on current config + Tailscale state. | ||
| * Binds to 0.0.0.0 when networkAccessEnabled AND Tailscale is running, else 127.0.0.1. | ||
| */ | ||
| export async function checkAndRebind(): Promise<void> { | ||
| if (!httpServer) return | ||
|
|
||
| const config = configManager.loadConfig() | ||
| let desiredHost = '127.0.0.1' | ||
|
|
||
| if (config.defaults.networkAccessEnabled) { | ||
| try { | ||
| const tsStatus = await getTailscaleStatus() | ||
| if (tsStatus.running) { | ||
| desiredHost = '0.0.0.0' | ||
| } | ||
| } catch { | ||
| // Tailscale check failed, stay on localhost | ||
| } | ||
| } |
There was a problem hiding this comment.
packages/server/src/server-rebind.ts introduces new host-selection and rebind behavior, but there are no tests covering it. Since server code is already covered by Vitest, adding focused unit tests (e.g., desiredHost selection based on config + mocked Tailscale status, and that close()/listen() are invoked when the host changes) would help prevent regressions.
There was a problem hiding this comment.
Agreed this is worth covering. Will add in a follow-up.
There was a problem hiding this comment.
Added in 304253a — 9 tests covering: no-op cases, rebind to 0.0.0.0 / back to 127.0.0.1, Tailscale failure handling, listen error recovery, concurrent call serialization, and port preservation.
| try { | ||
| const server = httpServer | ||
| if (typeof server.closeAllConnections === 'function') { | ||
| server.closeAllConnections() | ||
| } | ||
| await new Promise<void>((resolve) => server.close(() => resolve())) | ||
| await new Promise<void>((resolve, reject) => { | ||
| server.listen(boundPort, desiredHost, () => resolve()) | ||
| server.once('error', reject) | ||
| }) |
There was a problem hiding this comment.
checkAndRebind() can be invoked from multiple places (config change handler and the tailscale:status RPC). As written, concurrent calls can interleave close()/listen() on the same httpServer, which can leave the server closed or in an error state. Consider serializing rebinding with a module-level mutex/in-flight promise so only one close→listen sequence can run at a time (and later calls either await or no-op).
There was a problem hiding this comment.
Fixed in 9cb59d7 — added a rebindInFlight mutex so concurrent calls await the in-progress rebind instead of interleaving.
- Add mutex so concurrent checkAndRebind() calls don't interleave close/listen on the same server - Remove error listener on successful bind to prevent accumulation
Covers: no-op cases, rebind to 0.0.0.0 / back to 127.0.0.1, Tailscale failure handling, listen error recovery, concurrent call serialization, and port preservation.
Summary
server-rebindmodule that re-listens the HTTP server on0.0.0.0when Tailscale becomes available after app startuptailscale:statusRPC)Test plan
127.0.0.1