diff --git a/dist/server.js b/dist/server.js index 9c70d6e..cd3252c 100644 --- a/dist/server.js +++ b/dist/server.js @@ -4420,12 +4420,10 @@ async function setupV2(context) { const pursue = command.action === "resume" || command.action === "goal" && args !== "" && !controlOnly; const signal = execution?.signal ? AbortSignal.any([abortController.signal, execution.signal]) : abortController.signal; let admitted = false; - let cancellation; const cancel = () => { if (!pursue || disposed) return; goalServices.stopAutonomy?.(input.sessionID); - cancellation = cancelActiveGoal(input.sessionID).catch((error) => v2ErrorLog("Failed to persist command cancellation", error)); }; execution?.signal?.addEventListener("abort", cancel, { once: true }); try { @@ -4470,7 +4468,6 @@ async function setupV2(context) { } } finally { execution?.signal?.removeEventListener("abort", cancel); - await cancellation; } } }); @@ -4503,6 +4500,18 @@ async function setupV2(context) { } })); } + try { + const hookInterrupt = context.session.hook; + registrations.push(await hookInterrupt("interrupt", async ({ sessionID }) => { + markSessionOwnership(sessionID, true); + goalServices.stopAutonomy?.(sessionID); + try { + await cancelActiveGoal(sessionID); + } catch (error) { + v2ErrorLog("Failed to persist explicit session cancellation", error); + } + })); + } catch {} registrations.push(await context.tool.transform((draft) => { for (const tool of goalToolsV2(goalServices)) draft.add(tool); diff --git a/src/server.ts b/src/server.ts index 324af44..1941648 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2969,13 +2969,10 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise | undefined const cancel = () => { if (!pursue || disposed) return goalServices.stopAutonomy?.(input.sessionID) - cancellation = cancelActiveGoal(input.sessionID).catch((error) => - v2ErrorLog("Failed to persist command cancellation", error), - ) + // Transport disposal stops local autonomy without closing the goal. } execution?.signal?.addEventListener("abort", cancel, { once: true }) try { @@ -3024,7 +3021,6 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise Promise, + ) => Promise<{ dispose(): Promise }> + registrations.push(await hookInterrupt("interrupt", async ({ sessionID }) => { + markSessionOwnership(sessionID, true) + goalServices.stopAutonomy?.(sessionID) + try { + await cancelActiveGoal(sessionID) + } catch (error) { + v2ErrorLog("Failed to persist explicit session cancellation", error) + } + })) + } catch { + // Host predates the explicit session interruption hook. + } + registrations.push( await context.tool.transform((draft) => { for (const tool of goalToolsV2(goalServices)) draft.add(tool) diff --git a/test/server-v2.test.ts b/test/server-v2.test.ts index 290cf1b..c12c58a 100644 --- a/test/server-v2.test.ts +++ b/test/server-v2.test.ts @@ -1807,6 +1807,7 @@ test("V2 cleanup disposes registrations and stops the event consumer", async () expect.arrayContaining([ "command.transform", "session.hook:prompt", + "session.hook:interrupt", "tool.transform", "tool.hook:execute.before", "tool.hook:execute.after", @@ -2465,6 +2466,7 @@ test("V2 command cancellation between idle cycles persists cancellation and stop { signal: controller.signal }, ) await waitFor(() => waits > 0) + await mock.hooks.interrupt!({ sessionID: "ses_v2" }) controller.abort() await command expect((await getGoal("ses_v2"))?.status).toBe("cancelled") @@ -2524,3 +2526,17 @@ test("V2 a stopped event stream pauses a waiting goal rather than polling foreve .execute({ sessionID: "ses_v2", prompt: { text: "Continue the goal" }, delivery: "steer" }) expect((await getGoal("ses_v2"))?.status).toBe("paused") }) + +test("V2 a command transport abort preserves the active goal for reconnection", async () => { + const mock = makeMockContext() + let waits = 0 + const context = { ...mock, session: { ...mock.session, wait: async () => { waits++ } } } + await setupPlugin(context as never) + await createGoalViaV2Tool(mock, "Preserve scope after disconnect") + const controller = new AbortController() + const command = mock.commands.find((command) => command.name === "goal")!.execute({ sessionID: "ses_v2", prompt: { text: "Preserve scope after disconnect" }, delivery: "steer" }, { signal: controller.signal }) + await waitFor(() => waits > 0) + controller.abort() + await command + expect((await getGoal("ses_v2"))?.status).toBe("active") +})