Skip to content

fix: hot-rebind server when Tailscale becomes available#102

Merged
jcanizalez merged 4 commits intomainfrom
feat/agent-picker-tasks-worktree-67544611
Mar 26, 2026
Merged

fix: hot-rebind server when Tailscale becomes available#102
jcanizalez merged 4 commits intomainfrom
feat/agent-picker-tasks-worktree-67544611

Conversation

@jcanizalez
Copy link
Copy Markdown
Owner

Summary

  • Adds a server-rebind module that re-listens the HTTP server on 0.0.0.0 when Tailscale becomes available after app startup
  • Triggers rebind automatically on config change (toggling Remote Access) and on refresh (tailscale:status RPC)
  • Both Electron and web clients auto-reconnect after the brief rebind interruption

Test plan

  • Start app with Tailscale disabled, enable Tailscale, toggle "Enable Remote Access" → verify the app is reachable on the Tailscale IP
  • With Remote Access already enabled, restart Tailscale, press Refresh → verify rebind happens
  • Disable Remote Access → verify server rebinds back to 127.0.0.1
  • Verify Electron client reconnects after rebind without manual intervention

- 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.
Copilot AI review requested due to automatic review settings March 26, 2026 03:19
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ts and wires it into server startup, config changes, and tailscale:status RPC.
  • Updates AgentPicker to support an “Unassigned/None” state (allowNone, nullable currentAgent/onChange) and adopts it in task create/edit surfaces.
  • Adds Vitest coverage for the new nullable AgentPicker behavior.

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.

Comment on lines +52 to +53
server.listen(boundPort, desiredHost, () => resolve())
server.once('error', reject)
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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')).

Suggested change
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)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9cb59d7 — paired onError/onListening handlers that remove each other on resolution.

Comment on lines +642 to +650
<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>
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The agent-picker changes are from a prior commit on this branch (163f4c7) — not part of this fix. Will land separately.

Comment on lines +20 to +39
/**
* 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
}
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed this is worth covering. Will add in a follow-up.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +45 to +54
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)
})
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@jcanizalez jcanizalez merged commit 5086853 into main Mar 26, 2026
1 check passed
@jcanizalez jcanizalez deleted the feat/agent-picker-tasks-worktree-67544611 branch March 26, 2026 15:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants