Skip to content

Commit b4a548c

Browse files
committed
Add in-place agent reconnect to recover from a hung child
When the agent child hangs (e.g. the machine resumes from suspend with a dead connection) the only recovery was to quit Neovim and restore, since quitting is what actually killed the child. Agentic.reconnect() does that in place: stop the child, which drains the stuck request callbacks and resets the generating UI, respawn and re-initialize, then reload the current session so the conversation continues. The agent process is shared per provider, so this also resets other tabs on that provider; they re-establish on next use.
1 parent 55ea5f7 commit b4a548c

6 files changed

Lines changed: 273 additions & 1 deletion

File tree

lua/agentic/acp/acp_client.lua

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,17 @@ function ACPClient:stop()
698698
self.transport:stop()
699699
end
700700

701+
--- Force a fresh agent process in place: kill the current (possibly hung)
702+
--- child, then respawn and re-initialize. `stop()` drains every pending
703+
--- callback and moves the state to "disconnected", which `_connect()` requires.
704+
--- Sessions are not restored here; the caller re-establishes them once the
705+
--- client reaches "ready" again (via `when_ready`).
706+
function ACPClient:reconnect()
707+
self.transport:stop()
708+
self.reconnect_count = 0
709+
self:_connect()
710+
end
711+
701712
function ACPClient:_connect()
702713
if self.state ~= "disconnected" then
703714
return

lua/agentic/acp/acp_client.test.lua

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,4 +575,88 @@ describe("ACPClient", function()
575575
assert.is_nil(watched_client._watchdog_timer)
576576
end)
577577
end)
578+
579+
describe("reconnect", function()
580+
local original_schedule
581+
582+
before_each(function()
583+
-- The drain on disconnect defers rejection via vim.schedule; run it
584+
-- inline so it resolves within the test.
585+
original_schedule = vim.schedule
586+
vim.schedule = function(fn)
587+
fn()
588+
end
589+
end)
590+
591+
after_each(function()
592+
vim.schedule = original_schedule
593+
end)
594+
595+
it("stops the transport and re-initializes back to ready", function()
596+
local client = create_ready_client(LOAD_CAPS)
597+
assert.equal("ready", client.state)
598+
599+
-- stop() drives the connection down; _connect() needs "disconnected".
600+
transport_stop_stub:invokes(function()
601+
if captured_on_state_change then
602+
captured_on_state_change("disconnected")
603+
end
604+
end)
605+
606+
-- A fresh start() reconnects and answers the new initialize.
607+
transport_start_stub:reset()
608+
transport_start_stub:invokes(function()
609+
if captured_on_state_change then
610+
captured_on_state_change("connected")
611+
end
612+
end)
613+
transport_send_stub:reset()
614+
transport_send_stub:invokes(function(_self, data)
615+
local decoded = vim.json.decode(data)
616+
if decoded.method == "initialize" and captured_on_message then
617+
captured_on_message({
618+
jsonrpc = "2.0",
619+
id = decoded.id,
620+
result = {
621+
protocolVersion = 1,
622+
agentCapabilities = LOAD_CAPS,
623+
agentInfo = { name = "test" },
624+
},
625+
})
626+
end
627+
end)
628+
629+
client:reconnect()
630+
631+
assert.spy(transport_stop_stub).was.called(1)
632+
assert.spy(transport_start_stub).was.called(1)
633+
assert.equal("ready", client.state)
634+
end)
635+
636+
it(
637+
"rejects in-flight requests when it tears the connection down",
638+
function()
639+
local client = create_ready_client(LOAD_CAPS)
640+
641+
--- @type agentic.acp.ACPError|nil
642+
local prompt_err
643+
client:send_prompt("sess-1", {}, function(_result, err)
644+
prompt_err = err
645+
end)
646+
647+
-- stop() reports the drop, which must drain pending callbacks.
648+
transport_stop_stub:invokes(function()
649+
if captured_on_state_change then
650+
captured_on_state_change("disconnected")
651+
end
652+
end)
653+
transport_start_stub:reset()
654+
transport_start_stub:invokes(function() end)
655+
656+
client:reconnect()
657+
658+
assert.is_not_nil(prompt_err)
659+
end
660+
)
661+
end)
578662
end)

lua/agentic/acp/acp_transport.test.lua

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,74 @@ describe("ACPTransport process lifecycle", function()
101101
)
102102
end)
103103

104+
describe("ACPTransport reconnect-in-place", function()
105+
local child
106+
107+
before_each(function()
108+
child = MiniTest.new_child_neovim()
109+
child.restart({ "-i", "NONE", "-u", "NONE" })
110+
child.lua("vim.opt.rtp:prepend(...)", { vim.fn.getcwd() })
111+
end)
112+
113+
after_each(function()
114+
child.stop()
115+
end)
116+
117+
it(
118+
"can stop and start the same transport, yielding a fresh live child",
119+
function()
120+
-- The reconnect feature reuses one transport object: stop() kills the
121+
-- child and start() spawns a new one. stop() closes the process
122+
-- handle, which cancels the dead child's exit callback, so the killed
123+
-- child can never disturb the connection that replaced it.
124+
local result = child.lua([[
125+
local Transport = require("agentic.acp.acp_transport")
126+
_G.states = {}
127+
_G.reconnects = 0
128+
_G.transport = Transport.create_stdio_transport({
129+
command = "/bin/cat",
130+
}, {
131+
on_state_change = function(s)
132+
table.insert(_G.states, s)
133+
end,
134+
on_message = function() end,
135+
on_reconnect = function()
136+
_G.reconnects = _G.reconnects + 1
137+
end,
138+
})
139+
140+
_G.transport:start() -- child A
141+
local pid_a = _G.transport.pid
142+
_G.transport:stop() -- kill A
143+
_G.transport:start() -- child B, same transport object
144+
local pid_b = _G.transport.pid
145+
146+
-- Let any stale callback from A fire before we assert.
147+
vim.wait(800)
148+
149+
local function alive(pid)
150+
vim.fn.system({ "kill", "-0", tostring(pid) })
151+
return vim.v.shell_error == 0
152+
end
153+
154+
return {
155+
final_state = _G.states[#_G.states],
156+
reconnects = _G.reconnects,
157+
different_pid = pid_a ~= pid_b,
158+
b_alive = alive(pid_b),
159+
}
160+
]])
161+
162+
assert.is_true(result.different_pid)
163+
assert.equal("connected", result.final_state)
164+
assert.equal(0, result.reconnects)
165+
assert.is_true(result.b_alive)
166+
167+
child.lua([[ _G.transport:stop() ]])
168+
end
169+
)
170+
end)
171+
104172
describe("ACPTransport.decode_line", function()
105173
local Transport = require("agentic.acp.acp_transport")
106174

lua/agentic/init.lua

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,15 @@ function Agentic.restore_session()
315315
end)
316316
end
317317

318+
--- Recover from an unresponsive agent: respawn the agent process and reload the
319+
--- current session in place, without quitting Neovim. Use when the agent stops
320+
--- responding (e.g. after the machine resumes from suspend).
321+
function Agentic.reconnect()
322+
SessionRegistry.get_session_for_tab_page(nil, function(session)
323+
session:reconnect()
324+
end)
325+
end
326+
318327
--- Restore a session by its ID.
319328
--- @param session_id string
320329
function Agentic.restore_session_by_id(session_id)

lua/agentic/session_manager.lua

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1383,6 +1383,36 @@ function SessionManager:destroy()
13831383
end
13841384
end
13851385

1386+
--- Recover from an unresponsive agent without quitting Neovim: kill and
1387+
--- respawn the agent process, then reload the current session so the
1388+
--- conversation continues. This is the in-place equivalent of quitting Neovim
1389+
--- and restoring the session, the only recovery available when the child is
1390+
--- hung (e.g. after the machine resumes from suspend with a dead connection).
1391+
---
1392+
--- Note: the agent process is shared per provider, so this also resets other
1393+
--- tabs' sessions on the same provider; they re-establish on their next use.
1394+
function SessionManager:reconnect()
1395+
local session_id = self.session_id
1396+
1397+
-- Reset the local UI immediately. The respawn also drains the dead request
1398+
-- callbacks, but don't make the user wait for that to see the spinner stop.
1399+
self.is_generating = false
1400+
self.status_animation:stop()
1401+
1402+
Logger.notify("Reconnecting to agent…", vim.log.levels.INFO)
1403+
1404+
self.agent:reconnect()
1405+
1406+
self.agent:when_ready(function()
1407+
local caps = self.agent.agent_capabilities
1408+
if session_id and caps and caps.loadSession then
1409+
self:load_acp_session(session_id)
1410+
else
1411+
self:new_session()
1412+
end
1413+
end)
1414+
end
1415+
13861416
--- Load an existing ACP session by ID, subscribing to its updates
13871417
--- @param session_id string
13881418
--- @param title string|nil

lua/agentic/session_manager.test.lua

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
--- @diagnostic disable: invisible, missing-fields, assign-type-mismatch, cast-local-type, param-type-mismatch
1+
--- @diagnostic disable: invisible, missing-fields, assign-type-mismatch, cast-local-type, param-type-mismatch, return-type-mismatch
22
local assert = require("tests.helpers.assert")
33
local spy = require("tests.helpers.spy")
44

@@ -1757,4 +1757,74 @@ describe("agentic.SessionManager", function()
17571757
handlers.on_request_permission(mock_request, mock_callback)
17581758
end)
17591759
end)
1760+
1761+
describe("reconnect", function()
1762+
--- @type TestStub
1763+
local notify_stub
1764+
1765+
before_each(function()
1766+
notify_stub = spy.stub(Logger, "notify")
1767+
end)
1768+
1769+
after_each(function()
1770+
notify_stub:revert()
1771+
end)
1772+
1773+
--- @param overrides table
1774+
--- @return agentic.SessionManager session, table calls
1775+
local function make_session(overrides)
1776+
local calls =
1777+
{ reconnected = false, loaded_with = nil, new = false }
1778+
local session = {
1779+
session_id = overrides.session_id,
1780+
is_generating = true,
1781+
status_animation = { stop = function() end },
1782+
agent = {
1783+
reconnect = function()
1784+
calls.reconnected = true
1785+
end,
1786+
-- Fire the ready callback synchronously.
1787+
when_ready = function(_self, cb)
1788+
cb()
1789+
end,
1790+
agent_capabilities = overrides.caps,
1791+
},
1792+
reconnect = SessionManager.reconnect,
1793+
load_acp_session = function(_self, id)
1794+
calls.loaded_with = id
1795+
end,
1796+
new_session = function()
1797+
calls.new = true
1798+
end,
1799+
} --[[@as agentic.SessionManager]]
1800+
return session, calls
1801+
end
1802+
1803+
it("respawns the agent and reloads the current session", function()
1804+
local session, calls = make_session({
1805+
session_id = "sess-1",
1806+
caps = { loadSession = true },
1807+
})
1808+
1809+
session:reconnect()
1810+
1811+
assert.is_true(calls.reconnected)
1812+
assert.is_false(session.is_generating)
1813+
assert.equal("sess-1", calls.loaded_with)
1814+
assert.is_false(calls.new)
1815+
end)
1816+
1817+
it("starts a fresh session when there is none to reload", function()
1818+
local session, calls = make_session({
1819+
session_id = nil,
1820+
caps = { loadSession = true },
1821+
})
1822+
1823+
session:reconnect()
1824+
1825+
assert.is_true(calls.reconnected)
1826+
assert.is_nil(calls.loaded_with)
1827+
assert.is_true(calls.new)
1828+
end)
1829+
end)
17601830
end)

0 commit comments

Comments
 (0)