mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56f4af0436 | ||
|
|
9072bce0c3 | ||
|
|
8e5f536766 | ||
|
|
6bc4b73c42 | ||
|
|
631ed0408d | ||
|
|
6521739947 | ||
|
|
6f07b0de48 |
+48
-2
@@ -25,8 +25,54 @@ Reload releases the old runtime before resuming its durable session: an idle pro
|
||||
still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the
|
||||
replacement. Once closure succeeds, a failed resume leaves the durable agent closed and retryable.
|
||||
|
||||
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
|
||||
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
|
||||
Idle resumable runtimes are collected after one hour without activity, checked once a minute.
|
||||
Collection releases the process and preserves the agent record, conversation, relationships, and
|
||||
provider session. The next prompt resumes that session. Set `agents.idleRuntimeCleanup: false` in
|
||||
host config to disable automatic collection. The host setting applies immediately; config-file edits
|
||||
apply on reload. Disabling invalidates pending safety checks and close admission. A close already
|
||||
started finishes. Re-enabling keeps the existing idle timestamps.
|
||||
|
||||
Foreground runs, permissions and responses, queued deliveries, lifecycle operations, and working
|
||||
managed or native descendants prevent collection. Outstanding local or remote finish subscriptions
|
||||
also protect their caller, including registration, recovery and notification dispatch acknowledgment.
|
||||
An idle managed child can still own background work.
|
||||
|
||||
The daemon baseline always applies. An optional provider `canStopRuntime()` adds a veto: false,
|
||||
errors and timeouts retain ownership, while true cannot override daemon blockers. Without a provider
|
||||
check, the baseline decides collection after the same one-hour idle period. Once protected work
|
||||
finishes, a fresh idle hour starts. There is no deadline that overrides a veto.
|
||||
|
||||
The `idle-runtime-cleanup/` module owns the clocks, eligibility policy and sweep lifetime.
|
||||
AgentManager supplies captured runtime capabilities and owns atomic revalidation and shutdown.
|
||||
Finish subscriptions expose caller protection through their public capability; the collector never
|
||||
reads their watches or durable records.
|
||||
|
||||
The collector rechecks daemon state after asynchronous probes and event draining, in the existing
|
||||
close/load lifecycle lane. A prompt admitted before closure prevents collection. A prompt arriving
|
||||
after closure starts waits for release and resumes the saved session.
|
||||
|
||||
Provider checks follow the signals their runtimes expose:
|
||||
|
||||
- Claude uses its process-local background roster and pending completion notifications. A fresh
|
||||
process starts empty. CLI 2.1.292 or newer is known to support the roster; older binaries must
|
||||
demonstrate support by emitting one. Unknown versions stay resident until then.
|
||||
- Codex queries background terminals for loaded threads, including native children, and protects
|
||||
pending provider requests. App servers without the terminal-list RPC stay resident.
|
||||
- OpenCode checks active executions and descendants. V1 checks PTYs; v2 also checks shell and
|
||||
inbox inventories. The shared server remains leased while a session vetoes collection.
|
||||
- Pi checks streaming, compaction, pending replies, and adapter-owned operations. Known native
|
||||
subagent signals also protect their parent through the daemon's subagent store.
|
||||
- OMP additionally requires `get_state.isSettled: true` and `hasPendingAsyncWork: false`.
|
||||
These protect jobs with unsuppressed completion delivery, pending deliveries, queued result
|
||||
injection, and admitted or scheduled continuations that can re-wake the session. Older OMP
|
||||
runtimes without either field stay resident. These signals are not an inventory of arbitrary
|
||||
processes.
|
||||
- ACP checks adapter-owned foreground turns, permissions, buffered user messages, and running
|
||||
ACP terminals, including quiet terminals. Exited or failed-to-spawn terminal records do not
|
||||
prevent collection. This does not inspect processes launched internally by the provider.
|
||||
|
||||
These checks do not discover arbitrary detached processes or work hidden by third-party
|
||||
extensions. A provider plugin adds its own veto through the [runtime safety contract](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
|
||||
|
||||
A provider runtime can still die on its own — crash, OOM kill, host suspend. Work the agent parked
|
||||
inside that process dies with it: Claude Code's background Bash shells, `Monitor` watches, and
|
||||
|
||||
@@ -498,6 +498,11 @@ Validate and apply it inside the provider; core does not know your option shape.
|
||||
See [provider options](custom-providers.md#provider-options) for configuration and
|
||||
merge semantics.
|
||||
|
||||
Providers add an optional veto to daemon baseline cleanup through the per-session
|
||||
[runtime safety query](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
|
||||
The daemon calls the same `AgentSession.canStopRuntime()` interface for built-ins and plugins;
|
||||
the plugin bridge transports that query through the existing provider request/event protocol.
|
||||
|
||||
Agent refresh closes the current provider session and opens it again with current configuration and
|
||||
persistence. Re-read credentials and provider-owned configuration on `session.open`; consume the
|
||||
daemon launch from `connect` and the per-session env and MCP servers from `session.open`. There is
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerDraft,
|
||||
expectComposerVisible,
|
||||
submitMessage,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
function userMessages(page: Page) {
|
||||
return page.getByTestId("user-message");
|
||||
}
|
||||
|
||||
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
|
||||
await submitMessage(page, text);
|
||||
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
|
||||
await expectComposerDraft(page, "");
|
||||
}
|
||||
|
||||
async function pressInComposer(page: Page, key: string) {
|
||||
await composerLocator(page).press(key);
|
||||
}
|
||||
|
||||
test.describe("Composer recall", () => {
|
||||
test("Up and Down walk sent messages from an empty composer", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-recall-",
|
||||
title: "Composer recall",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await sendAndWaitForMessage(page, "fix the auth bug", 1);
|
||||
await sendAndWaitForMessage(page, "add tests", 2);
|
||||
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "fix the auth bug");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "");
|
||||
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await typeIntoFocusedComposer(page, " for login");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
|
||||
await pressInComposer(page, "Enter");
|
||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a recalled slash message does not open the command picker", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-recall-slash-",
|
||||
title: "Composer recall slash",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await sendAndWaitForMessage(page, "/tdd", 1);
|
||||
|
||||
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "/tdd");
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await typeIntoFocusedComposer(page, "x");
|
||||
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,7 @@
|
||||
import { readFile, mkdir, rename, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
@@ -25,6 +29,8 @@ import {
|
||||
removeHostFromHostPage,
|
||||
addDirectHostFromSettings,
|
||||
expectHostOnlineWithoutError,
|
||||
expectIdleAgentRelease,
|
||||
setIdleAgentRelease,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
test.describe("Settings host page", () => {
|
||||
@@ -137,6 +143,100 @@ test.describe("Settings host page", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("idle agent release changes live on one host and stays saved without restarting", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const primaryServerId = getServerId();
|
||||
const daemon = await startIsolatedHostDaemon("srv_e2e_idle_agent_release");
|
||||
const client = await connectDaemonClient<DaemonClient>({
|
||||
clientIdPrefix: "idle-release-settings",
|
||||
port: daemon.port,
|
||||
});
|
||||
const originalPid = (await client.getDaemonStatus()).pid;
|
||||
const expectLiveSavedSetting = async (enabled: boolean) => {
|
||||
const saved = JSON.parse(await readFile(path.join(daemon.paseoHome, "config.json"), "utf8"));
|
||||
expect(saved.agents.idleRuntimeCleanup).toBe(enabled);
|
||||
expect((await client.getDaemonConfig()).config.idleRuntimeCleanup).toBe(enabled);
|
||||
expect((await client.reloadDaemonConfig()).restartRequiredPaths).toEqual([]);
|
||||
expect((await client.getDaemonStatus()).pid).toBe(originalPid);
|
||||
};
|
||||
const hosts = [
|
||||
{
|
||||
serverId: primaryServerId,
|
||||
label: TEST_HOST_LABEL,
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: daemon.serverId,
|
||||
label: "Idle release host",
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
},
|
||||
];
|
||||
const openHostAgentsInFreshApp = async (serverId: string) => {
|
||||
await seedSavedSettingsHosts(page, hosts);
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
await selectSettingsHost(page, serverId);
|
||||
await openHostSection(page, serverId, "agents");
|
||||
};
|
||||
try {
|
||||
await test.step("idle agent release starts on and turns off", async () => {
|
||||
await openHostAgentsInFreshApp(daemon.serverId);
|
||||
await expectIdleAgentRelease(page, true);
|
||||
await setIdleAgentRelease(page, false);
|
||||
await expectLiveSavedSetting(false);
|
||||
});
|
||||
|
||||
await test.step("a fresh app reads the saved setting from the same running host", async () => {
|
||||
await openHostAgentsInFreshApp(daemon.serverId);
|
||||
await expectIdleAgentRelease(page, false);
|
||||
await expectLiveSavedSetting(false);
|
||||
await setIdleAgentRelease(page, true);
|
||||
await expectLiveSavedSetting(true);
|
||||
await setIdleAgentRelease(page, false);
|
||||
});
|
||||
|
||||
await test.step("other hosts keep their own setting", async () => {
|
||||
await selectSettingsHost(page, primaryServerId);
|
||||
await openHostSection(page, primaryServerId, "agents");
|
||||
await expectIdleAgentRelease(page, true);
|
||||
await expectLiveSavedSetting(false);
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a failed idle release save stays visible and retry applies it live", async ({ page }) => {
|
||||
const daemon = await startIsolatedHostDaemon("srv_e2e_idle_release_failure");
|
||||
const configPath = path.join(daemon.paseoHome, "config.json");
|
||||
try {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{ serverId: daemon.serverId, label: "Failure host", endpoint: `127.0.0.1:${daemon.port}` },
|
||||
]);
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
await openHostSection(page, daemon.serverId, "agents");
|
||||
await expectIdleAgentRelease(page, true);
|
||||
// A real filesystem failure, with the daemon and RPC path intact.
|
||||
await rename(configPath, `${configPath}.saved`);
|
||||
await mkdir(configPath);
|
||||
await page.getByRole("switch", { name: "Release idle agents" }).click();
|
||||
await expect(page.getByTestId("host-page-idle-runtime-cleanup-error")).toContainText(
|
||||
"Failed to read",
|
||||
);
|
||||
await expectIdleAgentRelease(page, true);
|
||||
await rm(configPath, { recursive: true });
|
||||
await rename(`${configPath}.saved`, configPath);
|
||||
await setIdleAgentRelease(page, false);
|
||||
await expect(page.getByTestId("host-page-idle-runtime-cleanup-error")).toHaveCount(0);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
addDirectHostFromSettings,
|
||||
clickSettingsBackToWorkspace,
|
||||
openSettingsHostSection,
|
||||
openSettingsSection,
|
||||
selectSettingsHost,
|
||||
} from "../support/helpers/settings";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
|
||||
|
||||
@@ -44,6 +47,14 @@ test.describe("Settings host selection", () => {
|
||||
await openSettings(page);
|
||||
|
||||
await openSettingsHostSection(page, remoteDaemon.serverId, "connections");
|
||||
|
||||
await selectSettingsHost(page, getServerId());
|
||||
await openSettingsSection(page, "appearance");
|
||||
await openSettingsHostSection(page, getServerId(), "connections");
|
||||
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, remoteDaemon.serverId, "connections");
|
||||
} finally {
|
||||
await remoteWorkspace.cleanup().catch(() => undefined);
|
||||
await remoteDaemon.close().catch(() => undefined);
|
||||
|
||||
@@ -147,6 +147,28 @@ test.describe("Settings sidebar navigation", () => {
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
test("host choice survives app sections and host screen replacements", async ({ page }) => {
|
||||
const primaryServerId = getServerId();
|
||||
const secondaryServerId = "srv_e2e_settings_secondary";
|
||||
const endpoint = `127.0.0.1:${getE2EDaemonPort()}`;
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{ serverId: primaryServerId, label: "First horse", endpoint },
|
||||
{ serverId: secondaryServerId, label: "Stable horse", endpoint },
|
||||
]);
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await selectSettingsHost(page, secondaryServerId);
|
||||
await openSettingsHostSection(page, secondaryServerId, "connections");
|
||||
await openSettingsSection(page, "appearance");
|
||||
await expectSettingsHostPickerLabel(page, "Stable horse");
|
||||
await openSettingsSection(page, "general");
|
||||
await expectSettingsHostPickerLabel(page, "Stable horse");
|
||||
await openSettingsHostSection(page, secondaryServerId, "connections");
|
||||
await selectSettingsHost(page, primaryServerId);
|
||||
await openSettingsSection(page, "about");
|
||||
await expectSettingsHostPickerLabel(page, "First horse");
|
||||
});
|
||||
|
||||
test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await verifyLegacyHostSettingsRedirect(page);
|
||||
@@ -329,6 +351,15 @@ test.describe("Settings — compact master-detail", () => {
|
||||
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
|
||||
|
||||
await openSettingsHostSection(page, secondaryServerId, "connections");
|
||||
await goBackInSettings(page);
|
||||
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
|
||||
|
||||
await openSettingsSection(page, "appearance");
|
||||
await goBackInSettings(page);
|
||||
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
|
||||
await openSettingsHostSection(page, secondaryServerId, "connections");
|
||||
await goBackInSettings(page);
|
||||
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
|
||||
});
|
||||
|
||||
test("removing the last active host returns to welcome after settings closes", async ({
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Inline code formatting
|
||||
|
||||
This fixture renders the production `MarkdownRenderer` with plain, bold, italic,
|
||||
combined emphasis, strikethrough and linked code, plus bold code in headings,
|
||||
lists and quotes. Toggle regular/compact text. Inline code uses the theme’s
|
||||
code-to-body size ratio at every heading level; fenced code keeps the code-size
|
||||
token. Bold and links preserve the resulting size. Check the glyphs, not the background height: native backgrounds use
|
||||
line metrics.
|
||||
|
||||
Use a development client rebuilt from this checkout. Follow the temporary entry
|
||||
point and Metro workflow in [status-ring](../status-ring/README.md), importing
|
||||
`MarkdownFormattingRegression` from `./e2e/mobile/markdown-formatting/native`.
|
||||
Open the same Metro URL in a browser to compare web. Restore `index.ts` afterward.
|
||||
|
||||
The package patch contains `InlineCodeFormattingTest`, which exercises actual
|
||||
Android spans in Robolectric native graphics mode, covering draw and measurement,
|
||||
span order, code inside paragraph/heading sizes, bold/italic composition, and
|
||||
links preserving code and emphasized prose fonts.
|
||||
It runs in the existing `test:android` CI job. To run only this regression locally,
|
||||
from `packages/app/android` after Expo prebuild:
|
||||
|
||||
```sh
|
||||
./gradlew :react-native-enriched-markdown:testDebugUnitTest \
|
||||
--tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2
|
||||
```
|
||||
@@ -0,0 +1,52 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Button, ScrollView, Text } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: { flex: 1 },
|
||||
scroll: { backgroundColor: theme.colors.surface0 },
|
||||
content: { padding: 32, paddingTop: 60 },
|
||||
title: { color: theme.colors.foreground },
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const source = [
|
||||
"Plain: `MMMMiiii`",
|
||||
"Bold: **`MMMMiiii`**",
|
||||
"Italic: *`MMMMiiii`*",
|
||||
"Both: ***`MMMMiiii`***",
|
||||
"Strike: ~~`MMMMiiii`~~",
|
||||
"Link: [`MMMMiiii`](https://example.com)",
|
||||
"# Heading **`MMMMiiii`**",
|
||||
"## Heading *`MMMMiiii`*",
|
||||
"### Heading [`MMMMiiii`](https://example.com)",
|
||||
"#### Heading ~~`MMMMiiii`~~",
|
||||
"##### Heading ***`MMMMiiii`***",
|
||||
"###### Heading `MMMMiiii`",
|
||||
"- List **`MMMMiiii`**",
|
||||
"> Quote **`MMMMiiii`**",
|
||||
"Normal **bold** and *italic* prose.",
|
||||
].join("\n\n");
|
||||
|
||||
export default function MarkdownFormattingRegression() {
|
||||
const [compact, setCompact] = useState(false);
|
||||
const toggleCompact = useCallback(() => setCompact((value) => !value), []);
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
|
||||
<Text style={styles.title}>Inline code formatting regression</Text>
|
||||
<Button
|
||||
title={compact ? "Use regular text" : "Use compact text"}
|
||||
onPress={toggleCompact}
|
||||
/>
|
||||
<MarkdownRenderer text={source} compact={compact} />
|
||||
</ScrollView>
|
||||
</GestureHandlerRootView>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -326,6 +326,25 @@ export async function expectHostInjectMcpCard(page: Page): Promise<void> {
|
||||
await expect(card.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
|
||||
}
|
||||
|
||||
function idleAgentReleaseSwitch(page: Page) {
|
||||
return page
|
||||
.getByTestId("host-page-idle-runtime-cleanup-card")
|
||||
.getByRole("switch", { name: "Release idle agents" });
|
||||
}
|
||||
|
||||
export async function expectIdleAgentRelease(page: Page, enabled: boolean): Promise<void> {
|
||||
const toggle = idleAgentReleaseSwitch(page);
|
||||
await expect(toggle).toBeEnabled();
|
||||
await expect(toggle).toHaveAttribute("aria-checked", String(enabled));
|
||||
}
|
||||
|
||||
export async function setIdleAgentRelease(page: Page, enabled: boolean): Promise<void> {
|
||||
const toggle = idleAgentReleaseSwitch(page);
|
||||
await expect(toggle).toHaveAttribute("aria-checked", String(!enabled));
|
||||
await toggle.click();
|
||||
await expectIdleAgentRelease(page, enabled);
|
||||
}
|
||||
|
||||
export async function openHostSection(
|
||||
page: Page,
|
||||
serverId: string,
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
|
||||
"build:apk": "npm --prefix ../.. run build:apk",
|
||||
"build:apk:arm64": "npm --prefix ../.. run build:apk:arm64",
|
||||
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
|
||||
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest :react-native-enriched-markdown:testReleaseUnitTest --tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
type StartupBlocker,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
||||
import { useSettingsRouteEntry } from "@/navigation/settings-selection";
|
||||
import { ThemedStack } from "@/navigation/themed-stack";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { AgentNavigationListener } from "@/desktop/agent-navigation";
|
||||
@@ -759,6 +760,7 @@ function OpenProjectListener() {
|
||||
}
|
||||
|
||||
function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
useSettingsRouteEntry();
|
||||
const pathname = usePathname();
|
||||
const hosts = useHosts();
|
||||
const storeReady = useStoreReady();
|
||||
|
||||
@@ -804,3 +804,18 @@ it("makes the web Markdown container selectable in Safari as well as unprefixed
|
||||
WebkitUserSelect: "text",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"scales inline code from theme tokens with its surrounding block (compact: %s)",
|
||||
(compact) => {
|
||||
const theme = {
|
||||
...darkTheme,
|
||||
fontSize: { ...darkTheme.fontSize, code: 13, content: 17, base: 16 },
|
||||
};
|
||||
const { markdownStyle } = createMarkdownStyles(theme, { compact, blockGap: 16 });
|
||||
const bodySize = compact ? theme.fontSize.base : theme.fontSize.content;
|
||||
expect(markdownStyle.code).not.toHaveProperty("fontSize");
|
||||
expect(markdownStyle.code).toHaveProperty("fontScale", theme.fontSize.code / bodySize);
|
||||
expect(markdownStyle.codeBlock?.fontSize).toBe(theme.fontSize.code);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -72,7 +72,7 @@ export function createMarkdownStyles(
|
||||
},
|
||||
code: {
|
||||
fontFamily: theme.fontFamily.mono === "ui-monospace" && !isWeb ? "" : theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
fontScale: theme.fontSize.code / fontSize,
|
||||
color: theme.colors.foreground,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderColor: "transparent",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { createWorkspaceVisibilityPolicy } from "@/components/sidebar/sidebar-group-visibility";
|
||||
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages";
|
||||
@@ -486,6 +487,20 @@ describe("sidebar group visibility", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("treats placements without a hydrated entry as Done", () => {
|
||||
const items = entries([...Array(8).fill("done"), "running", "done"]);
|
||||
const hydrated = new Map(items.slice(0, 9).map((item) => [item.workspaceKey, item]));
|
||||
for (const entriesByKey of [new Map(), hydrated]) {
|
||||
const { result } = renderHook(() =>
|
||||
useLimitedSidebarGroup(items, createWorkspaceVisibilityPolicy(entriesByKey)),
|
||||
);
|
||||
expect(result.current.canToggle).toBe(true);
|
||||
expect(names(result.current.visibleItems)).toEqual(
|
||||
entriesByKey.size === 0 ? names(items.slice(0, 7)) : [...names(items.slice(0, 7)), "row-9"],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("shows eight active rows once with no redundant toggle", () => {
|
||||
const items = entries([
|
||||
"running",
|
||||
|
||||
@@ -99,6 +99,7 @@ import {
|
||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
|
||||
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
|
||||
import { createWorkspaceVisibilityPolicy } from "@/components/sidebar/sidebar-group-visibility";
|
||||
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
|
||||
import {
|
||||
SidebarWorkspaceRowFrame,
|
||||
@@ -1593,11 +1594,7 @@ function ProjectBlock({
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
}) {
|
||||
const visibilityPolicy = useMemo(
|
||||
() => ({
|
||||
kind: "workspaces" as const,
|
||||
getStatusBucket: (workspace: SidebarWorkspacePlacement) =>
|
||||
workspaceEntriesByKey.get(workspace.workspaceKey)!.statusBucket,
|
||||
}),
|
||||
() => createWorkspaceVisibilityPolicy<SidebarWorkspacePlacement>(workspaceEntriesByKey),
|
||||
[workspaceEntriesByKey],
|
||||
);
|
||||
const {
|
||||
|
||||
@@ -4,6 +4,18 @@ export type SidebarGroupVisibilityPolicy<T> =
|
||||
| { kind: "pinned" }
|
||||
| { kind: "workspaces"; getStatusBucket: (item: T) => SidebarStateBucket };
|
||||
|
||||
// A placement can outrun its entry: a retained sidebar that is inactive (compact layout with the
|
||||
// sidebar closed) or still waiting for sessions keeps an empty entry map. A missing entry renders
|
||||
// no row, so it counts as Done and claims no slot past the limit.
|
||||
export function createWorkspaceVisibilityPolicy<T extends { workspaceKey: string }>(
|
||||
entriesByKey: ReadonlyMap<string, { statusBucket: SidebarStateBucket }>,
|
||||
): SidebarGroupVisibilityPolicy<T> {
|
||||
return {
|
||||
kind: "workspaces",
|
||||
getStatusBucket: (item) => entriesByKey.get(item.workspaceKey)?.statusBucket ?? "done",
|
||||
};
|
||||
}
|
||||
|
||||
export function selectSidebarGroupVisibility<T>(
|
||||
items: readonly T[],
|
||||
expanded: boolean,
|
||||
|
||||
@@ -55,7 +55,11 @@ import {
|
||||
} from "@/composer/agent-controls";
|
||||
import { ContextWindowMeter } from "@/components/context-window-meter";
|
||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||
import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
selectAgentSentMessages,
|
||||
selectAgentTurnPresentation,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import { useFilePicker } from "@/hooks/use-file-picker";
|
||||
import { useFileDrop } from "@/components/file-drop/use-file-drop";
|
||||
import type { DroppedItem } from "@/components/file-drop/types";
|
||||
@@ -118,6 +122,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
import { submitAgentInput } from "@/composer/submit";
|
||||
import { ComposerRecall } from "@/composer/recall";
|
||||
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
|
||||
import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
@@ -995,6 +1000,7 @@ interface ComposerAutocompleteHandle {
|
||||
function ComposerAutocompleteBinding({
|
||||
text,
|
||||
cursor,
|
||||
recall,
|
||||
configuration,
|
||||
inputRef,
|
||||
anchorRef,
|
||||
@@ -1003,6 +1009,7 @@ function ComposerAutocompleteBinding({
|
||||
}: {
|
||||
text: ComposerTextSource;
|
||||
cursor: StoreApi<number>;
|
||||
recall: ComposerRecall;
|
||||
configuration: Omit<
|
||||
Parameters<typeof useAgentAutocomplete>[0],
|
||||
"userInput" | "cursorIndex" | "onAutocompleteApplied"
|
||||
@@ -1020,9 +1027,11 @@ function ComposerAutocompleteBinding({
|
||||
cursorIndex: Math.min(cursorIndex, userInput.length),
|
||||
onAutocompleteApplied: () => inputRef.current?.focus(),
|
||||
});
|
||||
// A recalled message is sent as is; its leading `/command` must not open the picker.
|
||||
const isShown = show && !recall.isShowing(userInput);
|
||||
const handleKeyPress = autocomplete.onKeyPress;
|
||||
useImperativeHandle(ref, () => ({ onKeyPress: (event) => show && handleKeyPress(event) }), [
|
||||
show,
|
||||
useImperativeHandle(ref, () => ({ onKeyPress: (event) => isShown && handleKeyPress(event) }), [
|
||||
isShown,
|
||||
handleKeyPress,
|
||||
]);
|
||||
const selectOption = autocomplete.onSelectOption;
|
||||
@@ -1032,7 +1041,7 @@ function ComposerAutocompleteBinding({
|
||||
);
|
||||
return (
|
||||
<ComposerAutocomplete
|
||||
visible={autocomplete.isVisible && show}
|
||||
visible={autocomplete.isVisible && isShown}
|
||||
anchorRef={anchorRef}
|
||||
options={autocomplete.options}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
@@ -1362,6 +1371,7 @@ function ComposerContentImpl({
|
||||
);
|
||||
useEffect(() => () => cursorPublication.cancel(), [cursorPublication]);
|
||||
const autocompleteRef = useRef<ComposerAutocompleteHandle>(null);
|
||||
const recall = useMemo(() => new ComposerRecall(), []);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [pendingFiles, setPendingFiles] = useState<PendingFileAttachment[]>([]);
|
||||
const nextPendingFileId = useRef(0);
|
||||
@@ -1981,10 +1991,19 @@ function ComposerContentImpl({
|
||||
|
||||
const hasSendableContent = hasText || selectedAttachments.length > 0;
|
||||
|
||||
// Handle keyboard navigation for command autocomplete.
|
||||
// An open autocomplete owns the arrows; otherwise they may recall sent messages.
|
||||
const handleCommandKeyPress = useCallback(
|
||||
(event: ComposerKeyPressEvent) => autocompleteRef.current?.onKeyPress(event) ?? false,
|
||||
[],
|
||||
(event: ComposerKeyPressEvent) => {
|
||||
if (autocompleteRef.current?.onKeyPress(event)) return true;
|
||||
const recalledText = recall.handleKeyPress(event, () =>
|
||||
selectAgentSentMessages(useSessionStore.getState().sessions[serverId], agentId),
|
||||
);
|
||||
if (recalledText === null) return false;
|
||||
event.preventDefault();
|
||||
replaceUserInput(recalledText);
|
||||
return true;
|
||||
},
|
||||
[agentId, recall, replaceUserInput, serverId],
|
||||
);
|
||||
|
||||
const cancelButtonStyle = useMemo(
|
||||
@@ -2423,6 +2442,7 @@ function ComposerContentImpl({
|
||||
<ComposerAutocompleteBinding
|
||||
text={textSource}
|
||||
cursor={cursor}
|
||||
recall={recall}
|
||||
inputRef={messageInputRef}
|
||||
anchorRef={messageInputContainerRef}
|
||||
show={mode.showAutocomplete && !isFullscreen}
|
||||
|
||||
@@ -110,6 +110,8 @@ export interface ComposerInputSnapshot {
|
||||
|
||||
export interface ComposerKeyPressEvent {
|
||||
key: string;
|
||||
/** Shift, Alt, Ctrl, or Meta was held. */
|
||||
hasModifier: boolean;
|
||||
preventDefault: () => void;
|
||||
input: ComposerInputSnapshot;
|
||||
}
|
||||
@@ -210,6 +212,7 @@ type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
||||
metaKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
altKey?: boolean;
|
||||
// Web-only: present on DOM KeyboardEvent during IME composition (CJK input).
|
||||
isComposing?: boolean;
|
||||
keyCode?: number;
|
||||
@@ -411,17 +414,18 @@ function handleDesktopKeyPressImpl(
|
||||
): void {
|
||||
if (isImeComposingKeyboardEvent(event.nativeEvent)) return;
|
||||
|
||||
const { shiftKey, metaKey, ctrlKey, altKey } = event.nativeEvent;
|
||||
|
||||
if (ctx.onKeyPressCallback) {
|
||||
const handled = ctx.onKeyPressCallback({
|
||||
key: event.nativeEvent.key,
|
||||
hasModifier: Boolean(shiftKey || metaKey || ctrlKey || altKey),
|
||||
preventDefault: () => event.preventDefault(),
|
||||
input: ctx.input,
|
||||
});
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
const { shiftKey, metaKey, ctrlKey } = event.nativeEvent;
|
||||
|
||||
if (event.nativeEvent.key !== "Enter") return;
|
||||
if (!ctx.submitOnEnter) return;
|
||||
if (shiftKey) return;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ComposerKeyPressEvent } from "@/composer/input/input";
|
||||
import { ComposerRecall } from "@/composer/recall";
|
||||
|
||||
const SENT = ["add tests", "fix the auth bug"];
|
||||
|
||||
function press(
|
||||
key: string,
|
||||
text: string,
|
||||
options: { caret?: number; selectionEnd?: number; hasModifier?: boolean } = {},
|
||||
): ComposerKeyPressEvent {
|
||||
const caret = options.caret ?? text.length;
|
||||
return {
|
||||
key,
|
||||
hasModifier: options.hasModifier ?? false,
|
||||
preventDefault: () => undefined,
|
||||
input: { text, selection: { start: caret, end: options.selectionEnd ?? caret } },
|
||||
};
|
||||
}
|
||||
|
||||
/** Drives recall the way the composer does: each result becomes the composer text, caret at the end. */
|
||||
function composerWithRecall(sent: readonly string[] = SENT) {
|
||||
const recall = new ComposerRecall();
|
||||
let text = "";
|
||||
return {
|
||||
recall,
|
||||
get text() {
|
||||
return text;
|
||||
},
|
||||
type(next: string) {
|
||||
text = next;
|
||||
},
|
||||
press(key: string, options: { caret?: number; hasModifier?: boolean } = {}) {
|
||||
const result = recall.handleKeyPress(press(key, text, options), () => sent);
|
||||
if (result !== null) text = result;
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ComposerRecall", () => {
|
||||
it("walks back through sent messages and forward to an empty composer", () => {
|
||||
const composer = composerWithRecall();
|
||||
|
||||
expect(composer.press("ArrowUp")).toBe("add tests");
|
||||
expect(composer.press("ArrowUp")).toBe("fix the auth bug");
|
||||
expect(composer.press("ArrowDown")).toBe("add tests");
|
||||
expect(composer.press("ArrowDown")).toBe("");
|
||||
expect(composer.recall.isShowing(composer.text)).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves the caret to the input past the oldest message", () => {
|
||||
const composer = composerWithRecall();
|
||||
composer.press("ArrowUp");
|
||||
composer.press("ArrowUp");
|
||||
|
||||
expect(composer.press("ArrowUp")).toBeNull();
|
||||
expect(composer.text).toBe("fix the auth bug");
|
||||
});
|
||||
|
||||
it("only starts from an empty composer", () => {
|
||||
const composer = composerWithRecall();
|
||||
composer.type("half written");
|
||||
|
||||
expect(composer.press("ArrowUp")).toBeNull();
|
||||
expect(composer.press("ArrowDown")).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores Down from an empty composer and Up with no history", () => {
|
||||
expect(composerWithRecall().press("ArrowDown")).toBeNull();
|
||||
expect(composerWithRecall([]).press("ArrowUp")).toBeNull();
|
||||
});
|
||||
|
||||
it("hands the arrows back to the caret once the recalled message is edited", () => {
|
||||
const composer = composerWithRecall();
|
||||
composer.press("ArrowUp");
|
||||
composer.type("add tests for login");
|
||||
|
||||
expect(composer.recall.isShowing(composer.text)).toBe(false);
|
||||
expect(composer.press("ArrowUp")).toBeNull();
|
||||
expect(composer.press("ArrowDown")).toBeNull();
|
||||
});
|
||||
|
||||
it("moves between lines of a multi-line message before changing message", () => {
|
||||
const composer = composerWithRecall(["first line\nsecond line", "older"]);
|
||||
composer.press("ArrowUp");
|
||||
|
||||
expect(composer.press("ArrowUp", { caret: "first line\nsec".length })).toBeNull();
|
||||
expect(composer.press("ArrowUp", { caret: 3 })).toBe("older");
|
||||
expect(composer.press("ArrowDown")).toBe("first line\nsecond line");
|
||||
expect(composer.press("ArrowDown", { caret: 3 })).toBeNull();
|
||||
expect(composer.press("ArrowDown")).toBe("");
|
||||
});
|
||||
|
||||
it("ignores modified arrows, other keys, and a selection", () => {
|
||||
const composer = composerWithRecall();
|
||||
|
||||
expect(composer.press("ArrowUp", { hasModifier: true })).toBeNull();
|
||||
expect(composer.press("Enter")).toBeNull();
|
||||
composer.press("ArrowUp");
|
||||
const recall = composer.recall;
|
||||
expect(
|
||||
recall.handleKeyPress(
|
||||
press("ArrowUp", "add tests", { caret: 0, selectionEnd: 3 }),
|
||||
() => SENT,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ComposerKeyPressEvent } from "@/composer/input/input";
|
||||
|
||||
interface RecalledMessage {
|
||||
index: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the messages already sent to an agent from an empty composer, like a
|
||||
* shell's Up/Down history. A message stays recalled only while the composer
|
||||
* still holds its exact text; the first edit hands the arrows back to the caret.
|
||||
*/
|
||||
export class ComposerRecall {
|
||||
private recalled: RecalledMessage | null = null;
|
||||
|
||||
/** True while the composer shows a recalled message the user has not edited. */
|
||||
isShowing(text: string): boolean {
|
||||
return this.recalled !== null && this.recalled.text === text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the text the composer should show for this key press, or null to
|
||||
* leave the key to the input. `readSentMessages` is newest first and is only
|
||||
* read for keys recall acts on.
|
||||
*/
|
||||
handleKeyPress(
|
||||
event: ComposerKeyPressEvent,
|
||||
readSentMessages: () => readonly string[],
|
||||
): string | null {
|
||||
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return null;
|
||||
if (event.hasModifier) return null;
|
||||
const { text, selection } = event.input;
|
||||
if (selection.start !== selection.end) return null;
|
||||
|
||||
const recalled = this.isShowing(text) ? this.recalled : null;
|
||||
if (!recalled) {
|
||||
this.recalled = null;
|
||||
if (event.key !== "ArrowUp" || text.length > 0) return null;
|
||||
return this.show(readSentMessages(), 0);
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
if (!isCaretOnFirstLine(text, selection.start)) return null;
|
||||
return this.show(readSentMessages(), recalled.index + 1);
|
||||
}
|
||||
if (!isCaretOnLastLine(text, selection.start)) return null;
|
||||
if (recalled.index === 0) {
|
||||
this.recalled = null;
|
||||
return "";
|
||||
}
|
||||
return this.show(readSentMessages(), recalled.index - 1);
|
||||
}
|
||||
|
||||
private show(messages: readonly string[], index: number): string | null {
|
||||
const text = messages[index];
|
||||
if (text === undefined) return null;
|
||||
this.recalled = { index, text };
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function isCaretOnFirstLine(text: string, caret: number): boolean {
|
||||
return !text.slice(0, caret).includes("\n");
|
||||
}
|
||||
|
||||
function isCaretOnLastLine(text: string, caret: number): boolean {
|
||||
return !text.slice(caret).includes("\n");
|
||||
}
|
||||
@@ -2698,6 +2698,11 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "Connect to this host to manage agents",
|
||||
idleRuntimeCleanup: {
|
||||
title: "تحرير الوكلاء الخاملين",
|
||||
hint: "تحرير الذاكرة بعد ساعة من الخمول.",
|
||||
info: "يوقف عملية الوكيل مع الاحتفاظ بمحادثته. أرسل رسالة لاستئنافها.\n\nيمنع العمل النشط والإشعارات المعلّقة وفحوصات أمان المزوّد عملية التنظيف.",
|
||||
},
|
||||
},
|
||||
workspaces: {
|
||||
unavailable: "Connect to this host to manage workspaces",
|
||||
|
||||
@@ -2789,6 +2789,11 @@ export const en = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "Connect to this host to manage agents",
|
||||
idleRuntimeCleanup: {
|
||||
title: "Release idle agents",
|
||||
hint: "Free up memory after an hour of inactivity.",
|
||||
info: "Stops the agent's process while keeping its conversation. Send a message to resume.\n\nActive work, pending notifications, and provider safety checks prevent cleanup.",
|
||||
},
|
||||
},
|
||||
workspaces: {
|
||||
unavailable: "Connect to this host to manage workspaces",
|
||||
|
||||
@@ -2718,6 +2718,11 @@ export const es: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "Connect to this host to manage agents",
|
||||
idleRuntimeCleanup: {
|
||||
title: "Liberar agentes inactivos",
|
||||
hint: "Libera memoria tras una hora de inactividad.",
|
||||
info: "Detiene el proceso del agente y conserva la conversación. Envía un mensaje para reanudarla.\n\nEl trabajo activo, las notificaciones pendientes y las comprobaciones de seguridad del proveedor impiden la limpieza.",
|
||||
},
|
||||
},
|
||||
workspaces: {
|
||||
unavailable: "Connect to this host to manage workspaces",
|
||||
|
||||
@@ -2721,6 +2721,11 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "Connectez-vous à cet hôte pour gérer les agents",
|
||||
idleRuntimeCleanup: {
|
||||
title: "Libérer les agents inactifs",
|
||||
hint: "Libère de la mémoire après une heure d’inactivité.",
|
||||
info: "Arrête le processus de l’agent tout en conservant sa conversation. Envoyez un message pour la reprendre.\n\nLe travail en cours, les notifications en attente et les vérifications de sécurité du fournisseur empêchent le nettoyage.",
|
||||
},
|
||||
},
|
||||
workspaces: {
|
||||
unavailable: "Connectez-vous à cet hôte pour gérer les espaces de travail",
|
||||
|
||||
@@ -2687,6 +2687,11 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "エージェントを管理するにはこのホストに接続してください",
|
||||
idleRuntimeCleanup: {
|
||||
title: "アイドル状態のエージェントを解放",
|
||||
hint: "1 時間のアイドル状態の後にメモリを解放します。",
|
||||
info: "会話を保持したままエージェントのプロセスを停止します。メッセージを送信すると再開します。\n\n実行中の作業、保留中の通知、プロバイダーの安全性チェックがある場合は解放しません。",
|
||||
},
|
||||
},
|
||||
workspaces: {
|
||||
unavailable: "ワークスペースを管理するにはこのホストに接続してください",
|
||||
|
||||
@@ -2673,6 +2673,11 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "에이전트를 관리하려면 이 호스트에 연결하세요",
|
||||
idleRuntimeCleanup: {
|
||||
title: "유휴 에이전트 해제",
|
||||
hint: "1시간 동안 활동이 없으면 메모리를 확보합니다.",
|
||||
info: "대화를 유지하면서 에이전트 프로세스를 중지합니다. 메시지를 보내면 다시 이어집니다.\n\n진행 중인 작업, 대기 중인 알림, 프로바이더 안전 검사가 있으면 정리하지 않습니다.",
|
||||
},
|
||||
},
|
||||
workspaces: {
|
||||
unavailable: "워크스페이스를 관리하려면 이 호스트에 연결하세요",
|
||||
|
||||
@@ -2700,6 +2700,11 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "Conecte-se a este host para gerenciar agentes",
|
||||
idleRuntimeCleanup: {
|
||||
title: "Liberar agentes ociosos",
|
||||
hint: "Libera memória após uma hora de inatividade.",
|
||||
info: "Encerra o processo do agente e mantém a conversa. Envie uma mensagem para retomar.\n\nTrabalho ativo, notificações pendentes e verificações de segurança do provedor impedem a limpeza.",
|
||||
},
|
||||
},
|
||||
workspaces: {
|
||||
unavailable: "Conecte-se a este host para gerenciar workspaces",
|
||||
|
||||
@@ -2709,6 +2709,11 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "Подключитесь к этому хосту, чтобы управлять агентами",
|
||||
idleRuntimeCleanup: {
|
||||
title: "Освобождать неактивных агентов",
|
||||
hint: "Освобождает память после часа бездействия.",
|
||||
info: "Останавливает процесс агента, сохраняя разговор. Отправьте сообщение, чтобы продолжить.\n\nАктивная работа, ожидающие уведомления и проверки безопасности провайдера предотвращают очистку.",
|
||||
},
|
||||
},
|
||||
workspaces: {
|
||||
unavailable: "Подключитесь к этому хосту, чтобы управлять рабочими пространствами",
|
||||
|
||||
@@ -2634,6 +2634,11 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "连接到这个 Host 以管理 Agent",
|
||||
idleRuntimeCleanup: {
|
||||
title: "释放空闲 Agent",
|
||||
hint: "空闲一小时后释放内存。",
|
||||
info: "停止 Agent 进程,但保留对话。发送消息即可继续。\n\n进行中的工作、待处理的通知和 Provider 安全检查会阻止清理。",
|
||||
},
|
||||
},
|
||||
workspaces: {
|
||||
unavailable: "连接到这个 Host 以管理 Workspace",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { usePathname, useRootNavigationState } from "expo-router";
|
||||
import { useStore } from "zustand";
|
||||
import {
|
||||
useIsLastWorkspaceSelectionHydrated,
|
||||
useLastWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { createSettingsSelection } from "./model";
|
||||
|
||||
const selection = createSettingsSelection();
|
||||
|
||||
// Mount once above the sibling Settings routes so replacements and Back share
|
||||
// one visit, and leaving Settings gives the next workspace entry a fresh default.
|
||||
export function useSettingsRouteEntry(): void {
|
||||
const pathname = usePathname();
|
||||
const navigationState = useRootNavigationState();
|
||||
const routeKey = navigationState?.routes[navigationState.index]?.key;
|
||||
const workspace = useLastWorkspaceSelection();
|
||||
const isWorkspaceHydrated = useIsLastWorkspaceSelectionHydrated();
|
||||
const workspaceServerId = workspace?.serverId ?? null;
|
||||
useLayoutEffect(() => {
|
||||
if (isWorkspaceHydrated && routeKey) {
|
||||
selection.enterRoute({ key: routeKey, pathname }, workspaceServerId);
|
||||
}
|
||||
}, [pathname, routeKey, workspaceServerId, isWorkspaceHydrated]);
|
||||
}
|
||||
|
||||
export function useSettingsHostSelection(): string | null {
|
||||
return useStore(selection.store, (state) => state.selectedServerId);
|
||||
}
|
||||
|
||||
export const selectSettingsHost = selection.selectHost;
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createSettingsSelection } from "./model";
|
||||
|
||||
describe("Settings host selection", () => {
|
||||
it("keeps picker choices across sections, replacement and Back", () => {
|
||||
const settings = createSettingsSelection();
|
||||
settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host");
|
||||
expect(settings.store.getState().selectedServerId).toBe("workspace-host");
|
||||
settings.selectHost("chosen-host");
|
||||
settings.enterRoute(
|
||||
{
|
||||
key: "/settings/hosts/chosen-host/connections",
|
||||
pathname: "/settings/hosts/chosen-host/connections",
|
||||
},
|
||||
"workspace-host",
|
||||
);
|
||||
settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host");
|
||||
settings.enterRoute(
|
||||
{ key: "/settings/appearance", pathname: "/settings/appearance" },
|
||||
"workspace-host",
|
||||
);
|
||||
settings.enterRoute(
|
||||
{ key: "/settings/general", pathname: "/settings/general" },
|
||||
"workspace-host",
|
||||
);
|
||||
expect(settings.store.getState().selectedServerId).toBe("chosen-host");
|
||||
});
|
||||
|
||||
it("applies explicit host links once, including project and plugin screens", () => {
|
||||
const settings = createSettingsSelection();
|
||||
settings.enterRoute(
|
||||
{
|
||||
key: "/settings/hosts/linked-host/projects/project",
|
||||
pathname: "/settings/hosts/linked-host/projects/project",
|
||||
},
|
||||
"workspace-host",
|
||||
);
|
||||
expect(settings.store.getState().selectedServerId).toBe("linked-host");
|
||||
settings.selectHost("chosen-host");
|
||||
settings.enterRoute(
|
||||
{
|
||||
key: "/settings/hosts/linked-host/projects/project",
|
||||
pathname: "/settings/hosts/linked-host/projects/project",
|
||||
},
|
||||
"workspace-host",
|
||||
);
|
||||
expect(settings.store.getState().selectedServerId).toBe("chosen-host");
|
||||
settings.enterRoute(
|
||||
{
|
||||
key: "/settings/hosts/plugin-host/plugins/plugin/screen",
|
||||
pathname: "/settings/hosts/plugin-host/plugins/plugin/screen",
|
||||
},
|
||||
"workspace-host",
|
||||
);
|
||||
expect(settings.store.getState().selectedServerId).toBe("plugin-host");
|
||||
});
|
||||
|
||||
it("seeds each new visit from the workspace, including a retained settings route", () => {
|
||||
const settings = createSettingsSelection();
|
||||
settings.enterRoute(
|
||||
{ key: "/settings/general", pathname: "/settings/general" },
|
||||
"first-workspace-host",
|
||||
);
|
||||
settings.selectHost("chosen-host");
|
||||
settings.enterRoute(
|
||||
{
|
||||
key: "/h/second-workspace-host/workspace/workspace",
|
||||
pathname: "/h/second-workspace-host/workspace/workspace",
|
||||
},
|
||||
"second-workspace-host",
|
||||
);
|
||||
settings.enterRoute(
|
||||
{ key: "/settings/general", pathname: "/settings/general" },
|
||||
"second-workspace-host",
|
||||
);
|
||||
expect(settings.store.getState().selectedServerId).toBe("second-workspace-host");
|
||||
});
|
||||
|
||||
it("does not replay a retained host route after a newer picker choice", () => {
|
||||
const settings = createSettingsSelection();
|
||||
const oldRoute = { key: "old-host-screen", pathname: "/settings/hosts/first/host" };
|
||||
settings.enterRoute(oldRoute, "workspace-host");
|
||||
settings.selectHost("second");
|
||||
settings.enterRoute(
|
||||
{ key: "new-host-screen", pathname: "/settings/hosts/second/host" },
|
||||
"workspace-host",
|
||||
);
|
||||
settings.enterRoute(oldRoute, "workspace-host");
|
||||
settings.enterRoute({ key: "app-screen", pathname: "/settings/appearance" }, "workspace-host");
|
||||
expect(settings.store.getState().selectedServerId).toBe("second");
|
||||
settings.enterRoute({ ...oldRoute, key: "explicit-new-entry" }, "workspace-host");
|
||||
expect(settings.store.getState().selectedServerId).toBe("first");
|
||||
});
|
||||
|
||||
it("does not reseed a visit when remembered workspace state changes", () => {
|
||||
const settings = createSettingsSelection();
|
||||
settings.enterRoute({ key: "/settings", pathname: "/settings" }, null);
|
||||
settings.selectHost("chosen-host");
|
||||
settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host");
|
||||
expect(settings.store.getState().selectedServerId).toBe("chosen-host");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createStore } from "zustand/vanilla";
|
||||
|
||||
interface SettingsRouteEntry {
|
||||
key: string;
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
interface SettingsSelectionState {
|
||||
selectedServerId: string | null;
|
||||
}
|
||||
|
||||
export function createSettingsSelection() {
|
||||
const store = createStore<SettingsSelectionState>(() => ({ selectedServerId: null }));
|
||||
const enteredRoutes = new Map<string, string>();
|
||||
let inSettings = false;
|
||||
|
||||
function enterRoute(
|
||||
{ key, pathname }: SettingsRouteEntry,
|
||||
workspaceServerId: string | null,
|
||||
): void {
|
||||
const isSettings = pathname === "/settings" || pathname.startsWith("/settings/");
|
||||
if (!isSettings) {
|
||||
inSettings = false;
|
||||
enteredRoutes.clear();
|
||||
return;
|
||||
}
|
||||
// A retained route returning through Back must not replay its old host intent.
|
||||
if (enteredRoutes.get(key) === pathname) return;
|
||||
const isNewVisit = !inSettings;
|
||||
inSettings = true;
|
||||
enteredRoutes.set(key, pathname);
|
||||
|
||||
const hostMatch = /^\/settings\/hosts\/([^/]+)(?:\/|$)/.exec(pathname);
|
||||
if (hostMatch) {
|
||||
store.setState({ selectedServerId: decodeURIComponent(hostMatch[1]) });
|
||||
} else if (isNewVisit) {
|
||||
store.setState({ selectedServerId: workspaceServerId });
|
||||
}
|
||||
}
|
||||
|
||||
function selectHost(serverId: string): void {
|
||||
store.setState({ selectedServerId: serverId });
|
||||
}
|
||||
|
||||
return { store, enterRoute, selectHost };
|
||||
}
|
||||
@@ -133,7 +133,7 @@ import {
|
||||
type HostSectionSlug,
|
||||
type SettingsSectionSlug,
|
||||
} from "@/utils/host-routes";
|
||||
import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { selectSettingsHost, useSettingsHostSelection } from "@/navigation/settings-selection";
|
||||
import { returnFromSettings, type SettingsView } from "@/navigation/settings-navigation";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
@@ -1098,21 +1098,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
const hosts = useHosts();
|
||||
const localServerId = useLocalDaemonServerId();
|
||||
const sortedHosts = useSortedHosts(hosts, localServerId);
|
||||
const lastWorkspaceSelection = useLastWorkspaceSelection();
|
||||
const routedSettingsHostServerId =
|
||||
view.kind === "host" || view.kind === "project" || view.kind === "plugin"
|
||||
? view.serverId
|
||||
: null;
|
||||
const [selectedSettingsHostServerId, setSelectedSettingsHostServerId] = useState<string | null>(
|
||||
routedSettingsHostServerId ?? lastWorkspaceSelection?.serverId ?? null,
|
||||
);
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setSelectedSettingsHostServerId(
|
||||
routedSettingsHostServerId ?? lastWorkspaceSelection?.serverId ?? null,
|
||||
);
|
||||
}, [lastWorkspaceSelection?.serverId, routedSettingsHostServerId]),
|
||||
);
|
||||
const selectedSettingsHostServerId = useSettingsHostSelection();
|
||||
|
||||
// The host the four sections scope to: the host on the active view, otherwise
|
||||
// the picker choice, otherwise the connected local daemon, otherwise the first host.
|
||||
@@ -1232,7 +1218,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
// host detail route, keep that detail section and swap only the host segment.
|
||||
const handleSelectHost = useCallback(
|
||||
(serverId: string) => {
|
||||
setSelectedSettingsHostServerId(serverId);
|
||||
selectSettingsHost(serverId);
|
||||
if (view.kind === "project") {
|
||||
const target = buildSettingsHostSectionRoute(serverId, "projects");
|
||||
if (isCompactLayout) {
|
||||
|
||||
@@ -65,6 +65,7 @@ import { ICON_SIZE } from "@/styles/theme";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import { BrowserToolsOptInCard } from "./browser-tools-card";
|
||||
import { IdleRuntimeCleanupCard } from "./idle-runtime-cleanup-card";
|
||||
import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle";
|
||||
|
||||
const ThemedRestart = withUnistyles(RotateCw);
|
||||
@@ -291,6 +292,7 @@ export function HostAgentsPage({ serverId }: { serverId: string }) {
|
||||
<InjectPaseoToolsCard serverId={serverId} />
|
||||
<BrowserToolsOptInCard serverId={serverId} />
|
||||
<AppendSystemPromptCard serverId={serverId} />
|
||||
<IdleRuntimeCleanupCard serverId={serverId} />
|
||||
</SettingsSection>
|
||||
) : (
|
||||
<View style={[settingsStyles.card, styles.emptyCard]}>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useCallback } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { SettingsInfoTip } from "@/components/settings/headings/settings-info-tip";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
|
||||
// Host-wide, persisted, and applied live by the daemon.
|
||||
export function IdleRuntimeCleanupCard({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const isSupported = useHostFeature(serverId, "idleRuntimeCleanupSetting");
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (next: boolean) => {
|
||||
const result = await patchConfig({ idleRuntimeCleanup: next });
|
||||
if (!result) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(next: boolean) => {
|
||||
mutation.mutate(next);
|
||||
},
|
||||
[mutation],
|
||||
);
|
||||
|
||||
if (!isConnected || !isSupported || !config) return null;
|
||||
|
||||
return (
|
||||
<View style={settingsStyles.card} testID="host-page-idle-runtime-cleanup-card">
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.host.agents.idleRuntimeCleanup.title")}
|
||||
</Text>
|
||||
<SettingsInfoTip
|
||||
title={t("settings.host.agents.idleRuntimeCleanup.title")}
|
||||
info={t("settings.host.agents.idleRuntimeCleanup.info")}
|
||||
/>
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.host.agents.idleRuntimeCleanup.hint")}
|
||||
</Text>
|
||||
{mutation.error ? (
|
||||
<Text style={settingsStyles.rowError} testID="host-page-idle-runtime-cleanup-error">
|
||||
{mutation.error.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Switch
|
||||
value={config.idleRuntimeCleanup !== false}
|
||||
onValueChange={handleValueChange}
|
||||
disabled={mutation.isPending}
|
||||
accessibilityLabel={t("settings.host.agents.idleRuntimeCleanup.title")}
|
||||
testID="host-page-idle-runtime-cleanup-switch"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
@@ -5,6 +5,7 @@ import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||
|
||||
import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
selectAgentSentMessages,
|
||||
selectAgentTurnPresentation,
|
||||
selectAgentTimelineState,
|
||||
useSessionStore,
|
||||
@@ -235,6 +236,25 @@ describe("agent timeline state", () => {
|
||||
).toEqual({ status: "synced", items: [], range: null, older: "none", newer: "none" });
|
||||
});
|
||||
|
||||
it("lists sent messages newest first, including the live head", () => {
|
||||
initializeTestSession();
|
||||
const sent = (id: string, text: string) => ({ ...submittedMessage(id), text });
|
||||
useSessionStore.getState().applyAgentTimelineResponseState("test-server", "agent-1", {
|
||||
items: [sent("m1", "fix the auth bug"), sent("m2", "add tests"), sent("m3", "add tests")],
|
||||
head: [sent("m4", " "), sent("m5", "refactor the wrong module")],
|
||||
range: { epoch: "epoch-1", startSeq: 1, endSeq: 5 },
|
||||
older: "none",
|
||||
newer: false,
|
||||
synchronized: true,
|
||||
acknowledgedClientMessageIds: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
selectAgentSentMessages(useSessionStore.getState().sessions["test-server"], "agent-1"),
|
||||
).toEqual(["refactor the wrong module", "add tests", "fix the auth bug"]);
|
||||
expect(selectAgentSentMessages(undefined, "agent-1")).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves older availability when an applied response leaves it unchanged", () => {
|
||||
initializeTestSession();
|
||||
const store = useSessionStore.getState();
|
||||
|
||||
@@ -332,6 +332,26 @@ export function selectAgentTimelineState(
|
||||
return items.length > 0 ? { status: "painted", items } : { status: "cold" };
|
||||
}
|
||||
|
||||
/** Text of the loaded user messages, newest first, with back-to-back repeats collapsed. */
|
||||
export function selectAgentSentMessages(
|
||||
session: SessionState | undefined,
|
||||
agentId: string,
|
||||
): string[] {
|
||||
if (!session) return [];
|
||||
const items = [
|
||||
...(session.agentStreamTail.get(agentId) ?? []),
|
||||
...(session.agentStreamHead.get(agentId) ?? []),
|
||||
];
|
||||
const messages: string[] = [];
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (item.kind !== "user_message" || item.text.trim().length === 0) continue;
|
||||
if (messages[messages.length - 1] === item.text) continue;
|
||||
messages.push(item.text);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function selectAgentTurnPresentation(
|
||||
session: SessionState | undefined,
|
||||
agentId: string,
|
||||
|
||||
@@ -9,6 +9,7 @@ export const PROVIDER_CAPABILITIES = [
|
||||
"prompt.image",
|
||||
"prompt.output_schema",
|
||||
"prompt.steer",
|
||||
"session.can_stop_runtime",
|
||||
"session.archive",
|
||||
"session.configure",
|
||||
"session.list",
|
||||
@@ -277,6 +278,7 @@ export type ProviderInput =
|
||||
}
|
||||
| { type: "session.archive"; requestId: string; persistence: ProviderPersistence }
|
||||
| { type: "session.unarchive"; requestId: string; persistence: ProviderPersistence }
|
||||
| { type: "session.can_stop_runtime"; requestId: string; sessionId: string }
|
||||
| { type: "session.close"; requestId: string; sessionId: string };
|
||||
|
||||
export interface ProviderThinkingOption {
|
||||
@@ -552,6 +554,7 @@ export type ProviderTimelineItem =
|
||||
});
|
||||
|
||||
export type ProviderEvent =
|
||||
| { type: "session.can_stop_runtime"; requestId: string; sessionId: string; canStop: boolean }
|
||||
| { type: "catalog"; requestId: string; catalog: ProviderCatalog }
|
||||
| { type: "sessions"; requestId: string; sessions: ProviderSessionSummary[] }
|
||||
| { type: "request.completed"; requestId: string }
|
||||
@@ -634,6 +637,8 @@ function requiredPromptCapabilities(prompt: ProviderPrompt): readonly ProviderCa
|
||||
|
||||
export function requiredProviderCapabilities(input: ProviderInput): readonly ProviderCapability[] {
|
||||
switch (input.type) {
|
||||
case "session.can_stop_runtime":
|
||||
return ["session.can_stop_runtime"];
|
||||
case "catalog":
|
||||
case "session.interrupt":
|
||||
case "session.close":
|
||||
@@ -955,6 +960,13 @@ export const ProviderInputSchema: z.ZodType<ProviderInput> = z.discriminatedUnio
|
||||
persistence: persistenceSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("session.can_stop_runtime"),
|
||||
requestId: idSchema,
|
||||
sessionId: idSchema,
|
||||
})
|
||||
.strict(),
|
||||
z.object({ type: z.literal("session.close"), requestId: idSchema, sessionId: idSchema }).strict(),
|
||||
]);
|
||||
|
||||
@@ -1326,6 +1338,14 @@ export const ProviderEventSchema: z.ZodType<ProviderEvent> = z.discriminatedUnio
|
||||
sessionId: idSchema,
|
||||
})
|
||||
.strip(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("session.can_stop_runtime"),
|
||||
requestId: idSchema,
|
||||
sessionId: idSchema,
|
||||
canStop: z.boolean(),
|
||||
})
|
||||
.strip(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("session.closed"),
|
||||
|
||||
@@ -224,6 +224,9 @@ export const MutableDaemonConfigSchema = z
|
||||
metadataGeneration: MutableMetadataGenerationConfigSchema.default({ providers: [] }),
|
||||
autoArchiveAfterMerge: z.boolean().default(false),
|
||||
enableTerminalAgentHooks: z.boolean().default(false),
|
||||
// COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, keep optional while old daemons are supported.
|
||||
// Persisted host-wide value, applied live by the daemon.
|
||||
idleRuntimeCleanup: z.boolean().optional(),
|
||||
appendSystemPrompt: z.string().default(""),
|
||||
terminalProfiles: z.array(TerminalProfileSchema).optional(),
|
||||
agentProfiles: z.array(AgentProfileSchema).optional(),
|
||||
@@ -245,6 +248,7 @@ export const MutableDaemonConfigPatchSchema = z
|
||||
metadataGeneration: MutableMetadataGenerationConfigSchema.partial().optional(),
|
||||
autoArchiveAfterMerge: z.boolean().optional(),
|
||||
enableTerminalAgentHooks: z.boolean().optional(),
|
||||
idleRuntimeCleanup: z.boolean().optional(),
|
||||
appendSystemPrompt: z.string().optional(),
|
||||
terminalProfiles: z.array(TerminalProfileSchema).optional(),
|
||||
agentProfiles: z.array(AgentProfileSchema).optional(),
|
||||
@@ -3694,6 +3698,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
pluginTimelineItems: z.boolean().optional(),
|
||||
// COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16.
|
||||
skillManagement: z.boolean().optional(),
|
||||
// COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, remove gate after 2027-04-26.
|
||||
idleRuntimeCleanupSetting: z.boolean().optional(),
|
||||
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
||||
"terminal-restore-modes": z.boolean().optional(),
|
||||
// COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js";
|
||||
import {
|
||||
prepareAgentMessage,
|
||||
formatAgentMessage,
|
||||
@@ -10007,6 +10008,47 @@ test("closeAgent persists one final closed snapshot", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("idle runtime cleanup honors a provider veto and resumes the saved conversation", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-cleanup-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
let safe = false;
|
||||
const client = new (class extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
const session = await super.createSession(config);
|
||||
session.canStopRuntime = async () => safe;
|
||||
return session;
|
||||
}
|
||||
})();
|
||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
const collect = runtimeCleanup(manager);
|
||||
try {
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
const now = new Date(Date.now() + 61 * 60_000);
|
||||
expect(await collect({ now })).toEqual({ collected: [], failures: [] });
|
||||
safe = true;
|
||||
expect(await collect({ now })).toEqual({ collected: [], failures: [] });
|
||||
const later = new Date(now.getTime() + 61 * 60_000);
|
||||
expect((await collect({ now: later })).collected).toEqual([
|
||||
{ agentId: agent.id, provider: "codex", sessionId: agent.persistence?.sessionId },
|
||||
]);
|
||||
expect(manager.getAgent(agent.id)).toBeNull();
|
||||
const resumed = await ensureAgentLoaded(agent.id, {
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
});
|
||||
expect(resumed.id).toBe(agent.id);
|
||||
expect(resumed.persistence).toEqual(agent.persistence);
|
||||
await manager.runAgent(agent.id, "Continue the saved conversation");
|
||||
} finally {
|
||||
await manager.closeAgent(manager.listAgents()[0]?.id ?? "missing").catch(() => undefined);
|
||||
await storage.flush();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("idle agents remain resident until an explicit lifecycle action closes them", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-residency-"));
|
||||
let closeCount = 0;
|
||||
@@ -10951,12 +10993,12 @@ test("tryRunOutOfBand gives providers the command another agent sent, not its en
|
||||
|
||||
try {
|
||||
const fromAgent = prepareAgentMessage("/handled", { kind: "agent-message", agentId: "sender" });
|
||||
expect(manager.tryRunOutOfBand(agent.id, fromAgent.prompt)).toBe(true);
|
||||
expect(await manager.tryRunOutOfBand(agent.id, fromAgent.prompt)).toBe(true);
|
||||
expect(seenPrompts).toEqual(["/handled"]);
|
||||
|
||||
// A human who pastes an envelope gets it quoted, and it stays a plain prompt.
|
||||
const pasted = prepareAgentMessage(String(fromAgent.prompt));
|
||||
expect(manager.tryRunOutOfBand(agent.id, pasted.prompt)).toBe(false);
|
||||
expect(await manager.tryRunOutOfBand(agent.id, pasted.prompt)).toBe(false);
|
||||
expect(seenPrompts.at(-1)).toBe(pasted.prompt);
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
@@ -12083,3 +12125,176 @@ test("reads the last durable assistant response without loading a stored-only ch
|
||||
);
|
||||
expect(manager.getAgent("stored-child")).toBeNull();
|
||||
});
|
||||
|
||||
function runtimeCleanup(manager: AgentManager) {
|
||||
let now = Date.now();
|
||||
let protectedCaller = (_agentId: string) => false;
|
||||
const cleanup = createIdleRuntimeCleanup({
|
||||
enabled: true,
|
||||
runtimes: () => manager.getRuntimeCleanupCandidates(),
|
||||
logger,
|
||||
now: () => now,
|
||||
isProtected: (agentId) => protectedCaller(agentId),
|
||||
schedule: () => () => {},
|
||||
});
|
||||
onTestFinished(() => cleanup.close());
|
||||
return (options: { now: Date; isProtected?: (agentId: string) => boolean }) => {
|
||||
now = options.now.getTime();
|
||||
protectedCaller = options.isProtected ?? (() => false);
|
||||
return cleanup.sweep();
|
||||
};
|
||||
}
|
||||
|
||||
function idleCleanupHarness() {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-protection-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const commands: AgentPromptInput[] = [];
|
||||
const prompts: AgentPromptInput[] = [];
|
||||
const recordPrompts = (session: AgentSession) => {
|
||||
session.tryHandleOutOfBand = (prompt) =>
|
||||
prompt === "/maintenance"
|
||||
? {
|
||||
run: async () => {
|
||||
commands.push(prompt);
|
||||
},
|
||||
}
|
||||
: null;
|
||||
const start = session.startTurn.bind(session);
|
||||
session.startTurn = (prompt, options) => {
|
||||
prompts.push(prompt);
|
||||
return start(prompt, options);
|
||||
};
|
||||
return session;
|
||||
};
|
||||
const sessions: AgentSession[] = [];
|
||||
const probes: Array<(() => Promise<boolean>) | undefined> = [];
|
||||
const closeEntered = deferred<void>();
|
||||
let closeGate: Promise<void> | undefined;
|
||||
let closes = 0;
|
||||
const client = new (class extends TestAgentClient {
|
||||
override async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
): Promise<AgentSession> {
|
||||
const session = await super.resumeSession(handle, config);
|
||||
session.describePersistence = () => handle;
|
||||
return recordPrompts(session);
|
||||
}
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
const index = sessions.length;
|
||||
const session: AgentSession = await super.createSession(config);
|
||||
session.canStopRuntime = async () => probes[index]?.() ?? false;
|
||||
session.close = async () => {
|
||||
closes++;
|
||||
closeEntered.resolve();
|
||||
await closeGate;
|
||||
};
|
||||
sessions.push(session);
|
||||
return recordPrompts(session);
|
||||
}
|
||||
})();
|
||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
onTestFinished(async () => {
|
||||
closeGate = undefined;
|
||||
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id)));
|
||||
await storage.flush();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
});
|
||||
return {
|
||||
commands,
|
||||
sweep: runtimeCleanup(manager),
|
||||
manager,
|
||||
storage,
|
||||
sessions,
|
||||
probes,
|
||||
prompts,
|
||||
closeEntered,
|
||||
get closes() {
|
||||
return closes;
|
||||
},
|
||||
holdClose: (gate: Promise<void>) => {
|
||||
closeGate = gate;
|
||||
},
|
||||
create: (labels?: Record<string, string>) =>
|
||||
manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
labels,
|
||||
}),
|
||||
later: (minutes: number) => new Date(Date.now() + minutes * 60_000),
|
||||
};
|
||||
}
|
||||
|
||||
test("idle cleanup rechecks new prompt activity after an asynchronous safety probe", async () => {
|
||||
const h = idleCleanupHarness();
|
||||
const agent = await h.create();
|
||||
const entered = deferred<void>();
|
||||
const probe = deferred<boolean>();
|
||||
h.probes[0] = () => {
|
||||
entered.resolve();
|
||||
return probe.promise;
|
||||
};
|
||||
const sweep = h.sweep({ now: h.later(61) });
|
||||
await entered.promise;
|
||||
await h.manager.runAgent(agent.id, "arrived during safety query");
|
||||
probe.resolve(true);
|
||||
expect((await sweep).collected).toEqual([]);
|
||||
expect(h.closes).toBe(0);
|
||||
});
|
||||
|
||||
test("idle cleanup retains a parent whose idle managed child owns background work", async () => {
|
||||
const h = idleCleanupHarness();
|
||||
const parent = await h.create();
|
||||
await h.create({ [PARENT_AGENT_ID_LABEL]: parent.id });
|
||||
h.probes[0] = async () => true;
|
||||
h.probes[1] = async () => false;
|
||||
expect((await h.sweep({ now: h.later(61) })).collected).toEqual([]);
|
||||
expect(h.closes).toBe(0);
|
||||
});
|
||||
|
||||
test("idle cleanup rechecks queued deliveries admitted during the safety probe", async () => {
|
||||
const h = idleCleanupHarness();
|
||||
await h.create();
|
||||
let queued = false;
|
||||
h.probes[0] = async () => {
|
||||
queued = true;
|
||||
return true;
|
||||
};
|
||||
expect((await h.sweep({ now: h.later(61), isProtected: () => queued })).collected).toEqual([]);
|
||||
expect(h.closes).toBe(0);
|
||||
});
|
||||
|
||||
test("a prompt racing idle cleanup waits for close and resumes exactly once", async () => {
|
||||
const h = idleCleanupHarness();
|
||||
const agent = await h.create();
|
||||
h.probes[0] = async () => true;
|
||||
const gate = deferred<void>();
|
||||
h.holdClose(gate.promise);
|
||||
const sweep = h.sweep({ now: h.later(61) });
|
||||
await h.closeEntered.promise;
|
||||
const run = h.manager.runAgent(agent.id, "arrived while closing");
|
||||
gate.resolve();
|
||||
expect((await sweep).collected).toHaveLength(1);
|
||||
const result = await run;
|
||||
expect(result.sessionId).toBe(agent.persistence?.sessionId);
|
||||
expect(h.closes).toBe(1);
|
||||
expect(h.prompts).toEqual(["arrived while closing"]);
|
||||
});
|
||||
|
||||
test("an out-of-band command racing idle cleanup waits for the resumed runtime", async () => {
|
||||
const h = idleCleanupHarness();
|
||||
const agent = await h.create();
|
||||
h.probes[0] = async () => true;
|
||||
const gate = deferred<void>();
|
||||
h.holdClose(gate.promise);
|
||||
const sweep = h.sweep({ now: h.later(61) });
|
||||
await h.closeEntered.promise;
|
||||
const command = startAgentRun(h.manager, agent.id, "/maintenance", logger);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const deliveredWhileClosing = [...h.commands];
|
||||
gate.resolve();
|
||||
await sweep;
|
||||
expect(deliveredWhileClosing).toEqual([]);
|
||||
expect(await command).toEqual({ disposition: "out_of_band" });
|
||||
expect(h.commands).toEqual(["/maintenance"]);
|
||||
expect(h.prompts).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js";
|
||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||
import { projectTimelineRows } from "./timeline-projection.js";
|
||||
import type { PluginLifecycle } from "../plugins/lifecycle/index.js";
|
||||
import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js";
|
||||
@@ -758,6 +760,8 @@ export class AgentManager {
|
||||
private readonly agentRegistrationTasks = new Set<Promise<void>>();
|
||||
private readonly inFlightAgentCloses = new Map<string, Promise<void>>();
|
||||
private readonly reloadedSessionCloses = new WeakMap<AgentSession, Promise<void>>();
|
||||
private readonly runtimeCleanupIdentities = new WeakMap<AgentSession, object>();
|
||||
private readonly collectingRuntimes = new Set<string>();
|
||||
private readonly lifecycleMutationTails = new Map<string, Promise<void>>();
|
||||
private readonly agentStreamCoalescer: AgentStreamCoalescer;
|
||||
private mcpBaseUrl: string | null;
|
||||
@@ -1747,6 +1751,64 @@ export class AgentManager {
|
||||
});
|
||||
}
|
||||
|
||||
/** Capture runtime capabilities without handing lifecycle ownership to the collector. */
|
||||
getRuntimeCleanupCandidates(): RuntimeCleanupCandidate[] {
|
||||
if (!this.registry) return [];
|
||||
const candidates: RuntimeCleanupCandidate[] = [];
|
||||
for (const agent of this.agents.values()) {
|
||||
const updatedAt = agent.updatedAt;
|
||||
let identity = this.runtimeCleanupIdentities.get(agent.session);
|
||||
if (!identity) {
|
||||
identity = Object.freeze({});
|
||||
this.runtimeCleanupIdentities.set(agent.session, identity);
|
||||
}
|
||||
const isEligible = () =>
|
||||
this.agents.get(agent.id) === agent &&
|
||||
agent.updatedAt === updatedAt &&
|
||||
this.runtimeHasNoPendingWork(agent);
|
||||
candidates.push({
|
||||
identity,
|
||||
agentId: agent.id,
|
||||
parentAgentId: getParentAgentIdFromLabels(agent.labels) ?? undefined,
|
||||
provider: agent.provider,
|
||||
sessionId: agent.persistence?.sessionId ?? null,
|
||||
lastActivityAt: updatedAt.getTime(),
|
||||
isEligible,
|
||||
canStopRuntime: agent.session.canStopRuntime?.bind(agent.session),
|
||||
closeIfEligible: async (revalidate) => {
|
||||
if (!agent.persistence?.sessionId) return false;
|
||||
// Block prompt admission before entering the close lane. A skipped close also
|
||||
// releases this barrier, so the waiting prompt keeps the existing runtime.
|
||||
this.collectingRuntimes.add(agent.id);
|
||||
try {
|
||||
return await this.runLifecycleMutation(agent.id, () =>
|
||||
this.closeAgentRuntime(agent.id, () => isEligible() && revalidate()),
|
||||
);
|
||||
} finally {
|
||||
this.collectingRuntimes.delete(agent.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private runtimeHasNoPendingWork(agent: LiveManagedAgent): boolean {
|
||||
return (
|
||||
agent.lifecycle === "idle" &&
|
||||
!agent.internal &&
|
||||
agent.activeForegroundTurnId === null &&
|
||||
!this.runs.hasRun(agent.id) &&
|
||||
!agent.pendingReplacement &&
|
||||
agent.pendingPermissions.size === 0 &&
|
||||
agent.inFlightPermissionResponses.size === 0 &&
|
||||
(this.collectingRuntimes.has(agent.id) || !this.lifecycleMutationTails.has(agent.id)) &&
|
||||
!this.foregroundMutationTails.has(agent.id) &&
|
||||
!this.sessionEventTails.has(agent.id) &&
|
||||
!this.providerSubagents.list(agent.id).some((child) => child.status === "running")
|
||||
);
|
||||
}
|
||||
|
||||
closeAgent(agentId: string): Promise<void> {
|
||||
const existing = this.inFlightAgentCloses.get(agentId);
|
||||
if (existing) {
|
||||
@@ -1767,7 +1829,7 @@ export class AgentManager {
|
||||
return close;
|
||||
}
|
||||
|
||||
private async closeAgentRuntime(agentId: string): Promise<void> {
|
||||
private async closeAgentRuntime(agentId: string, canClose?: () => boolean): Promise<boolean> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
this.logger.trace(
|
||||
{
|
||||
@@ -1782,6 +1844,7 @@ export class AgentManager {
|
||||
"agent.manager.close.start",
|
||||
);
|
||||
await this.drainSessionEvents(agentId);
|
||||
if (canClose && !canClose()) return false;
|
||||
// Retain ownership until shutdown succeeds. A failed close may still own a
|
||||
// native writer, so publishing a resumable closed snapshot would orphan it.
|
||||
await agent.session.close();
|
||||
@@ -1807,6 +1870,7 @@ export class AgentManager {
|
||||
if (persistError !== undefined) {
|
||||
throw persistError;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private cancelRunningProviderSubagents(parentAgentId: string): void {
|
||||
@@ -2459,7 +2523,19 @@ export class AgentManager {
|
||||
* emitted by the handler flow through dispatchStream so they persist and
|
||||
* broadcast like normal timeline events.
|
||||
*/
|
||||
tryRunOutOfBand(agentId: string, prompt: AgentPromptInput, options?: AgentRunOptions): boolean {
|
||||
async tryRunOutOfBand(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
): Promise<boolean> {
|
||||
if (this.collectingRuntimes.has(agentId) || !this.agents.has(agentId)) {
|
||||
await this.waitForAgentClose(agentId);
|
||||
await ensureAgentLoaded(agentId, {
|
||||
agentManager: this,
|
||||
agentStorage: this.requireRegistry(),
|
||||
logger: this.logger,
|
||||
});
|
||||
}
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
// Providers parse slash commands from the raw text, so a command another agent sent
|
||||
// must reach them without its sender envelope.
|
||||
@@ -2470,6 +2546,7 @@ export class AgentManager {
|
||||
if (!handler) {
|
||||
return false;
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
if (options?.clientMessageId) {
|
||||
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "accepted");
|
||||
this.emitState(agent);
|
||||
@@ -2489,7 +2566,7 @@ export class AgentManager {
|
||||
}
|
||||
this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() });
|
||||
};
|
||||
void (async () => {
|
||||
void this.runForegroundMutation(agentId, async () => {
|
||||
try {
|
||||
await handler.run({ emit: dispatch });
|
||||
} catch (error) {
|
||||
@@ -2500,7 +2577,7 @@ export class AgentManager {
|
||||
item: { type: "assistant_message", text: `[Error] ${text}` },
|
||||
});
|
||||
}
|
||||
})();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2584,11 +2661,27 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async *streamAfterRuntimeCollection(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
): AsyncGenerator<AgentStreamEvent> {
|
||||
await this.waitForAgentClose(agentId);
|
||||
await ensureAgentLoaded(agentId, {
|
||||
agentManager: this,
|
||||
agentStorage: this.requireRegistry(),
|
||||
logger: this.logger,
|
||||
});
|
||||
yield* this.streamAgent(agentId, prompt, options);
|
||||
}
|
||||
|
||||
streamAgent(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
): AsyncGenerator<AgentStreamEvent> {
|
||||
if (this.collectingRuntimes.has(agentId) || (!this.agents.has(agentId) && this.registry))
|
||||
return this.streamAfterRuntimeCollection(agentId, prompt, options);
|
||||
const existingAgent = this.requireSessionAgent(agentId);
|
||||
this.logger.trace(
|
||||
{
|
||||
|
||||
@@ -283,3 +283,33 @@ test("batches retain each sender's attachment context and image order", async ()
|
||||
)?.map((message) => message.source?.agentId),
|
||||
).toEqual(["one", "two"]);
|
||||
});
|
||||
|
||||
test("runtime safety sees queued messages until provider dispatch settles", async () => {
|
||||
const clock = createClock();
|
||||
let release!: () => void;
|
||||
const dispatched = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const delivery = createAgentMessageDelivery({
|
||||
schedule: clock.schedule,
|
||||
dispatch: async () => {
|
||||
await dispatched;
|
||||
return "steered";
|
||||
},
|
||||
onError: (error) => {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
expect(delivery.hasPending("parent")).toBe(false);
|
||||
delivery.enqueue({
|
||||
recipientId: "parent",
|
||||
prompt: "finished",
|
||||
source: { kind: "agent-message", agentId: "child" },
|
||||
});
|
||||
expect(delivery.hasPending("parent")).toBe(true);
|
||||
clock.advance(2000);
|
||||
expect(delivery.hasPending("parent")).toBe(true);
|
||||
release();
|
||||
await delivery.close();
|
||||
expect(delivery.hasPending("parent")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -43,6 +43,8 @@ interface Dependencies {
|
||||
}
|
||||
export interface AgentMessageDelivery {
|
||||
enqueue(message: Message): Receipt;
|
||||
/** Queued messages and provider dispatches still owned by this recipient. */
|
||||
hasPending(recipientId: string): boolean;
|
||||
/** Stop admission, flush pending messages, and await provider dispatch acknowledgments. */
|
||||
close(): Promise<void>;
|
||||
/** Existing completion waits include agent messages admitted before the wait. */
|
||||
@@ -138,6 +140,7 @@ export function createAgentMessageDelivery(dependencies: Dependencies): AgentMes
|
||||
}
|
||||
|
||||
return {
|
||||
hasPending: (recipientId) => pending.has(recipientId) || dispatching.has(recipientId),
|
||||
enqueue(message) {
|
||||
if (closed) throw new Error("Agent message delivery is closed");
|
||||
let resolve!: Admitted["resolve"];
|
||||
|
||||
@@ -110,7 +110,7 @@ export async function startAgentRun(
|
||||
// Out-of-band commands (e.g. /goal pause) must run WITHOUT canceling an
|
||||
// in-flight turn — replaceAgentRun would interrupt the running turn. The
|
||||
// intercept lives at this layer so it covers every prompt entrypoint.
|
||||
if (agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) {
|
||||
if (await agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) {
|
||||
options?.onDispatch?.("out_of_band");
|
||||
return { disposition: "out_of_band" };
|
||||
}
|
||||
|
||||
@@ -694,6 +694,10 @@ export interface AgentSession {
|
||||
* still uncertain.
|
||||
*/
|
||||
interrupt(): Promise<void>;
|
||||
/** True only when releasing this runtime loses no background work or pending replies.
|
||||
* An optional additional veto: missing methods use daemon baseline eligibility;
|
||||
* implemented checks that reject or return false keep the runtime resident. */
|
||||
canStopRuntime?(): Promise<boolean>;
|
||||
/** Release live runtime resources without archiving or deleting the durable native session. */
|
||||
close(): Promise<void>;
|
||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createIdleRuntimeCleanup } from "../idle-runtime-cleanup/index.js";
|
||||
import { HostObservationError } from "../host-operations/index.js";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -412,3 +413,87 @@ test("shutdown during observation setup owns rejection of the already-canceled n
|
||||
expect(await state.store.list()).toHaveLength(1);
|
||||
expect(state.delivery.prompts).toEqual([]);
|
||||
});
|
||||
|
||||
test("remote finish intent protects its caller through registration, observation and delivery", async () => {
|
||||
const s = await scenario();
|
||||
let time = 61 * 60_000;
|
||||
let collected = false;
|
||||
const runtime = {
|
||||
identity: {},
|
||||
agentId: "parent",
|
||||
parentAgentId: undefined,
|
||||
provider: "test",
|
||||
sessionId: "parent",
|
||||
lastActivityAt: 0,
|
||||
isEligible: () => !collected,
|
||||
closeIfEligible: async (revalidate: () => boolean) => {
|
||||
if (!revalidate()) return false;
|
||||
collected = true;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const cleanup = createIdleRuntimeCleanup({
|
||||
enabled: true,
|
||||
logger: createTestLogger(),
|
||||
now: () => time,
|
||||
runtimes: () => (collected ? [] : [runtime]),
|
||||
isProtected: (agentId) => s.service.hasPendingForCaller(agentId),
|
||||
schedule: () => () => {},
|
||||
});
|
||||
const savedPut = s.store.put.bind(s.store);
|
||||
const writing = deferred<void>();
|
||||
s.store.put = async (record) => {
|
||||
await writing.promise;
|
||||
await savedPut(record);
|
||||
};
|
||||
const registration = s.service.register({ ...intent, serverId: "remote" }, true);
|
||||
const protectedDuringRegistration = s.service.hasPendingForCaller?.("parent") ?? false;
|
||||
writing.resolve();
|
||||
const accepted = await registration;
|
||||
expect(protectedDuringRegistration).toBe(true);
|
||||
expect((await cleanup.sweep()).collected).toEqual([]);
|
||||
expect(s.service.hasPendingForCaller("parent")).toBe(true);
|
||||
accepted.accept();
|
||||
await until(() => s.observation.waits.length === 1);
|
||||
expect(s.service.hasPendingForCaller("parent")).toBe(true);
|
||||
s.observation.waits[0]!.result.resolve(outcome());
|
||||
await until(() => s.delivery.prompts.length === 1);
|
||||
expect(s.service.hasPendingForCaller("parent")).toBe(true);
|
||||
s.delivery.acknowledgments[0]!.resolve("steered");
|
||||
await until(() => !s.service.hasPendingForCaller("parent"));
|
||||
expect(await s.store.list()).toEqual([]);
|
||||
time = 62 * 60_000;
|
||||
expect((await cleanup.sweep()).collected).toEqual([]);
|
||||
time = 122 * 60_000;
|
||||
expect((await cleanup.sweep()).collected).toHaveLength(1);
|
||||
await cleanup.close();
|
||||
});
|
||||
|
||||
test("finish recovery protects callers before durable intent has loaded", async () => {
|
||||
const s = await scenario();
|
||||
await s.service.register({ ...intent, serverId: "remote" });
|
||||
await s.service.close();
|
||||
const loading = deferred<void>();
|
||||
const recovered = createFinishSubscriptions({
|
||||
store: {
|
||||
...s.store,
|
||||
list: async () => {
|
||||
await loading.promise;
|
||||
return s.store.list();
|
||||
},
|
||||
},
|
||||
messages: () => s.delivery,
|
||||
open: s.observation.open.bind(s.observation),
|
||||
callerIsActive: async () => true,
|
||||
serverId: "origin",
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
services.push(recovered);
|
||||
expect(recovered.hasPendingForCaller("parent")).toBe(true);
|
||||
const starting = recovered.start();
|
||||
expect(recovered.hasPendingForCaller("parent")).toBe(true);
|
||||
loading.resolve();
|
||||
await starting;
|
||||
expect(recovered.hasPendingForCaller("parent")).toBe(true);
|
||||
expect(recovered.hasPendingForCaller("unrelated")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface FinishRegistration {
|
||||
}
|
||||
export interface FinishSubscriptions {
|
||||
start(): Promise<void>;
|
||||
/** Pending notification intent, including registration, recovery and unacknowledged delivery. */
|
||||
hasPendingForCaller(agentId: string): boolean;
|
||||
register(
|
||||
input: Omit<FinishSubscription, "id">,
|
||||
pendingDispatch?: boolean,
|
||||
@@ -72,6 +74,8 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio
|
||||
const tasks = new Set<Promise<void>>();
|
||||
const watches = new Map<string, Set<Watch>>();
|
||||
let closed = false;
|
||||
let recovering = true;
|
||||
const registering = new Map<string, number>();
|
||||
let mutations = Promise.resolve();
|
||||
function serialize<T>(action: () => Promise<T>): Promise<T> {
|
||||
const result = mutations.then(action);
|
||||
@@ -300,6 +304,13 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio
|
||||
}
|
||||
|
||||
return {
|
||||
hasPendingForCaller(agentId) {
|
||||
if (recovering || registering.has(agentId)) return true;
|
||||
for (const group of watches.values()) {
|
||||
for (const watch of group) if (watch.records[0]!.callerAgentId === agentId) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
start: () =>
|
||||
serialize(async () => {
|
||||
const groups = new Map<string, FinishSubscription[]>();
|
||||
@@ -309,9 +320,11 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio
|
||||
groups.set(key(record), records);
|
||||
}
|
||||
for (const records of groups.values()) attach(records, false);
|
||||
recovering = false;
|
||||
}),
|
||||
register: (input, pendingDispatch = false) =>
|
||||
serialize(async () => {
|
||||
register: (input, pendingDispatch = false) => {
|
||||
registering.set(input.callerAgentId, (registering.get(input.callerAgentId) ?? 0) + 1);
|
||||
return serialize(async () => {
|
||||
if (closed) throw new Error("Finish subscriptions are closed");
|
||||
const record: FinishSubscription = {
|
||||
...input,
|
||||
@@ -337,13 +350,18 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio
|
||||
},
|
||||
reject: () =>
|
||||
serialize(async () => {
|
||||
stop(watch, actualGroup);
|
||||
await remove(watch);
|
||||
stop(watch, actualGroup);
|
||||
}),
|
||||
};
|
||||
if (!pendingDispatch) registration.accept();
|
||||
return registration;
|
||||
}),
|
||||
}).finally(() => {
|
||||
const remaining = registering.get(input.callerAgentId)! - 1;
|
||||
if (remaining) registering.set(input.callerAgentId, remaining);
|
||||
else registering.delete(input.callerAgentId);
|
||||
});
|
||||
},
|
||||
async close() {
|
||||
closed = true;
|
||||
await mutations;
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { expect, onTestFinished, test } from "vitest";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { createIdleRuntimeCleanup, type RuntimeCleanupCandidate } from "./index.js";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((yes) => {
|
||||
resolve = yes;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
class Runtime {
|
||||
readonly identity = {};
|
||||
busy = false;
|
||||
closed = false;
|
||||
resumable = true;
|
||||
activity = 0;
|
||||
parentAgentId: string | undefined;
|
||||
probe: (() => Promise<boolean>) | undefined;
|
||||
admitClose: (() => Promise<void>) | undefined;
|
||||
finishClose: (() => Promise<void>) | undefined;
|
||||
constructor(readonly id = "agent") {}
|
||||
capture(): RuntimeCleanupCandidate {
|
||||
const activity = this.activity;
|
||||
return {
|
||||
identity: this.identity,
|
||||
agentId: this.id,
|
||||
parentAgentId: this.parentAgentId,
|
||||
provider: "test",
|
||||
sessionId: this.resumable ? this.id : null,
|
||||
lastActivityAt: activity,
|
||||
isEligible: () => !this.closed && !this.busy && this.activity === activity,
|
||||
canStopRuntime: this.probe,
|
||||
closeIfEligible: async (eligible) => {
|
||||
await this.admitClose?.();
|
||||
if (!eligible()) return false;
|
||||
this.closed = true;
|
||||
await this.finishClose?.();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
function scenario(enabled = true) {
|
||||
const runtime = new Runtime();
|
||||
const runtimes = [runtime];
|
||||
let time = 0;
|
||||
let protectedCaller = false;
|
||||
let tick!: () => void;
|
||||
const cleanup = createIdleRuntimeCleanup({
|
||||
enabled,
|
||||
logger: createTestLogger(),
|
||||
now: () => time,
|
||||
runtimes: () => runtimes.filter((item) => !item.closed).map((item) => item.capture()),
|
||||
isProtected: () => protectedCaller,
|
||||
schedule: (callback, interval) => {
|
||||
expect(interval).toBe(60_000);
|
||||
tick = callback;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
onTestFinished(() => cleanup.close());
|
||||
return {
|
||||
runtime,
|
||||
runtimes,
|
||||
cleanup,
|
||||
tick: () => tick(),
|
||||
protect: (value: boolean) => {
|
||||
protectedCaller = value;
|
||||
},
|
||||
sweep: (minutes: number) => {
|
||||
time = minutes * 60_000;
|
||||
return cleanup.sweep();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("a runtime without a provider check is collected at exactly one hour", async () => {
|
||||
const s = scenario();
|
||||
expect((await s.sweep(59)).collected).toEqual([]);
|
||||
expect((await s.sweep(60)).collected).toEqual([
|
||||
{ agentId: "agent", provider: "test", sessionId: "agent" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("activity restarts the single one-hour idle period", async () => {
|
||||
const s = scenario();
|
||||
await s.sweep(59);
|
||||
s.runtime.activity = 59 * 60_000;
|
||||
expect((await s.sweep(118)).collected).toEqual([]);
|
||||
expect((await s.sweep(119)).collected).toHaveLength(1);
|
||||
});
|
||||
|
||||
test.each(["foreground", "finish subscription"])(
|
||||
"a positive provider check cannot override %s",
|
||||
async (blocker) => {
|
||||
const s = scenario();
|
||||
let calls = 0;
|
||||
s.runtime.probe = async () => {
|
||||
calls++;
|
||||
return true;
|
||||
};
|
||||
if (blocker === "foreground") s.runtime.busy = true;
|
||||
else s.protect(true);
|
||||
expect((await s.sweep(61)).collected).toEqual([]);
|
||||
expect(calls).toBe(0);
|
||||
s.runtime.busy = false;
|
||||
s.protect(false);
|
||||
expect((await s.sweep(62)).collected).toEqual([]);
|
||||
expect((await s.sweep(121)).collected).toEqual([]);
|
||||
expect((await s.sweep(122)).collected).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["veto", "error"])(
|
||||
"a supported provider %s retains the runtime until a fresh idle hour passes",
|
||||
async (decision) => {
|
||||
const s = scenario();
|
||||
s.runtime.probe = async () => {
|
||||
if (decision === "error") throw new Error("Probe failed");
|
||||
return false;
|
||||
};
|
||||
const blocked = await s.sweep(61);
|
||||
expect(blocked.collected).toEqual([]);
|
||||
expect(blocked.failures).toHaveLength(decision === "error" ? 1 : 0);
|
||||
await s.sweep(119);
|
||||
s.runtime.probe = async () => true;
|
||||
expect((await s.sweep(120)).collected).toEqual([]);
|
||||
expect((await s.sweep(179)).collected).toEqual([]);
|
||||
expect((await s.sweep(180)).collected).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
test("an idle managed descendant with provider-held work vetoes its parent", async () => {
|
||||
const s = scenario();
|
||||
const child = new Runtime("child");
|
||||
child.parentAgentId = s.runtime.id;
|
||||
child.probe = async () => false;
|
||||
s.runtimes.push(child);
|
||||
expect((await s.sweep(61)).collected).toEqual([]);
|
||||
child.probe = async () => true;
|
||||
expect((await s.sweep(62)).collected).toEqual([]);
|
||||
expect((await s.sweep(122)).collected).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("new caller protection during an async provider probe prevents stale collection", async () => {
|
||||
const s = scenario();
|
||||
const probe = deferred<boolean>();
|
||||
s.runtime.probe = () => probe.promise;
|
||||
const collecting = s.sweep(61);
|
||||
s.protect(true);
|
||||
probe.resolve(true);
|
||||
expect((await collecting).collected).toEqual([]);
|
||||
expect(s.runtime.closed).toBe(false);
|
||||
s.protect(false);
|
||||
expect((await s.sweep(62)).collected).toEqual([]);
|
||||
expect((await s.sweep(122)).collected).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("a supported provider timeout retains ownership even if it later returns true", async () => {
|
||||
const s = scenario();
|
||||
const probe = deferred<boolean>();
|
||||
s.runtime.probe = () => probe.promise;
|
||||
const result = await s.sweep(61);
|
||||
expect(result.collected).toEqual([]);
|
||||
expect(result.failures[0]?.error).toEqual(new Error("Runtime safety probe timed out for agent"));
|
||||
probe.resolve(true);
|
||||
expect((await s.sweep(62)).collected).toEqual([]);
|
||||
expect(s.runtime.closed).toBe(false);
|
||||
}, 15_000);
|
||||
|
||||
test("the host opt-out disables explicit sweeps and scheduled ticks", async () => {
|
||||
const s = scenario(false);
|
||||
let calls = 0;
|
||||
s.runtime.probe = async () => {
|
||||
calls++;
|
||||
return true;
|
||||
};
|
||||
expect((await s.sweep(61)).collected).toEqual([]);
|
||||
s.tick();
|
||||
await s.cleanup.close();
|
||||
expect(calls).toBe(0);
|
||||
expect(s.runtime.closed).toBe(false);
|
||||
});
|
||||
|
||||
test("a managed descendant without persistence still protects its parent while working", async () => {
|
||||
const s = scenario();
|
||||
const child = new Runtime("initializing-child");
|
||||
child.parentAgentId = s.runtime.id;
|
||||
child.resumable = false;
|
||||
child.busy = true;
|
||||
s.runtimes.push(child);
|
||||
expect((await s.sweep(61)).collected).toEqual([]);
|
||||
child.busy = false;
|
||||
expect((await s.sweep(62)).collected).toEqual([]);
|
||||
expect((await s.sweep(122)).collected).toEqual([
|
||||
{ agentId: "agent", provider: "test", sessionId: "agent" },
|
||||
]);
|
||||
expect(child.closed).toBe(false);
|
||||
});
|
||||
|
||||
test("the live host switch retains eligible runtimes and re-enables with their existing idle clocks", async () => {
|
||||
const s = scenario();
|
||||
s.cleanup.setEnabled(false);
|
||||
expect((await s.sweep(61)).collected).toEqual([]);
|
||||
expect(s.runtime.closed).toBe(false);
|
||||
s.cleanup.setEnabled(true);
|
||||
s.tick();
|
||||
expect((await s.cleanup.sweep()).collected).toHaveLength(1);
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"disabling during a probe invalidates the sweep (re-enable: %s)",
|
||||
async (reEnable) => {
|
||||
const s = scenario();
|
||||
const probe = deferred<boolean>();
|
||||
s.runtime.probe = () => probe.promise;
|
||||
const collecting = s.sweep(61);
|
||||
s.cleanup.setEnabled(false);
|
||||
if (reEnable) s.cleanup.setEnabled(true);
|
||||
probe.resolve(true);
|
||||
expect((await collecting).collected).toEqual([]);
|
||||
expect(s.runtime.closed).toBe(false);
|
||||
s.cleanup.setEnabled(true);
|
||||
expect((await s.sweep(62)).collected).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([false, true])(
|
||||
"disabling while waiting for lifecycle admission prevents closure (re-enable: %s)",
|
||||
async (reEnable) => {
|
||||
const s = scenario();
|
||||
const admitted = deferred<void>();
|
||||
const waiting = deferred<void>();
|
||||
s.runtime.admitClose = () => {
|
||||
waiting.resolve();
|
||||
return admitted.promise;
|
||||
};
|
||||
const collecting = s.sweep(61);
|
||||
await waiting.promise;
|
||||
s.cleanup.setEnabled(false);
|
||||
if (reEnable) s.cleanup.setEnabled(true);
|
||||
admitted.resolve();
|
||||
expect((await collecting).collected).toEqual([]);
|
||||
expect(s.runtime.closed).toBe(false);
|
||||
s.cleanup.setEnabled(true);
|
||||
expect((await s.sweep(62)).collected).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["veto", "error"])(
|
||||
"an invalidated provider %s does not reset the idle clock",
|
||||
async (outcome) => {
|
||||
const s = scenario();
|
||||
const probe = deferred<boolean>();
|
||||
s.runtime.probe = async () => {
|
||||
await probe.promise;
|
||||
if (outcome === "error") throw new Error("Old probe failed");
|
||||
return false;
|
||||
};
|
||||
const collecting = s.sweep(61);
|
||||
s.cleanup.setEnabled(false);
|
||||
s.cleanup.setEnabled(true);
|
||||
probe.resolve(false);
|
||||
expect(await collecting).toEqual({ collected: [], failures: [] });
|
||||
s.runtime.probe = async () => true;
|
||||
expect((await s.sweep(62)).collected).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
test("disabling lets a started close finish but prevents the next closure in that sweep", async () => {
|
||||
const s = scenario();
|
||||
const sibling = new Runtime("sibling");
|
||||
s.runtimes.push(sibling);
|
||||
const closing = deferred<void>();
|
||||
const finished = deferred<void>();
|
||||
s.runtime.finishClose = () => {
|
||||
closing.resolve();
|
||||
return finished.promise;
|
||||
};
|
||||
const collecting = s.sweep(61);
|
||||
await closing.promise;
|
||||
expect(s.runtime.closed).toBe(true);
|
||||
s.cleanup.setEnabled(false);
|
||||
finished.resolve();
|
||||
expect((await collecting).collected).toEqual([
|
||||
{ agentId: "agent", provider: "test", sessionId: "agent" },
|
||||
]);
|
||||
expect(sibling.closed).toBe(false);
|
||||
s.cleanup.setEnabled(true);
|
||||
expect((await s.sweep(62)).collected).toEqual([
|
||||
{ agentId: "sibling", provider: "test", sessionId: "sibling" },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { Logger } from "pino";
|
||||
import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||
|
||||
/** A captured runtime capability. Its lifecycle owner revalidates before releasing it. */
|
||||
export interface RuntimeCleanupCandidate {
|
||||
readonly identity: object;
|
||||
readonly agentId: string;
|
||||
readonly parentAgentId: string | undefined;
|
||||
readonly provider: string;
|
||||
readonly sessionId: string | null;
|
||||
readonly lastActivityAt: number;
|
||||
isEligible(): boolean;
|
||||
canStopRuntime?(): Promise<boolean>;
|
||||
closeIfEligible(revalidate: () => boolean): Promise<boolean>;
|
||||
}
|
||||
interface Options {
|
||||
enabled: boolean;
|
||||
runtimes(): readonly RuntimeCleanupCandidate[];
|
||||
isProtected?(agentId: string): boolean;
|
||||
logger: Pick<Logger, "debug" | "info" | "warn">;
|
||||
now?: () => number;
|
||||
schedule?: (tick: () => void, intervalMs: number) => () => void;
|
||||
}
|
||||
interface CollectionEntry {
|
||||
agentId: string;
|
||||
provider: string;
|
||||
sessionId: string;
|
||||
}
|
||||
interface CollectionResult {
|
||||
collected: CollectionEntry[];
|
||||
failures: Array<{ agentId: string; error: unknown }>;
|
||||
}
|
||||
const IDLE_PERIOD_MS = 60 * 60_000;
|
||||
|
||||
/** Owns idle clocks, baseline eligibility, optional provider vetoes and the sweep lifetime. */
|
||||
export function createIdleRuntimeCleanup(options: Options) {
|
||||
const protectedAt = new WeakMap<object, { at: number; protected: boolean }>();
|
||||
let collection: Promise<CollectionResult> | null = null;
|
||||
let stopped = false;
|
||||
let enabled = options.enabled;
|
||||
let generation = 0;
|
||||
const isProtected = options.isProtected ?? (() => false);
|
||||
const now = options.now ?? Date.now;
|
||||
|
||||
function familyOf(root: RuntimeCleanupCandidate, runtimes: readonly RuntimeCleanupCandidate[]) {
|
||||
const family = new Map([[root.agentId, root]]);
|
||||
for (const parent of family.values()) {
|
||||
for (const child of runtimes) {
|
||||
if (child.parentAgentId === parent.agentId) family.set(child.agentId, child);
|
||||
}
|
||||
}
|
||||
return [...family.values()];
|
||||
}
|
||||
function baselineAllows(family: readonly RuntimeCleanupCandidate[]) {
|
||||
return family.every((runtime) => runtime.isEligible() && !isProtected(runtime.agentId));
|
||||
}
|
||||
async function providersAllow(
|
||||
family: readonly RuntimeCleanupCandidate[],
|
||||
isCurrent: () => boolean,
|
||||
) {
|
||||
for (const runtime of family) {
|
||||
if (!isCurrent()) return false;
|
||||
if (!runtime.canStopRuntime) continue;
|
||||
const allowed = await withTimeout(
|
||||
runtime.canStopRuntime(),
|
||||
10_000,
|
||||
`Runtime safety probe timed out for ${runtime.agentId}`,
|
||||
);
|
||||
if (allowed !== true) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
async function collect(): Promise<CollectionResult> {
|
||||
const result: CollectionResult = { collected: [], failures: [] };
|
||||
const sweepGeneration = generation;
|
||||
const isCurrent = () => enabled && !stopped && generation === sweepGeneration;
|
||||
if (!isCurrent()) return result;
|
||||
const timestamp = now();
|
||||
const runtimes = options.runtimes();
|
||||
for (const runtime of runtimes) {
|
||||
if (!isCurrent()) break;
|
||||
if (!runtime.sessionId) continue;
|
||||
const family = familyOf(runtime, runtimes);
|
||||
try {
|
||||
const allowed = baselineAllows(family) && (await providersAllow(family, isCurrent));
|
||||
if (!isCurrent()) break;
|
||||
if (!allowed) {
|
||||
protectedAt.set(runtime.identity, { at: timestamp, protected: true });
|
||||
options.logger.debug(
|
||||
{ agentId: runtime.agentId },
|
||||
"Idle runtime retained by a baseline or provider veto",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const previous = protectedAt.get(runtime.identity);
|
||||
if (previous?.protected)
|
||||
protectedAt.set(runtime.identity, { at: timestamp, protected: false });
|
||||
const idleSince = Math.max(
|
||||
runtime.lastActivityAt,
|
||||
protectedAt.get(runtime.identity)?.at ?? 0,
|
||||
);
|
||||
if (timestamp - idleSince < IDLE_PERIOD_MS) continue;
|
||||
const revalidate = () => {
|
||||
const currentFamily = familyOf(runtime, options.runtimes());
|
||||
return (
|
||||
isCurrent() &&
|
||||
currentFamily.length === family.length &&
|
||||
currentFamily.every((current) =>
|
||||
family.some((captured) => captured.identity === current.identity),
|
||||
) &&
|
||||
baselineAllows(family)
|
||||
);
|
||||
};
|
||||
if (await runtime.closeIfEligible(revalidate)) {
|
||||
const entry = {
|
||||
agentId: runtime.agentId,
|
||||
provider: runtime.provider,
|
||||
sessionId: runtime.sessionId,
|
||||
};
|
||||
result.collected.push(entry);
|
||||
options.logger.info(entry, "Collected idle agent runtime");
|
||||
} else if (isCurrent()) {
|
||||
protectedAt.set(runtime.identity, { at: timestamp, protected: true });
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCurrent()) break;
|
||||
protectedAt.set(runtime.identity, { at: timestamp, protected: true });
|
||||
result.failures.push({ agentId: runtime.agentId, error });
|
||||
options.logger.warn(
|
||||
{ agentId: runtime.agentId, err: error },
|
||||
"Idle runtime cleanup failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function sweep(): Promise<CollectionResult> {
|
||||
if (collection) return collection;
|
||||
collection = collect().finally(() => {
|
||||
collection = null;
|
||||
});
|
||||
return collection;
|
||||
}
|
||||
const schedule =
|
||||
options.schedule ??
|
||||
((tick, intervalMs) => {
|
||||
const timer = setInterval(tick, intervalMs);
|
||||
timer.unref();
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
const cancel = schedule(() => {
|
||||
void sweep().catch((error) => options.logger.warn({ err: error }, "Idle runtime sweep failed"));
|
||||
}, 60_000);
|
||||
return {
|
||||
sweep,
|
||||
setEnabled(value: boolean) {
|
||||
if (enabled === value) return;
|
||||
enabled = value;
|
||||
generation++;
|
||||
},
|
||||
async close() {
|
||||
stopped = true;
|
||||
cancel();
|
||||
await collection;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -32,7 +32,7 @@ class FakePermissionAgentManager {
|
||||
return this.permissionResult;
|
||||
}
|
||||
|
||||
tryRunOutOfBand(): boolean {
|
||||
async tryRunOutOfBand(): Promise<boolean> {
|
||||
return this.outOfBandHandled;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js";
|
||||
import { relative } from "node:path";
|
||||
import type {
|
||||
ProviderConnection,
|
||||
@@ -1234,3 +1239,85 @@ test("config-required plugin membership is independent of enablement and other p
|
||||
await plugins.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([true, false, "error", "wrong-session", "missing"] as const)(
|
||||
"plugin runtime safety query: %s",
|
||||
async (answer) => {
|
||||
const harness = createProviderHarness({
|
||||
capabilities:
|
||||
answer === "missing" ? CAPABILITIES : [...CAPABILITIES, "session.can_stop_runtime"],
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type !== "session.can_stop_runtime") return false;
|
||||
if (answer === "error")
|
||||
emit({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: "Probe failed" },
|
||||
});
|
||||
else
|
||||
emit({
|
||||
type: "session.can_stop_runtime",
|
||||
requestId: input.requestId,
|
||||
sessionId: answer === "wrong-session" ? "child-1" : input.sessionId,
|
||||
canStop: answer === true,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const client = registry.clients()[harness.registration.id]!;
|
||||
const session = await client.createSession({ provider: harness.registration.id, cwd: "/tmp" });
|
||||
try {
|
||||
if (answer === "error" || answer === "wrong-session") {
|
||||
await expect(session.canStopRuntime!()).rejects.toThrow(
|
||||
answer === "error" ? "Probe failed" : "another session",
|
||||
);
|
||||
} else if (answer === "missing") expect(session.canStopRuntime).toBeUndefined();
|
||||
else expect(await session.canStopRuntime!()).toBe(answer === true);
|
||||
const requests = harness.inputs.filter((input) => input.type === "session.can_stop_runtime");
|
||||
expect(requests).toHaveLength(answer === "missing" ? 0 : 1);
|
||||
if (answer !== "missing")
|
||||
expect(requests[0]).toMatchObject({
|
||||
sessionId: harness.inputs.find((input) => input.type === "session.open")?.sessionId,
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
await registry.shutdown();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("plugin without a runtime safety capability uses baseline collection without a query", async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), "plugin-baseline-cleanup-"));
|
||||
const logger = createTestLogger();
|
||||
const storage = new AgentStorage(join(home, "agents"), logger);
|
||||
const harness = createProviderHarness();
|
||||
const registry = new PluginAgentClientRegistry(logger);
|
||||
registry.replace([harness.registration]);
|
||||
const manager = new AgentManager({ clients: registry.clients(), registry: storage, logger });
|
||||
const agent = await manager.createAgent(
|
||||
{ provider: harness.registration.id, cwd: home },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
const cleanup = createIdleRuntimeCleanup({
|
||||
enabled: true,
|
||||
logger,
|
||||
runtimes: () => manager.getRuntimeCleanupCandidates(),
|
||||
now: () => agent.updatedAt.getTime() + 60 * 60_000,
|
||||
schedule: () => () => {},
|
||||
});
|
||||
try {
|
||||
expect(agent.session?.canStopRuntime).toBeUndefined();
|
||||
expect((await cleanup.sweep()).collected).toHaveLength(1);
|
||||
expect(manager.getAgent(agent.id)).toBeNull();
|
||||
expect(harness.inputs.filter((input) => input.type === "session.can_stop_runtime")).toEqual([]);
|
||||
} finally {
|
||||
await cleanup.close();
|
||||
await Promise.all(manager.listAgents().map((item) => manager.closeAgent(item.id)));
|
||||
await registry.shutdown();
|
||||
await storage.flush();
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -113,9 +113,15 @@ function providerError(error: ProviderError): Error {
|
||||
|
||||
function isProviderRequestReply(
|
||||
event: ProviderEvent,
|
||||
): event is Extract<ProviderEvent, { type: "request.completed" | "catalog" | "sessions" }> {
|
||||
): event is Extract<
|
||||
ProviderEvent,
|
||||
{ type: "request.completed" | "catalog" | "sessions" | "session.can_stop_runtime" }
|
||||
> {
|
||||
return (
|
||||
event.type === "request.completed" || event.type === "catalog" || event.type === "sessions"
|
||||
event.type === "request.completed" ||
|
||||
event.type === "catalog" ||
|
||||
event.type === "sessions" ||
|
||||
event.type === "session.can_stop_runtime"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -564,10 +570,21 @@ class ProviderRuntime {
|
||||
}
|
||||
|
||||
private finishRequest(
|
||||
event: Extract<ProviderEvent, { type: "request.completed" | "catalog" | "sessions" }>,
|
||||
event: Extract<
|
||||
ProviderEvent,
|
||||
{ type: "request.completed" | "catalog" | "sessions" | "session.can_stop_runtime" }
|
||||
>,
|
||||
): void {
|
||||
const request = this.requests.get(event.requestId);
|
||||
if (!request) return;
|
||||
if (
|
||||
event.type === "session.can_stop_runtime" &&
|
||||
this.providerSessions.get(event.sessionId)?.id !== request.sessionId
|
||||
) {
|
||||
this.requests.delete(event.requestId);
|
||||
request.reject(new Error("Runtime safety response belongs to another session"));
|
||||
return;
|
||||
}
|
||||
this.requests.delete(event.requestId);
|
||||
request.resolve(event);
|
||||
}
|
||||
@@ -635,6 +652,27 @@ class ProviderRuntimeSession {
|
||||
}
|
||||
}
|
||||
|
||||
async canStopRuntime(): Promise<boolean> {
|
||||
if (
|
||||
this.terminal ||
|
||||
this.prompts.size ||
|
||||
this.activeTurnIds.size ||
|
||||
!this.capabilities.includes("session.can_stop_runtime")
|
||||
)
|
||||
return false;
|
||||
const event = await this.runtime.complete(
|
||||
{
|
||||
type: "session.can_stop_runtime",
|
||||
requestId: randomUUID(),
|
||||
sessionId: this.providerSessionId,
|
||||
},
|
||||
AbortSignal.timeout(10_000),
|
||||
);
|
||||
if (event.type !== "session.can_stop_runtime")
|
||||
throw new Error("Invalid runtime safety response");
|
||||
return event.canStop;
|
||||
}
|
||||
|
||||
async configure(changes: ProviderConfigChanges): Promise<void> {
|
||||
const input: Extract<ProviderInput, { type: "session.configure" }> = {
|
||||
type: "session.configure",
|
||||
@@ -1201,6 +1239,11 @@ class PluginAgentSession implements AgentSession {
|
||||
for (const event of this.history) yield event;
|
||||
}
|
||||
|
||||
get canStopRuntime(): (() => Promise<boolean>) | undefined {
|
||||
if (!this.bridge.negotiatedCapabilities.includes("session.can_stop_runtime")) return undefined;
|
||||
return () => this.bridge.canStopRuntime();
|
||||
}
|
||||
|
||||
async getRuntimeInfo() {
|
||||
return {
|
||||
provider: this.provider,
|
||||
|
||||
@@ -469,6 +469,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
|
||||
describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()),
|
||||
interrupt: () => inner.interrupt(),
|
||||
close: () => inner.close(),
|
||||
canStopRuntime: inner.canStopRuntime?.bind(inner),
|
||||
listCommands: inner.listCommands?.bind(inner),
|
||||
setModel: inner.setModel?.bind(inner),
|
||||
setThinkingOption: inner.setThinkingOption?.bind(inner),
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
resolveACPModelSelection,
|
||||
summarizeACPRequestError,
|
||||
} from "./acp-agent.js";
|
||||
import { createIdleRuntimeCleanup } from "../idle-runtime-cleanup/index.js";
|
||||
import type { ProcessTerminator, TreeKillTarget } from "../../../utils/tree-kill.js";
|
||||
import {
|
||||
COPILOT_AGENT_FEATURE_OPTION,
|
||||
@@ -4424,6 +4425,161 @@ new AgentSideConnection(
|
||||
);
|
||||
`;
|
||||
|
||||
describe("ACP idle runtime cleanup", () => {
|
||||
test("pending permissions veto cleanup until answered", async () => {
|
||||
const session = createSession();
|
||||
try {
|
||||
expect(await session.canStopRuntime()).toBe(true);
|
||||
const permission = session.requestPermission({
|
||||
sessionId: "session-1",
|
||||
toolCall: { toolCallId: "tool-1", title: "Run command", status: "pending" },
|
||||
options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }],
|
||||
});
|
||||
expect(await session.canStopRuntime()).toBe(false);
|
||||
await session.respondToPermission(session.getPendingPermissions()[0]!.id, {
|
||||
behavior: "allow",
|
||||
});
|
||||
await permission;
|
||||
expect(await session.canStopRuntime()).toBe(true);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a failed terminal spawn does not pin the runtime or discard its diagnostic", async () => {
|
||||
const session = createSession();
|
||||
try {
|
||||
const terminal = await session.createTerminal({
|
||||
sessionId: "session-1",
|
||||
command: path.join(tmpdir(), "paseo-no-such-command", "missing"),
|
||||
});
|
||||
await expect(
|
||||
session.waitForTerminalExit({ sessionId: "session-1", ...terminal }),
|
||||
).rejects.toThrow("ENOENT");
|
||||
expect(await session.canStopRuntime()).toBe(true);
|
||||
expect(
|
||||
(await session.terminalOutput({ sessionId: "session-1", ...terminal })).output,
|
||||
).toContain("ENOENT");
|
||||
await session.releaseTerminal({ sessionId: "session-1", ...terminal });
|
||||
expect(await session.canStopRuntime()).toBe(true);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("retains a quiet terminal after a finished turn, then waits a fresh idle hour after exit", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "paseo-acp-idle-"));
|
||||
const agentScript = path.join(dir, "agent.mjs");
|
||||
await writeFile(agentScript, PERMISSIVE_MODEL_ACP_AGENT);
|
||||
const session = new ACPAgentSession(
|
||||
{ provider: "test-acp", cwd: dir },
|
||||
{
|
||||
provider: "test-acp",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: [process.execPath, agentScript],
|
||||
defaultModes: [],
|
||||
capabilities: { supportsStreaming: true, supportsSessionPersistence: true },
|
||||
launchEnv: {
|
||||
ACP_SDK_URL: pathToFileURL(
|
||||
createRequire(import.meta.url).resolve("@agentclientprotocol/sdk"),
|
||||
).href,
|
||||
},
|
||||
},
|
||||
);
|
||||
const hour = 60 * 60_000;
|
||||
let now = 0;
|
||||
let closed = false;
|
||||
const candidate = {
|
||||
identity: session,
|
||||
agentId: "test-agent",
|
||||
parentAgentId: undefined,
|
||||
provider: session.provider,
|
||||
sessionId: "permissive-session",
|
||||
lastActivityAt: 0,
|
||||
isEligible: () => !closed,
|
||||
canStopRuntime: (session as AgentSession).canStopRuntime?.bind(session),
|
||||
async closeIfEligible(revalidate: () => boolean) {
|
||||
if (!revalidate()) return false;
|
||||
await session.close();
|
||||
closed = true;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const cleanup = createIdleRuntimeCleanup({
|
||||
enabled: true,
|
||||
now: () => now,
|
||||
runtimes: () => (closed ? [] : [candidate]),
|
||||
schedule: () => () => {},
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
try {
|
||||
await session.initializeNewSession();
|
||||
const completed = new Promise<void>((resolve) =>
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "turn_completed") resolve();
|
||||
}),
|
||||
);
|
||||
let activeTurnSafety: Promise<boolean> | undefined;
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "turn_started") activeTurnSafety = session.canStopRuntime();
|
||||
});
|
||||
await session.startTurn("Finish this foreground turn");
|
||||
await expect(activeTurnSafety).resolves.toBe(false);
|
||||
await completed;
|
||||
expect(await session.canStopRuntime()).toBe(true);
|
||||
await session.sessionUpdate({
|
||||
sessionId: candidate.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "text", text: "buffered" },
|
||||
},
|
||||
});
|
||||
expect(await session.canStopRuntime()).toBe(false);
|
||||
await session.sessionUpdate({
|
||||
sessionId: candidate.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "flush" },
|
||||
},
|
||||
});
|
||||
expect(await session.canStopRuntime()).toBe(true);
|
||||
const terminal = await session.createTerminal({
|
||||
sessionId: candidate.sessionId,
|
||||
command: process.execPath,
|
||||
args: ["-e", "setInterval(() => {}, 1000)"],
|
||||
});
|
||||
now = 2 * hour;
|
||||
expect((await cleanup.sweep()).collected).toEqual([]);
|
||||
expect(closed).toBe(false);
|
||||
expect(await session.terminalOutput({ sessionId: candidate.sessionId, ...terminal })).toEqual(
|
||||
{
|
||||
output: "",
|
||||
truncated: false,
|
||||
exitStatus: undefined,
|
||||
},
|
||||
);
|
||||
await session.killTerminal({ sessionId: candidate.sessionId, ...terminal });
|
||||
await session.waitForTerminalExit({ sessionId: candidate.sessionId, ...terminal });
|
||||
expect((await cleanup.sweep()).collected).toEqual([]);
|
||||
now += hour - 1;
|
||||
expect((await cleanup.sweep()).collected).toEqual([]);
|
||||
now += 1;
|
||||
expect((await cleanup.sweep()).collected).toEqual([
|
||||
{
|
||||
agentId: candidate.agentId,
|
||||
provider: candidate.provider,
|
||||
sessionId: candidate.sessionId,
|
||||
},
|
||||
]);
|
||||
expect(closed).toBe(true);
|
||||
} finally {
|
||||
await cleanup.close();
|
||||
await session.close();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom ACP provider with configured models", () => {
|
||||
let dir: string;
|
||||
let agentScript: string;
|
||||
|
||||
@@ -649,6 +649,7 @@ interface TerminalExit {
|
||||
}
|
||||
|
||||
interface TerminalEntry {
|
||||
running: boolean;
|
||||
id: string;
|
||||
child: ChildProcess;
|
||||
output: string;
|
||||
@@ -2554,6 +2555,15 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
}
|
||||
|
||||
async canStopRuntime(): Promise<boolean> {
|
||||
return (
|
||||
this.activeForegroundTurnId === null &&
|
||||
this.pendingPermissions.size === 0 &&
|
||||
this.pendingUserMessage === null &&
|
||||
!Array.from(this.terminalEntries.values()).some((terminal) => terminal.running)
|
||||
);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) {
|
||||
return;
|
||||
@@ -2589,12 +2599,14 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
}
|
||||
|
||||
const terminalTerminations = Array.from(this.terminalEntries.values(), (terminal) =>
|
||||
this.terminateProcess(terminal.child, {
|
||||
gracefulTimeoutMs: 2_000,
|
||||
forceTimeoutMs: 2_000,
|
||||
}),
|
||||
);
|
||||
const terminalTerminations = Array.from(this.terminalEntries.values())
|
||||
.filter((terminal) => terminal.running)
|
||||
.map((terminal) =>
|
||||
this.terminateProcess(terminal.child, {
|
||||
gracefulTimeoutMs: 2_000,
|
||||
forceTimeoutMs: 2_000,
|
||||
}),
|
||||
);
|
||||
await Promise.all(terminalTerminations);
|
||||
this.terminalEntries.clear();
|
||||
|
||||
@@ -2796,6 +2808,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
waitForExit.catch(() => undefined);
|
||||
|
||||
const entry: TerminalEntry = {
|
||||
running: true,
|
||||
id: terminalId,
|
||||
child,
|
||||
output: "",
|
||||
@@ -2816,10 +2829,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
child.once("error", (error) => {
|
||||
const spawnError = error instanceof Error ? error : new Error(String(error));
|
||||
appendTerminalOutput(entry, `${spawnError.message}\n`);
|
||||
// A failed spawn has no exit event. Other process errors do not prove exit.
|
||||
if (child.pid === undefined) entry.running = false;
|
||||
rejectExit(spawnError);
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
const exit = { exitCode: code, signal };
|
||||
entry.running = false;
|
||||
entry.exit = exit;
|
||||
resolveExit(exit);
|
||||
});
|
||||
@@ -2844,7 +2860,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
|
||||
async releaseTerminal(params: { sessionId: string; terminalId: string }): Promise<void> {
|
||||
const entry = this.getTerminalEntry(params.terminalId);
|
||||
if (!entry.exit) {
|
||||
if (entry.running) {
|
||||
await this.terminateProcess(entry.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 });
|
||||
}
|
||||
this.terminalEntries.delete(params.terminalId);
|
||||
@@ -2852,7 +2868,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
|
||||
async killTerminal(params: KillTerminalRequest): Promise<Record<string, never>> {
|
||||
const entry = this.getTerminalEntry(params.terminalId);
|
||||
if (!entry.exit) {
|
||||
if (entry.running) {
|
||||
await this.terminateProcess(entry.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 });
|
||||
}
|
||||
return {};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
|
||||
import type { Query, SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -706,3 +708,137 @@ describe("background Claude subagents", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("Claude runtime safety protects silent shells, monitors and pending completion notifications", () => {
|
||||
const safety = new ClaudeRuntimeSafety();
|
||||
const observe = (message: unknown) => safety.observe(message as SDKMessage);
|
||||
observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" });
|
||||
expect(safety.canStopRuntime()).toBe(true);
|
||||
observe({
|
||||
type: "system",
|
||||
subtype: "background_tasks_changed",
|
||||
tasks: [
|
||||
{ task_id: "quiet-shell", task_type: "local_bash", description: "sleep 7200" },
|
||||
{ task_id: "monitor", task_type: "local_bash", description: "watch build" },
|
||||
{ task_id: "native-child", task_type: "local_agent", description: "research" },
|
||||
],
|
||||
});
|
||||
expect(safety.canStopRuntime()).toBe(false);
|
||||
observe({ type: "system", subtype: "background_tasks_changed", tasks: [] });
|
||||
expect(safety.canStopRuntime()).toBe(false);
|
||||
for (const task_id of ["quiet-shell", "monitor"])
|
||||
observe({ type: "system", subtype: "task_notification", task_id });
|
||||
expect(safety.canStopRuntime()).toBe(false);
|
||||
observe({ type: "system", subtype: "task_notification", task_id: "native-child" });
|
||||
expect(safety.canStopRuntime()).toBe(true);
|
||||
safety.reset();
|
||||
observe({ type: "system", subtype: "init", claude_code_version: "2.1.220" });
|
||||
expect(safety.canStopRuntime()).toBe(false);
|
||||
observe({ type: "system", subtype: "background_tasks_changed", tasks: [] });
|
||||
expect(safety.canStopRuntime()).toBe(true);
|
||||
});
|
||||
|
||||
test("Claude session runtime safety observes pumped background work and autonomous replies", async () => {
|
||||
let wake: (() => void) | undefined;
|
||||
const channel = { closed: false };
|
||||
const queue: Array<{ message: SDKMessage; consumed(): void }> = [];
|
||||
const pump = (async function* () {
|
||||
while (!channel.closed) {
|
||||
const entry = queue.shift();
|
||||
if (!entry) {
|
||||
await new Promise<void>((resolve) => {
|
||||
wake = resolve;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
yield entry.message;
|
||||
entry.consumed();
|
||||
}
|
||||
})();
|
||||
const push = (message: unknown) =>
|
||||
new Promise<void>((consumed) => {
|
||||
queue.push({ message: message as SDKMessage, consumed });
|
||||
wake?.();
|
||||
});
|
||||
const end = () => {
|
||||
channel.closed = true;
|
||||
wake?.();
|
||||
};
|
||||
let nativeInput!: AsyncIterator<SDKUserMessage>;
|
||||
let connected!: () => void;
|
||||
const connection = new Promise<void>((resolve) => {
|
||||
connected = resolve;
|
||||
});
|
||||
// The SDK Query is injected through the production adapter's existing launch port.
|
||||
const query = {
|
||||
next: () => pump.next(),
|
||||
return: async () => {
|
||||
end();
|
||||
return pump.return(undefined);
|
||||
},
|
||||
close: end,
|
||||
interrupt: async () => {},
|
||||
setPermissionMode: async () => {},
|
||||
setModel: async () => {},
|
||||
supportedModels: async () => [],
|
||||
supportedCommands: async () => [],
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
} as unknown as Query;
|
||||
const session = await new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
queryFactory: ({ prompt }) => {
|
||||
if (typeof prompt === "string") throw new Error("Expected streaming input");
|
||||
nativeInput = prompt[Symbol.asyncIterator]();
|
||||
connected();
|
||||
return query;
|
||||
},
|
||||
}).createSession({ provider: "claude", cwd: process.cwd() });
|
||||
const init = {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "runtime-safety",
|
||||
claude_code_version: "2.1.292",
|
||||
permissionMode: "default",
|
||||
};
|
||||
const result = {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
total_cost_usd: 0,
|
||||
};
|
||||
try {
|
||||
await session.startTurn("start background work");
|
||||
await connection;
|
||||
const input = await nativeInput.next();
|
||||
await push({ type: "command_lifecycle", command_uuid: input.value.uuid, state: "started" });
|
||||
await push(init);
|
||||
await push(result);
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
await push({
|
||||
type: "system",
|
||||
subtype: "background_tasks_changed",
|
||||
tasks: [{ task_id: "monitor", task_type: "local_bash", description: "quiet monitor" }],
|
||||
});
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
await push({ type: "system", subtype: "background_tasks_changed", tasks: [] });
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
await push({
|
||||
type: "system",
|
||||
subtype: "task_notification",
|
||||
task_id: "monitor",
|
||||
status: "completed",
|
||||
output: "ready",
|
||||
summary: "ready",
|
||||
});
|
||||
// Completion can wake Claude autonomously after the foreground turn has ended.
|
||||
await push(init);
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
await push(result);
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
|
||||
import { validateProviderOptions } from "../../provider-options.js";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -2255,6 +2256,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private readonly queryFactory?: ClaudeQueryFactory;
|
||||
private readonly resolveBinary: () => Promise<string>;
|
||||
private query: Query | null = null;
|
||||
private readonly runtimeSafety = new ClaudeRuntimeSafety();
|
||||
private processingSdkMessages = 0;
|
||||
private readonly harnessEnvironment: Record<string, string>;
|
||||
private readonly usageSessionKey = randomUUID();
|
||||
private childProcess: ChildProcess | null = null;
|
||||
@@ -2931,6 +2934,18 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return this.persistence;
|
||||
}
|
||||
|
||||
async canStopRuntime(): Promise<boolean> {
|
||||
return (
|
||||
!this.activeForegroundTurnId &&
|
||||
!this.autonomousTurn &&
|
||||
!this.mainTurnInFlight &&
|
||||
this.unstartedMessageUuids.size === 0 &&
|
||||
this.pendingPermissions.size === 0 &&
|
||||
this.processingSdkMessages === 0 &&
|
||||
this.runtimeSafety.canStopRuntime()
|
||||
);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.logger.trace(
|
||||
{
|
||||
@@ -3424,6 +3439,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const options = await this.buildOptions(launchMode);
|
||||
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
|
||||
this.input = input;
|
||||
this.runtimeSafety.reset();
|
||||
// A fresh Claude process has no turn of its own in flight.
|
||||
this.mainTurnInFlight = false;
|
||||
this.query = claudeQuery(
|
||||
@@ -4079,13 +4095,17 @@ class ClaudeAgentSession implements AgentSession {
|
||||
);
|
||||
};
|
||||
const handlePumpedMessage = async (message: SDKMessage): Promise<boolean> => {
|
||||
logRawMessage(message);
|
||||
consecutiveInterruptAbortRecoveries = 0;
|
||||
if (await this.handleMissingResumedConversation(message, activeQuery)) {
|
||||
return true;
|
||||
this.processingSdkMessages++;
|
||||
try {
|
||||
this.runtimeSafety.observe(message);
|
||||
logRawMessage(message);
|
||||
consecutiveInterruptAbortRecoveries = 0;
|
||||
if (await this.handleMissingResumedConversation(message, activeQuery)) return true;
|
||||
await this.routeSdkMessageFromPump(message);
|
||||
return false;
|
||||
} finally {
|
||||
this.processingSdkMessages--;
|
||||
}
|
||||
await this.routeSdkMessageFromPump(message);
|
||||
return false;
|
||||
};
|
||||
const drainActiveQuery = async (): Promise<boolean> => {
|
||||
for await (const message of activeQuery) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { parseClaudeCodeVersion } from "./model-manifest.js";
|
||||
|
||||
/** Process-local background ownership. Completion delivery can follow an empty roster. */
|
||||
export class ClaudeRuntimeSafety {
|
||||
private supported = false;
|
||||
private liveTasks = new Set<string>();
|
||||
private readonly awaitingNotifications = new Set<string>();
|
||||
|
||||
reset(): void {
|
||||
this.supported = false;
|
||||
this.liveTasks.clear();
|
||||
this.awaitingNotifications.clear();
|
||||
}
|
||||
|
||||
observe(message: SDKMessage): void {
|
||||
if (message.type !== "system") return;
|
||||
if (message.subtype === "init") {
|
||||
// 2.1.292 is a known roster-capable release. Older binaries can demonstrate
|
||||
// support by sending a roster; a fresh process starts with an empty roster.
|
||||
const version = parseClaudeCodeVersion(message.claude_code_version ?? "");
|
||||
if (
|
||||
version &&
|
||||
(version[0] > 2 ||
|
||||
(version[0] === 2 && (version[1] > 1 || (version[1] === 1 && version[2] >= 292))))
|
||||
)
|
||||
this.supported = true;
|
||||
}
|
||||
if (message.subtype === "background_tasks_changed") {
|
||||
this.supported = true;
|
||||
this.liveTasks = new Set(message.tasks.map((task) => task.task_id));
|
||||
for (const id of this.liveTasks) this.awaitingNotifications.add(id);
|
||||
}
|
||||
if (message.subtype === "task_notification") {
|
||||
this.awaitingNotifications.delete(message.task_id);
|
||||
}
|
||||
}
|
||||
|
||||
canStopRuntime(): boolean {
|
||||
return this.supported && this.liveTasks.size === 0 && this.awaitingNotifications.size === 0;
|
||||
}
|
||||
}
|
||||
@@ -7167,3 +7167,35 @@ describe("Codex interactive resume startup", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("Codex runtime safety queries live terminals and retains unsupported app servers", async () => {
|
||||
let response: unknown = { data: [], nextCursor: null };
|
||||
const appServer = createFakeCodexAppServer({ "thread/backgroundTerminals/list": () => response });
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig(),
|
||||
null,
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
try {
|
||||
await session.startTurn("hello");
|
||||
await appServer.waitForTurnStart();
|
||||
expect(await session.canStopRuntime()).toBe(false);
|
||||
appServer.completeTurn({ threadId: "thread-1" });
|
||||
await expect.poll(() => session.canStopRuntime()).toBe(true);
|
||||
response = { data: [{ processId: "shell-1" }], nextCursor: null };
|
||||
expect(await session.canStopRuntime()).toBe(false);
|
||||
response = {};
|
||||
await expect(session.canStopRuntime()).rejects.toThrow();
|
||||
response = { __jsonRpcError: { code: -32601, message: "Method not found" } };
|
||||
await expect(session.canStopRuntime()).rejects.toThrow("Method not found");
|
||||
expect(
|
||||
appServer
|
||||
.requests()
|
||||
.findLast((request) => request.method === "thread/backgroundTerminals/list"),
|
||||
).toMatchObject({ params: { threadId: "thread-1" } });
|
||||
appServer.assertNoErrors();
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5029,6 +5029,34 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
async canStopRuntime(): Promise<boolean> {
|
||||
if (
|
||||
!this.client ||
|
||||
this.client.hasPendingRequests() ||
|
||||
!this.currentThreadId ||
|
||||
this.activeForegroundTurnId ||
|
||||
this.currentTurnId ||
|
||||
this.pendingForegroundStart ||
|
||||
this.pendingPermissionHandlers.size ||
|
||||
this.getPendingPermissions().length ||
|
||||
this.pendingSubAgentNotificationsByThreadId.size
|
||||
)
|
||||
return false;
|
||||
// Older app servers reject this request. A failed or malformed probe must retain
|
||||
// the process: command completion alone does not mean its terminal has exited.
|
||||
const loaded = z
|
||||
.object({ data: z.array(z.string()) })
|
||||
.parse(await this.client.request("thread/loaded/list", {}, 10_000));
|
||||
const threadIds = new Set([this.currentThreadId, ...loaded.data]);
|
||||
for (const threadId of threadIds) {
|
||||
const response = z
|
||||
.object({ data: z.array(z.unknown()), nextCursor: z.string().nullable() })
|
||||
.parse(await this.client.request("thread/backgroundTerminals/list", { threadId }, 10_000));
|
||||
if (response.data.length > 0 || response.nextCursor !== null) return false;
|
||||
}
|
||||
return !this.client.hasPendingRequests();
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
this.clearPendingPermissions();
|
||||
|
||||
@@ -202,6 +202,7 @@ function createNewlineLineReader(
|
||||
|
||||
export class CodexAppServerClient {
|
||||
private readonly stdoutLineReader: { close(): void };
|
||||
private activeProviderRequests = 0;
|
||||
private readonly pending = new Map<number, PendingRequest>();
|
||||
private readonly requestHandlers = new Map<string, RequestHandler>();
|
||||
private notificationHandler: NotificationHandler | null = null;
|
||||
@@ -255,6 +256,10 @@ export class CodexAppServerClient {
|
||||
this.requestHandlers.set(method, handler);
|
||||
}
|
||||
|
||||
hasPendingRequests(): boolean {
|
||||
return this.pending.size > 0 || this.activeProviderRequests > 0;
|
||||
}
|
||||
|
||||
request(method: string, params?: unknown, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> {
|
||||
if (this.disposed) {
|
||||
return Promise.reject(new Error("Codex app-server client is closed"));
|
||||
@@ -392,6 +397,7 @@ export class CodexAppServerClient {
|
||||
this.traceRawEvent(request);
|
||||
const handler = this.requestHandlers.get(request.method);
|
||||
try {
|
||||
this.activeProviderRequests++;
|
||||
const result = handler ? await handler(request.params, request.id) : {};
|
||||
this.writeJsonRpcResponse({ id: request.id, result });
|
||||
} catch (error) {
|
||||
@@ -399,6 +405,8 @@ export class CodexAppServerClient {
|
||||
id: request.id,
|
||||
error: { message: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
} finally {
|
||||
this.activeProviderRequests--;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { OmpAgentMessage } from "./rpc-types.js";
|
||||
import type { OmpNoTurnScheduler, OmpProviderIdleScheduler } from "./agent.js";
|
||||
import type { OmpUsagePollScheduler } from "./usage-poller.js";
|
||||
import { resolveOmpProviderOptions } from "./provider-config.js";
|
||||
import { OmpRuntimeEventSchema } from "./rpc-types.js";
|
||||
import { OmpRuntimeEventSchema, OmpSessionStateSchema } from "./rpc-types.js";
|
||||
import { OmpHarness } from "./test-utils/omp-harness.js";
|
||||
import { OmpAgentClient } from "./agent.js";
|
||||
import { FakeOmp } from "./test-utils/fake-omp.js";
|
||||
@@ -1544,3 +1544,86 @@ describe("OMP agent client and session", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test("omp runtime safety protects queued replies and runtime-owned activity", async () => {
|
||||
const runtime = new FakeOmp();
|
||||
const session = await new OmpAgentClient({ logger: createTestLogger(), runtime }).createSession({
|
||||
provider: "omp",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
onTestFinished(() => session.close());
|
||||
const native = runtime.latestSession();
|
||||
native.state.isSettled = true;
|
||||
native.state.hasPendingAsyncWork = false;
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
native.state.queuedMessageCount = 1;
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
native.state.queuedMessageCount = 0;
|
||||
native.state.isStreaming = true;
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
native.state.isStreaming = false;
|
||||
native.state.isCompacting = true;
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
native.state.isCompacting = false;
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
native.getStateError = new Error("Unable to prove idle");
|
||||
await expect(session.canStopRuntime!()).rejects.toThrow("Unable to prove idle");
|
||||
});
|
||||
|
||||
test("OMP runtime safety retains async work after the foreground turn finishes", async () => {
|
||||
const runtime = new FakeOmp();
|
||||
const session = await new OmpAgentClient({ logger: createTestLogger(), runtime }).createSession({
|
||||
provider: "omp",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
onTestFinished(() => session.close());
|
||||
const native = runtime.latestSession();
|
||||
native.state.hasPendingAsyncWork = true;
|
||||
native.state.isSettled = false;
|
||||
const prompted = native.nextPrompt();
|
||||
const run = session.run("start a background job");
|
||||
await prompted;
|
||||
native.beginTurn();
|
||||
native.acceptPrompt("start a background job", "async-user");
|
||||
native.streamAssistantText("Job started in background");
|
||||
native.finishTurn();
|
||||
await run;
|
||||
expect(native.state.isStreaming).toBe(false);
|
||||
expect(native.state.queuedMessageCount).toBe(0);
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
// The delivery is drained, but an admitted/scheduled continuation still prevents settlement.
|
||||
native.state.hasPendingAsyncWork = false;
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
native.state.isSettled = true;
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ hasPendingAsyncWork: true, isSettled: true, expected: false },
|
||||
{ hasPendingAsyncWork: false, isSettled: false, expected: false },
|
||||
{ hasPendingAsyncWork: false, isSettled: true, expected: true },
|
||||
{ hasPendingAsyncWork: undefined, isSettled: true, expected: false },
|
||||
{ hasPendingAsyncWork: false, isSettled: undefined, expected: false },
|
||||
{ hasPendingAsyncWork: undefined, isSettled: undefined, expected: false },
|
||||
])(
|
||||
"OMP runtime safety requires explicit settlement: %j",
|
||||
async ({ hasPendingAsyncWork, isSettled, expected }) => {
|
||||
const runtime = new FakeOmp();
|
||||
const session = await new OmpAgentClient({ logger: createTestLogger(), runtime }).createSession(
|
||||
{
|
||||
provider: "omp",
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
);
|
||||
onTestFinished(() => session.close());
|
||||
const native = runtime.latestSession();
|
||||
const response = { ...native.state };
|
||||
if (hasPendingAsyncWork !== undefined) response.hasPendingAsyncWork = hasPendingAsyncWork;
|
||||
if (isSettled !== undefined) response.isSettled = isSettled;
|
||||
// Legacy get_state responses stay valid; missing safety fields never imply settlement.
|
||||
native.state = OmpSessionStateSchema.parse(response);
|
||||
expect(native.state.hasPendingAsyncWork).toBe(hasPendingAsyncWork);
|
||||
expect(native.state.isSettled).toBe(isSettled);
|
||||
expect(await session.canStopRuntime!()).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1172,6 +1172,27 @@ export class OmpAgentSession implements AgentSession {
|
||||
this.activeToolCalls.clear();
|
||||
}
|
||||
|
||||
async canStopRuntime(): Promise<boolean> {
|
||||
if (
|
||||
this.closed ||
|
||||
this.activeTurnId ||
|
||||
this.activeToolCalls.size ||
|
||||
this.pendingExtensionUiRequests.size ||
|
||||
this.pendingClientMessages.length ||
|
||||
this.pendingNoTurnOutputs.length ||
|
||||
this.pendingNoTurnCompletionAbort
|
||||
)
|
||||
return false;
|
||||
const state = await this.runtimeSession.getState();
|
||||
return (
|
||||
state.isSettled === true &&
|
||||
state.hasPendingAsyncWork === false &&
|
||||
!state.isStreaming &&
|
||||
!state.isCompacting &&
|
||||
state.queuedMessageCount === 0
|
||||
);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) {
|
||||
return;
|
||||
|
||||
@@ -152,6 +152,8 @@ export const OmpSessionStateSchema = z
|
||||
thinkingLevel: OmpThinkingLevelSchema.optional(),
|
||||
isStreaming: z.boolean(),
|
||||
isCompacting: z.boolean(),
|
||||
hasPendingAsyncWork: z.boolean().optional(),
|
||||
isSettled: z.boolean().optional(),
|
||||
autoCompactionEnabled: z.boolean().optional(),
|
||||
fastModeEnabled: z.boolean().optional(),
|
||||
fastModeActive: z.boolean().optional(),
|
||||
|
||||
@@ -7010,3 +7010,21 @@ describe("OpenCode session permission rules", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("OpenCode v1 runtime safety retains terminals and busy sessions on its shared server", async () => {
|
||||
const { parent, openCode } = await createParentSession("runtime-safety");
|
||||
try {
|
||||
expect(await parent.canStopRuntime!()).toBe(true);
|
||||
openCode.ptyListResponse = { data: [{ id: "shell", status: "running" }] };
|
||||
expect(await parent.canStopRuntime!()).toBe(false);
|
||||
openCode.ptyListResponse = { data: [] };
|
||||
openCode.sessionStatusResponse = { data: { "other-native-session": { type: "busy" } } };
|
||||
expect(await parent.canStopRuntime!()).toBe(false);
|
||||
openCode.sessionStatusResponse = { data: {} };
|
||||
expect(await parent.canStopRuntime!()).toBe(true);
|
||||
openCode.ptyListResponse = { error: "Inventory unavailable" };
|
||||
await expect(parent.canStopRuntime!()).rejects.toThrow("Unable to inspect OpenCode terminals");
|
||||
} finally {
|
||||
await parent.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5055,6 +5055,43 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
};
|
||||
}
|
||||
|
||||
async canStopRuntime(): Promise<boolean> {
|
||||
if (
|
||||
this.closed ||
|
||||
this.serverExited ||
|
||||
this.reconnection ||
|
||||
this.turnState.status !== "idle" ||
|
||||
this.runningToolCalls.size ||
|
||||
this.pendingPermissions.size ||
|
||||
this.pendingSteerSubmissions.length
|
||||
)
|
||||
return false;
|
||||
const request = { signal: AbortSignal.timeout(10_000) };
|
||||
const family = new Map([[this.sessionId, this.config.cwd]]);
|
||||
const inspected = new Set<string>();
|
||||
for (const [sessionId, directory] of family) {
|
||||
if (!inspected.has(directory)) {
|
||||
const statuses = await this.client.session.status({ directory }, request);
|
||||
if (statuses.error || !statuses.data)
|
||||
throw new Error("Unable to inspect OpenCode executions");
|
||||
if (Object.values(statuses.data).some((status) => status.type !== "idle")) return false;
|
||||
const terminals = await this.client.pty.list({ directory }, request);
|
||||
if (terminals.error || !terminals.data)
|
||||
throw new Error("Unable to inspect OpenCode terminals");
|
||||
if (terminals.data.some((terminal) => terminal.status === "running")) return false;
|
||||
inspected.add(directory);
|
||||
}
|
||||
const response = await this.client.session.children(
|
||||
{ sessionID: sessionId, directory },
|
||||
request,
|
||||
);
|
||||
const children = readOpenCodeChildSessionInfosFromResponse(response);
|
||||
if (!children) throw new Error("Unable to inspect OpenCode child sessions");
|
||||
for (const child of children) family.set(child.id, child.directory ?? directory);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
try {
|
||||
this.closed = true;
|
||||
|
||||
+2
@@ -170,6 +170,7 @@ export class TestOpenCodeClient {
|
||||
null;
|
||||
sessionPromptAsyncEvents: unknown[] = [idleEvent()];
|
||||
sessionPromptAsyncResponse: OpenCodeResponse = {};
|
||||
ptyListResponse: OpenCodeResponse = { data: [] };
|
||||
sessionStatusResponse: OpenCodeResponse = { data: {} };
|
||||
sessionStatusImplementation:
|
||||
| ((parameters: unknown, options: unknown) => Promise<OpenCodeResponse>)
|
||||
@@ -195,6 +196,7 @@ export class TestOpenCodeClient {
|
||||
|
||||
asSdkClient(): OpencodeClient {
|
||||
return {
|
||||
pty: { list: async () => this.ptyListResponse },
|
||||
app: {
|
||||
agents: async (parameters: unknown, options: unknown) => {
|
||||
this.calls.appAgents.push(parameters);
|
||||
|
||||
@@ -90,6 +90,9 @@ export class V2Harness {
|
||||
|
||||
readonly api: V2Api = {
|
||||
server: { info: unexpected },
|
||||
shell: {
|
||||
list: async () => ({ location: { directory: this.info.location.directory }, data: [] }),
|
||||
},
|
||||
plugin: {
|
||||
list: async () => ({
|
||||
location: { directory: this.info.location.directory },
|
||||
@@ -131,6 +134,7 @@ export class V2Harness {
|
||||
},
|
||||
permission: { list: async () => [], reply: unexpected },
|
||||
session: {
|
||||
inbox: { list: async () => [] },
|
||||
form: { list: async () => [], reply: unexpected, cancel: unexpected },
|
||||
create: async (input = {}) => {
|
||||
this.creates.push(input);
|
||||
|
||||
@@ -1074,3 +1074,110 @@ describe("OpenCode v2 session lifecycle", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("OpenCode v2 runtime safety retains shells, queued reports and shared executions", async () => {
|
||||
const harness = new V2Harness();
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||
try {
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
harness.api.shell.list = async () => ({
|
||||
location: { directory: "/tmp/project" },
|
||||
data: [
|
||||
{
|
||||
id: "quiet-shell",
|
||||
status: "running",
|
||||
command: "sleep 7200",
|
||||
cwd: "/tmp/project",
|
||||
shell: "sh",
|
||||
file: "/tmp/out",
|
||||
metadata: {},
|
||||
time: { started: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
harness.api.shell.list = async () => ({ location: { directory: "/tmp/project" }, data: [] });
|
||||
harness.api.session.inbox.list = async () => [
|
||||
{
|
||||
id: "report",
|
||||
sessionID: session.id!,
|
||||
type: "synthetic",
|
||||
delivery: "queue",
|
||||
time: { created: 1 },
|
||||
payload: { text: "Background child finished" },
|
||||
},
|
||||
];
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
harness.api.session.inbox.list = async () => [];
|
||||
harness.api.session.active = async () => ({
|
||||
"unrelated-autonomous-session": { type: "running" },
|
||||
});
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
harness.api.session.active = async () => ({});
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
harness.api.shell.list = async () => {
|
||||
throw new Error("No shell inventory");
|
||||
};
|
||||
await expect(session.canStopRuntime!()).rejects.toThrow("No shell inventory");
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenCode v2 runtime safety follows descendant locations before releasing the last lease", async () => {
|
||||
const harness = new V2Harness();
|
||||
const child = {
|
||||
...harness.info,
|
||||
id: "child",
|
||||
parentID: harness.info.id,
|
||||
location: { directory: "/tmp/child-worktree" },
|
||||
};
|
||||
const locations: string[] = [];
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||
// A completed native child can still own a quiet shell in another location.
|
||||
harness.api.session.list = async (input) => ({
|
||||
data: input.parentID === session.id ? [child] : [],
|
||||
cursor: {},
|
||||
});
|
||||
let running = true;
|
||||
harness.api.shell.list = async (input) => {
|
||||
const directory = input?.location?.directory ?? "";
|
||||
locations.push(directory);
|
||||
return {
|
||||
location: { directory },
|
||||
data:
|
||||
directory === child.location.directory && running
|
||||
? [
|
||||
{
|
||||
id: "child-shell",
|
||||
status: "running",
|
||||
command: "sleep 7200",
|
||||
cwd: directory,
|
||||
shell: "sh",
|
||||
file: "/tmp/out",
|
||||
metadata: {},
|
||||
time: { started: 1 },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
};
|
||||
try {
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
expect(locations).toEqual(["/tmp/project", "/tmp/child-worktree"]);
|
||||
running = false;
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
await session.close();
|
||||
expect(harness.releases).toBe(1);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { OpenCodeClient } from "@opencode/client";
|
||||
|
||||
export interface V2Api {
|
||||
server: OpenCodeClient["server"];
|
||||
shell: Pick<OpenCodeClient["shell"], "list">;
|
||||
session: Pick<
|
||||
OpenCodeClient["session"],
|
||||
| "create"
|
||||
@@ -20,6 +21,7 @@ export interface V2Api {
|
||||
| "revert"
|
||||
| "log"
|
||||
> & {
|
||||
inbox: Pick<OpenCodeClient["session"]["inbox"], "list">;
|
||||
form: Pick<OpenCodeClient["session"]["form"], "list" | "reply" | "cancel">;
|
||||
};
|
||||
plugin: Pick<OpenCodeClient["plugin"], "list">;
|
||||
|
||||
@@ -516,6 +516,36 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
await delay(100, undefined, { signal }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
async canStopRuntime(): Promise<boolean> {
|
||||
if (this.closed || this.exited || this.reconnecting || this.turns.id || this.dirty)
|
||||
return false;
|
||||
const request = { signal: AbortSignal.timeout(10_000) };
|
||||
// Active executions are server-wide. Releasing the last lease must not stop an
|
||||
// autonomous execution merely because it belongs to another session or location.
|
||||
if (Object.keys(await this.client.session.active(request)).length > 0) return false;
|
||||
const family = new Map([[this.info.id, this.info]]);
|
||||
const directories = new Set<string>();
|
||||
for (const info of family.values()) {
|
||||
if ((await this.client.session.inbox.list({ sessionID: info.id }, request)).length)
|
||||
return false;
|
||||
directories.add(info.location.directory);
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await this.client.session.list(
|
||||
{ ...(cursor ? { cursor } : { parentID: info.id }), limit: 100 },
|
||||
request,
|
||||
);
|
||||
for (const child of page.data) family.set(child.id, child);
|
||||
cursor = page.cursor.next ?? undefined;
|
||||
} while (cursor);
|
||||
}
|
||||
for (const directory of directories) {
|
||||
const shells = await this.client.shell.list({ location: { directory } }, request);
|
||||
if (shells.data.some((shell) => shell.status === "running")) return false;
|
||||
}
|
||||
return !this.turns.id && !this.dirty;
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
|
||||
@@ -3488,3 +3488,23 @@ describe("transformPiModels", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test("pi runtime safety protects queued replies and runtime-owned activity", async () => {
|
||||
const runtime = new FakePi();
|
||||
const session = await createClient(runtime).createSession(createConfig());
|
||||
onTestFinished(() => session.close());
|
||||
const native = runtime.latestSession();
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
native.state.pendingMessageCount = 1;
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
native.state.pendingMessageCount = 0;
|
||||
native.state.isStreaming = true;
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
native.state.isStreaming = false;
|
||||
native.state.isCompacting = true;
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
native.state.isCompacting = false;
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
native.getStateError = new Error("Unable to prove idle");
|
||||
await expect(session.canStopRuntime!()).rejects.toThrow("Unable to prove idle");
|
||||
});
|
||||
|
||||
@@ -1581,6 +1581,22 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
return await resultPromise;
|
||||
}
|
||||
|
||||
async canStopRuntime(): Promise<boolean> {
|
||||
if (
|
||||
this.closed ||
|
||||
this.activeTurnId ||
|
||||
this.pendingExtensionUiRequests.size ||
|
||||
this.pendingExtensionResults.size ||
|
||||
this.pendingExtensionHydrations.size ||
|
||||
this.pendingSteerSubmissions.length ||
|
||||
this.pendingNoTurnOutputs.length ||
|
||||
this.interruptingTurn
|
||||
)
|
||||
return false;
|
||||
const state = await this.runtimeSession.getState();
|
||||
return !state.isStreaming && !state.isCompacting && state.pendingMessageCount === 0;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) {
|
||||
return;
|
||||
|
||||
@@ -1,46 +1,24 @@
|
||||
import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import { existsSync, rmSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
let materializeProviderImage: typeof import("./provider-image-output.js").materializeProviderImage;
|
||||
import { materializeProviderImage } from "./provider-image-output.js";
|
||||
|
||||
describe.skipIf(process.platform === "win32")("materializeProviderImage", () => {
|
||||
const originalPaseoHome = process.env.PASEO_HOME;
|
||||
let testHome: string | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
testHome = mkdtempSync(path.join(os.tmpdir(), "paseo-provider-images-posix-"));
|
||||
process.env.PASEO_HOME = testHome;
|
||||
vi.resetModules();
|
||||
({ materializeProviderImage } = await import("./provider-image-output.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPaseoHome === undefined) {
|
||||
delete process.env.PASEO_HOME;
|
||||
} else {
|
||||
process.env.PASEO_HOME = originalPaseoHome;
|
||||
}
|
||||
if (testHome) {
|
||||
rmSync(testHome, { recursive: true, force: true });
|
||||
testHome = null;
|
||||
}
|
||||
});
|
||||
|
||||
test("writes image attachments under a private directory in the paseo home", () => {
|
||||
test("writes image attachments under a private temp directory", () => {
|
||||
const materialized = materializeProviderImage({
|
||||
data: "YWJjMTIz",
|
||||
mimeType: "image/png",
|
||||
});
|
||||
const attachmentDir = path.dirname(materialized.path);
|
||||
|
||||
// Exactly "paseo-attachments" — no mkdtemp suffix. The old per-process temp
|
||||
// directory is what let the OS reap these images out from under the app.
|
||||
expect(path.basename(attachmentDir)).toBe("paseo-attachments");
|
||||
expect(attachmentDir).toBe(path.join(testHome as string, "paseo-attachments"));
|
||||
expect(existsSync(materialized.path)).toBe(true);
|
||||
expect(statSync(attachmentDir).mode & 0o777).toBe(0o700);
|
||||
expect(statSync(materialized.path).mode & 0o777).toBe(0o600);
|
||||
try {
|
||||
expect(path.basename(attachmentDir)).toMatch(/^paseo-attachments-/);
|
||||
expect(existsSync(materialized.path)).toBe(true);
|
||||
expect(statSync(attachmentDir).mode & 0o777).toBe(0o700);
|
||||
expect(statSync(materialized.path).mode & 0o777).toBe(0o600);
|
||||
} finally {
|
||||
rmSync(attachmentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,36 +1,17 @@
|
||||
import { existsSync, mkdtempSync, readdirSync, rmSync, utimesSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { projectTimelineRows } from "../timeline-projection.js";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
isProviderImageMarkdown,
|
||||
materializeProviderImage,
|
||||
renderProviderImageOutputAsAssistantMarkdown,
|
||||
} from "./provider-image-output.js";
|
||||
|
||||
const HASH = "a".repeat(64);
|
||||
|
||||
// Materialized images live under the paseo home now, so without this every test
|
||||
// run would read and delete the developer's real provider images.
|
||||
const originalPaseoHome = process.env.PASEO_HOME;
|
||||
let testHome: string | null = null;
|
||||
let materializeProviderImage: typeof import("./provider-image-output.js").materializeProviderImage;
|
||||
|
||||
function useTempPaseoHome(): string {
|
||||
testHome = mkdtempSync(path.join(os.tmpdir(), "paseo-provider-images-"));
|
||||
process.env.PASEO_HOME = testHome;
|
||||
return testHome;
|
||||
}
|
||||
|
||||
function attachmentsDir(): string {
|
||||
if (!testHome) {
|
||||
throw new Error("useTempPaseoHome() must run before reading the attachments dir.");
|
||||
}
|
||||
return path.join(testHome, "paseo-attachments");
|
||||
}
|
||||
|
||||
function renderImageMarkdown(imagePath: string): string {
|
||||
const item = renderProviderImageOutputAsAssistantMarkdown({ path: imagePath });
|
||||
if (!item || item.type !== "assistant_message") {
|
||||
@@ -111,91 +92,27 @@ describe("isProviderImageMarkdown", () => {
|
||||
});
|
||||
|
||||
describe("materializeProviderImage", () => {
|
||||
beforeEach(async () => {
|
||||
useTempPaseoHome();
|
||||
vi.resetModules();
|
||||
({ materializeProviderImage } = await import("./provider-image-output.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPaseoHome === undefined) {
|
||||
delete process.env.PASEO_HOME;
|
||||
} else {
|
||||
process.env.PASEO_HOME = originalPaseoHome;
|
||||
}
|
||||
if (testHome) {
|
||||
rmSync(testHome, { recursive: true, force: true });
|
||||
testHome = null;
|
||||
}
|
||||
});
|
||||
|
||||
test("recreates the private directory if the cached directory is removed", () => {
|
||||
test("recreates the private temp directory if the cached directory is removed", () => {
|
||||
const first = materializeProviderImage({
|
||||
data: "YWJjMTIz",
|
||||
mimeType: "image/png",
|
||||
});
|
||||
const firstDir = path.dirname(first.path);
|
||||
expect(existsSync(first.path)).toBe(true);
|
||||
|
||||
rmSync(path.dirname(first.path), { recursive: true, force: true });
|
||||
rmSync(firstDir, { recursive: true, force: true });
|
||||
|
||||
const second = materializeProviderImage({
|
||||
data: "ZGVmNDU2",
|
||||
mimeType: "image/png",
|
||||
});
|
||||
const secondDir = path.dirname(second.path);
|
||||
|
||||
expect(existsSync(second.path)).toBe(true);
|
||||
});
|
||||
|
||||
test("stores images in a stable directory under the paseo home", () => {
|
||||
const result = materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
|
||||
|
||||
// A fixed directory, not a per-process mkdtemp one — the old shape is what
|
||||
// let the OS reap these images out from under the app after ~3 days.
|
||||
expect(path.dirname(result.path)).toBe(attachmentsDir());
|
||||
expect(path.basename(path.dirname(result.path))).toBe("paseo-attachments");
|
||||
// Mode assertions live in the .posix suite: Windows ignores mkdir/writeFile
|
||||
// modes and reports 0o666, so asserting them here fails on that runner.
|
||||
});
|
||||
|
||||
test("is idempotent for the same bytes across daemon restarts", async () => {
|
||||
const first = materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
|
||||
|
||||
// Reload the module while retaining the files from the previous instance.
|
||||
vi.resetModules();
|
||||
const restarted = await import("./provider-image-output.js");
|
||||
const second = restarted.materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
|
||||
|
||||
expect(second.path).toBe(first.path);
|
||||
expect(readdirSync(attachmentsDir())).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("emits markdown that isProviderImageMarkdown still recognizes", () => {
|
||||
const item = renderProviderImageOutputAsAssistantMarkdown(
|
||||
{ data: "YWJjMTIz", mimeType: "image/png" },
|
||||
{ materialize: materializeProviderImage },
|
||||
);
|
||||
|
||||
if (!item || item.type !== "assistant_message") {
|
||||
throw new Error("Expected provider image output to render as assistant markdown.");
|
||||
try {
|
||||
expect(existsSync(second.path)).toBe(true);
|
||||
} finally {
|
||||
rmSync(secondDir, { recursive: true, force: true });
|
||||
}
|
||||
expect(isProviderImageMarkdown(item.text)).toBe(true);
|
||||
});
|
||||
|
||||
// The whole point of moving out of the temp dir is that nothing deletes these
|
||||
// behind the user's back. An age-based sweep would reintroduce that on a
|
||||
// longer fuse, because mtime cannot tell a referenced image from an abandoned
|
||||
// one and reading a transcript never touches the file.
|
||||
test("keeps images no matter how old they are", async () => {
|
||||
const image = materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
|
||||
const longAgoSeconds = Date.now() / 1000 - 365 * 24 * 60 * 60;
|
||||
utimesSync(image.path, longAgoSeconds, longAgoSeconds);
|
||||
|
||||
// Reloading the module must not sweep images referenced by older transcripts.
|
||||
vi.resetModules();
|
||||
const restarted = await import("./provider-image-output.js");
|
||||
restarted.materializeProviderImage({ data: "Z2hpNzg5", mimeType: "image/png" });
|
||||
|
||||
expect(existsSync(image.path)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import * as fsSync from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { AgentTimelineItem } from "../agent-sdk-types.js";
|
||||
import { resolvePaseoHome } from "../../paseo-home.js";
|
||||
|
||||
export interface ProviderImageOutput {
|
||||
path?: string | null;
|
||||
@@ -17,13 +17,11 @@ export interface MaterializedProviderImage {
|
||||
path: string;
|
||||
}
|
||||
|
||||
// Keep this basename compatible with provider-image Markdown in existing timelines.
|
||||
const PROVIDER_IMAGE_ATTACHMENT_DIR = "paseo-attachments";
|
||||
const PROVIDER_IMAGE_ATTACHMENT_DIR_PREFIX = `${PROVIDER_IMAGE_ATTACHMENT_DIR}-`;
|
||||
const PRIVATE_ATTACHMENT_DIR_MODE = 0o700;
|
||||
const MATERIALIZED_IMAGE_FILE_MODE = 0o600;
|
||||
|
||||
// Persist images across process lifetimes. Age-based cleanup would delete files
|
||||
// still referenced by transcripts; any future cleanup must account for references.
|
||||
let materializedImageAttachmentDir: string | null = null;
|
||||
|
||||
function canReuseMaterializedImageAttachmentDir(dir: string): boolean {
|
||||
@@ -47,11 +45,11 @@ function getMaterializedImageAttachmentDir(): string {
|
||||
return materializedImageAttachmentDir;
|
||||
}
|
||||
|
||||
const dir = path.join(resolvePaseoHome(), PROVIDER_IMAGE_ATTACHMENT_DIR);
|
||||
fsSync.mkdirSync(dir, { recursive: true, mode: PRIVATE_ATTACHMENT_DIR_MODE });
|
||||
fsSync.chmodSync(dir, PRIVATE_ATTACHMENT_DIR_MODE);
|
||||
materializedImageAttachmentDir = dir;
|
||||
return dir;
|
||||
materializedImageAttachmentDir = fsSync.mkdtempSync(
|
||||
path.join(os.tmpdir(), PROVIDER_IMAGE_ATTACHMENT_DIR_PREFIX),
|
||||
);
|
||||
fsSync.chmodSync(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE);
|
||||
return materializedImageAttachmentDir;
|
||||
}
|
||||
|
||||
function getImageExtension(mimeType: string): string {
|
||||
@@ -83,7 +81,9 @@ function normalizeImageData(mimeType: string, data: string): { mimeType: string;
|
||||
return { mimeType, data };
|
||||
}
|
||||
|
||||
// Content-addressed filenames deduplicate repeated image bytes across restarts.
|
||||
// Filenames are a content hash of the bytes so re-materializing the same image
|
||||
// within a process reuses the existing temp file instead of leaking a fresh one
|
||||
// for repeated image blocks or history replay.
|
||||
export function materializeProviderImage(image: {
|
||||
data: string;
|
||||
mimeType: string | null;
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test, onTestFinished } from "vitest";
|
||||
import { MutableDaemonConfigSchema } from "@getpaseo/protocol/messages";
|
||||
import { DaemonConfigStore } from "./daemon-config-store.js";
|
||||
import { loadPersistedConfig } from "./persisted-config.js";
|
||||
import {
|
||||
createIdleRuntimeCleanup,
|
||||
type RuntimeCleanupCandidate,
|
||||
} from "./agent/idle-runtime-cleanup/index.js";
|
||||
import { createTestLogger } from "../test-utils/test-logger.js";
|
||||
|
||||
import { fanOutReconciledWorkspaceUpdates } from "./bootstrap.js";
|
||||
import { fanOutReconciledWorkspaceUpdates, connectIdleRuntimeCleanupConfig } from "./bootstrap.js";
|
||||
|
||||
test("reconciliation emits workspace updates when observer sync fails", async () => {
|
||||
const emittedWorkspaceIds: string[][] = [];
|
||||
@@ -54,3 +65,132 @@ test("reconciliation isolates workspace update failures between sessions", async
|
||||
expect(emittedWorkspaceIds).toEqual([["ws-reclassified"]]);
|
||||
expect(warnings).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Exercise bootstrap's actual live-config binding with the real collector and store.
|
||||
|
||||
function liveCleanupHost() {
|
||||
const home = mkdtempSync(path.join(tmpdir(), "paseo-live-cleanup-"));
|
||||
writeFileSync(path.join(home, "config.json"), JSON.stringify({ version: 1 }));
|
||||
const store = new DaemonConfigStore(
|
||||
home,
|
||||
MutableDaemonConfigSchema.parse({
|
||||
relay: { enabled: true },
|
||||
mcp: { injectIntoAgents: false },
|
||||
idleRuntimeCleanup: true,
|
||||
}),
|
||||
undefined,
|
||||
{
|
||||
reloadSource: {
|
||||
resolve: (persisted) => ({
|
||||
mutable: MutableDaemonConfigSchema.parse({
|
||||
relay: { enabled: true },
|
||||
mcp: { injectIntoAgents: false },
|
||||
idleRuntimeCleanup: persisted.agents?.idleRuntimeCleanup ?? true,
|
||||
}),
|
||||
overrideControlledPaths: [],
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
||||
let minutes = 61;
|
||||
let closed = false;
|
||||
let protectedCaller = false;
|
||||
const runtime: RuntimeCleanupCandidate = {
|
||||
identity: {},
|
||||
agentId: "agent",
|
||||
provider: "test",
|
||||
sessionId: "session",
|
||||
parentAgentId: undefined,
|
||||
lastActivityAt: 0,
|
||||
isEligible: () => !closed,
|
||||
closeIfEligible: async (eligible) => {
|
||||
if (!eligible()) return false;
|
||||
closed = true;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const cleanup = createIdleRuntimeCleanup({
|
||||
enabled: true,
|
||||
now: () => minutes * 60_000,
|
||||
runtimes: () => [runtime],
|
||||
isProtected: () => protectedCaller,
|
||||
logger: createTestLogger(),
|
||||
schedule: () => () => {},
|
||||
});
|
||||
const unsubscribe = connectIdleRuntimeCleanupConfig(store, cleanup);
|
||||
onTestFinished(async () => {
|
||||
unsubscribe();
|
||||
await cleanup.close();
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
return {
|
||||
home,
|
||||
store,
|
||||
cleanup,
|
||||
unsubscribe,
|
||||
protect: (value: boolean) => {
|
||||
protectedCaller = value;
|
||||
},
|
||||
sweep: (at = minutes) => {
|
||||
minutes = at;
|
||||
return cleanup.sweep();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("bootstrap's host toggle prevents collection live, persists, and leaves other hosts enabled", async () => {
|
||||
const host = liveCleanupHost();
|
||||
const other = liveCleanupHost();
|
||||
host.store.patch({ idleRuntimeCleanup: false });
|
||||
expect(loadPersistedConfig(host.home).agents?.idleRuntimeCleanup).toBe(false);
|
||||
expect((await host.sweep()).collected).toEqual([]);
|
||||
expect((await other.sweep()).collected).toHaveLength(1);
|
||||
host.store.patch({ idleRuntimeCleanup: true });
|
||||
host.protect(true);
|
||||
expect((await host.sweep()).collected).toEqual([]);
|
||||
host.protect(false);
|
||||
expect((await host.sweep(62)).collected).toEqual([]);
|
||||
expect((await host.sweep(122)).collected).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("bootstrap's config-file reload binding disables and enables eligible collection live", async () => {
|
||||
const host = liveCleanupHost();
|
||||
for (const enabled of [false, true]) {
|
||||
writeFileSync(
|
||||
path.join(host.home, "config.json"),
|
||||
JSON.stringify({ version: 1, agents: { idleRuntimeCleanup: enabled } }),
|
||||
);
|
||||
expect(host.store.reload()).toEqual({
|
||||
appliedPaths: ["agents.idleRuntimeCleanup"],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: [],
|
||||
});
|
||||
expect((await host.sweep()).collected).toHaveLength(enabled ? 1 : 0);
|
||||
}
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"failed config apply restores prior idle cleanup behavior (previous: %s)",
|
||||
async (previous) => {
|
||||
const host = liveCleanupHost();
|
||||
host.store.patch({ idleRuntimeCleanup: previous });
|
||||
const persistedBefore = loadPersistedConfig(host.home);
|
||||
const refuse = host.store.onApply(() => {
|
||||
throw new Error("Another live owner refused");
|
||||
});
|
||||
expect(() => host.store.patch({ idleRuntimeCleanup: !previous })).toThrow(
|
||||
"Another live owner refused",
|
||||
);
|
||||
expect(host.store.get().idleRuntimeCleanup).toBe(previous);
|
||||
expect(loadPersistedConfig(host.home)).toEqual(persistedBefore);
|
||||
expect((await host.sweep()).collected).toHaveLength(previous ? 1 : 0);
|
||||
refuse();
|
||||
},
|
||||
);
|
||||
|
||||
test("disposing bootstrap's binding stops configuration delivery", async () => {
|
||||
const host = liveCleanupHost();
|
||||
host.unsubscribe();
|
||||
host.store.patch({ idleRuntimeCleanup: false });
|
||||
expect((await host.sweep()).collected).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createIdleRuntimeCleanup } from "./agent/idle-runtime-cleanup/index.js";
|
||||
import {
|
||||
createFinishSubscriptions,
|
||||
createFinishObservation,
|
||||
@@ -453,6 +454,7 @@ export interface PaseoDaemonConfig {
|
||||
dictationFinalTimeoutMs?: number;
|
||||
downloadTokenTtlMs?: number;
|
||||
agentProviderSettings?: AgentProviderRuntimeSettingsMap;
|
||||
idleRuntimeCleanup?: boolean;
|
||||
providerCatalogRefreshTimeoutMs?: number;
|
||||
metadataGeneration?: {
|
||||
providers?: Array<{
|
||||
@@ -577,6 +579,7 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae
|
||||
},
|
||||
autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false,
|
||||
enableTerminalAgentHooks: config.enableTerminalAgentHooks ?? false,
|
||||
idleRuntimeCleanup: config.idleRuntimeCleanup !== false,
|
||||
appendSystemPrompt: config.appendSystemPrompt ?? "",
|
||||
pluginsEnabled: config.pluginsEnabled ?? false,
|
||||
plugins: config.plugins ?? {},
|
||||
@@ -1398,6 +1401,7 @@ export async function createPaseoDaemon(
|
||||
archiveWorkspace: archiveScheduleWorkspaceExternal,
|
||||
});
|
||||
await scheduleService.start();
|
||||
|
||||
agentManager.setAgentArchivedCallback(async (agentId) => {
|
||||
try {
|
||||
await scheduleService.completeForAgent(agentId);
|
||||
@@ -1445,6 +1449,20 @@ export async function createPaseoDaemon(
|
||||
}),
|
||||
});
|
||||
|
||||
const idleRuntimeCleanup = createIdleRuntimeCleanup({
|
||||
enabled: daemonConfigStore.get().idleRuntimeCleanup !== false,
|
||||
runtimes: () => agentManager.getRuntimeCleanupCandidates(),
|
||||
isProtected: (agentId) =>
|
||||
finishSubscriptions.hasPendingForCaller(agentId) ||
|
||||
(wsServer?.agentMessages.hasPending(agentId) ?? false),
|
||||
logger,
|
||||
});
|
||||
|
||||
const unsubscribeIdleRuntimeCleanup = connectIdleRuntimeCleanupConfig(
|
||||
daemonConfigStore,
|
||||
idleRuntimeCleanup,
|
||||
);
|
||||
|
||||
const createAgentToolHostDependencies = (
|
||||
runtime: PaseoToolRuntimeContext,
|
||||
): PaseoToolHostDependencies => ({
|
||||
@@ -1907,6 +1925,8 @@ export async function createPaseoDaemon(
|
||||
await hubRelationships.stop();
|
||||
workspaceReconciliation.dispose();
|
||||
scriptHealthMonitor.stop();
|
||||
unsubscribeIdleRuntimeCleanup();
|
||||
await idleRuntimeCleanup.close();
|
||||
// Freeze both ingress and registration before taking the agent closure snapshot.
|
||||
wsServer?.prepareForShutdown();
|
||||
await finishSubscriptions.close();
|
||||
@@ -1997,3 +2017,11 @@ async function closeAllAgents(logger: Logger, agentManager: AgentManager): Promi
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function connectIdleRuntimeCleanupConfig(
|
||||
store: DaemonConfigStore,
|
||||
cleanup: Pick<ReturnType<typeof createIdleRuntimeCleanup>, "setEnabled">,
|
||||
): () => void {
|
||||
cleanup.setEnabled(store.get().idleRuntimeCleanup !== false);
|
||||
return store.onFieldChange("idleRuntimeCleanup", (value) => cleanup.setEnabled(value !== false));
|
||||
}
|
||||
|
||||
@@ -27,6 +27,17 @@ describe("server config", () => {
|
||||
expect(standaloneConfig.desktopManaged).toBe(false);
|
||||
});
|
||||
|
||||
test("idle runtime cleanup defaults on and supports a host-wide opt-out", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-idle-cleanup-"));
|
||||
roots.push(paseoHome);
|
||||
expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(true);
|
||||
await writeFile(
|
||||
path.join(paseoHome, "config.json"),
|
||||
JSON.stringify({ agents: { idleRuntimeCleanup: false } }),
|
||||
);
|
||||
expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(false);
|
||||
});
|
||||
|
||||
test("loads the provider catalog refresh timeout", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-provider-timeout-"));
|
||||
roots.push(paseoHome);
|
||||
|
||||
@@ -660,6 +660,7 @@ export function resolveConfigFromPersisted(
|
||||
voiceLlmProviderExplicit: voiceLlm.providerExplicit,
|
||||
voiceLlmModel: voiceLlm.model,
|
||||
agentProviderSettings: extractAgentProviderSettings(providerOverrides),
|
||||
idleRuntimeCleanup: persisted.agents?.idleRuntimeCleanup ?? true,
|
||||
providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs,
|
||||
metadataGeneration: persisted.agents?.metadataGeneration,
|
||||
providerOverrides,
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { DaemonConfigStore, applyMutableProviderConfigToOverrides } from "./daemon-config-store.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { loadPersistedConfig } from "./persisted-config.js";
|
||||
import type { PersistedConfig } from "./persisted-config.js";
|
||||
import type { MutableDaemonConfig } from "@getpaseo/protocol/messages";
|
||||
@@ -26,6 +27,7 @@ function reloadableConfig(
|
||||
metadataGeneration: { providers: agents.metadataGeneration?.providers ?? [] },
|
||||
autoArchiveAfterMerge: daemon.autoArchiveAfterMerge ?? false,
|
||||
enableTerminalAgentHooks: daemon.enableTerminalAgentHooks ?? false,
|
||||
idleRuntimeCleanup: agents.idleRuntimeCleanup !== false,
|
||||
appendSystemPrompt: daemon.appendSystemPrompt ?? "",
|
||||
terminalProfiles: daemon.terminalProfiles,
|
||||
agentProfiles: daemon.agentProfiles,
|
||||
@@ -902,6 +904,43 @@ describe("DaemonConfigStore", () => {
|
||||
expect(persisted.agents?.metadataGeneration).toEqual({ providers: [] });
|
||||
});
|
||||
|
||||
test("patch persists the host idle runtime cleanup opt-out for the next daemon start", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
writeFileSync(
|
||||
path.join(paseoHome, "config.json"),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
agents: { metadataGeneration: { providers: [{ provider: "claude" }] } },
|
||||
})}\n`,
|
||||
);
|
||||
const store = new DaemonConfigStore(
|
||||
paseoHome,
|
||||
{
|
||||
mcp: { injectIntoAgents: false },
|
||||
browserTools: { enabled: false },
|
||||
providers: {},
|
||||
autoArchiveAfterMerge: false,
|
||||
enableTerminalAgentHooks: false,
|
||||
idleRuntimeCleanup: true,
|
||||
appendSystemPrompt: "",
|
||||
metadataGeneration: { providers: [{ provider: "claude" }] },
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(store.patch({ idleRuntimeCleanup: false }).idleRuntimeCleanup).toBe(false);
|
||||
|
||||
expect(loadPersistedConfig(paseoHome).agents).toEqual({
|
||||
metadataGeneration: { providers: [{ provider: "claude" }] },
|
||||
idleRuntimeCleanup: false,
|
||||
});
|
||||
expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(false);
|
||||
|
||||
store.patch({ idleRuntimeCleanup: true });
|
||||
expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(true);
|
||||
});
|
||||
|
||||
test("patch persists custom ACP provider overrides into config.json", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
@@ -1013,6 +1052,29 @@ describe("DaemonConfigStore reload", () => {
|
||||
expect(store.get().git).toEqual({ maxProcessesPerSecond: 12, maxProcessConcurrency: 3 });
|
||||
});
|
||||
|
||||
test("reloads idle runtime cleanup in both directions without requiring a restart", () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore();
|
||||
const changes: unknown[] = [];
|
||||
store.onFieldChange("idleRuntimeCleanup", (value) => changes.push(value));
|
||||
for (const enabled of [false, true]) {
|
||||
writeConfig(paseoHome, { ...persisted, agents: { idleRuntimeCleanup: enabled } });
|
||||
expect(store.reload()).toEqual({
|
||||
appliedPaths: ["agents.idleRuntimeCleanup"],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: [],
|
||||
});
|
||||
expect(store.get().idleRuntimeCleanup).toBe(enabled);
|
||||
}
|
||||
expect(changes).toEqual([false, true]);
|
||||
store.patch({ idleRuntimeCleanup: false });
|
||||
expect(store.reload()).toEqual({
|
||||
appliedPaths: [],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: [],
|
||||
});
|
||||
expect(store.get().idleRuntimeCleanup).toBe(false);
|
||||
});
|
||||
|
||||
test("applies the global plugin switch in both directions", () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore({
|
||||
initialPersisted: { version: 1, pluginsEnabled: false },
|
||||
|
||||
@@ -25,6 +25,7 @@ interface SupportedMutableConfigPatch {
|
||||
metadataGeneration?: MutableDaemonConfig["metadataGeneration"];
|
||||
autoArchiveAfterMerge?: boolean;
|
||||
enableTerminalAgentHooks?: boolean;
|
||||
idleRuntimeCleanup?: boolean;
|
||||
appendSystemPrompt?: string;
|
||||
terminalProfiles?: MutableDaemonConfig["terminalProfiles"];
|
||||
agentProfiles?: MutableDaemonConfig["agentProfiles"];
|
||||
@@ -185,6 +186,7 @@ const RELOADABLE_PATHS = [
|
||||
"daemon.terminalProfiles",
|
||||
"daemon.agentProfiles",
|
||||
"app.baseUrl",
|
||||
"agents.idleRuntimeCleanup",
|
||||
"agents.providers",
|
||||
"agents.catalogRefreshTimeoutMs",
|
||||
"agents.metadataGeneration",
|
||||
@@ -209,6 +211,7 @@ const PERSISTED_TO_MUTABLE_PATH = new Map<string, string>([
|
||||
["daemon.terminalProfiles", "terminalProfiles"],
|
||||
["daemon.agentProfiles", "agentProfiles"],
|
||||
["app.baseUrl", "app.baseUrl"],
|
||||
["agents.idleRuntimeCleanup", "idleRuntimeCleanup"],
|
||||
["agents.providers", "providers"],
|
||||
["agents.catalogRefreshTimeoutMs", "catalogRefreshTimeoutMs"],
|
||||
["agents.metadataGeneration", "metadataGeneration"],
|
||||
@@ -283,6 +286,9 @@ function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMut
|
||||
...(patch.enableTerminalAgentHooks !== undefined
|
||||
? { enableTerminalAgentHooks: patch.enableTerminalAgentHooks }
|
||||
: {}),
|
||||
...(patch.idleRuntimeCleanup !== undefined
|
||||
? { idleRuntimeCleanup: patch.idleRuntimeCleanup }
|
||||
: {}),
|
||||
...(patch.appendSystemPrompt !== undefined
|
||||
? { appendSystemPrompt: patch.appendSystemPrompt }
|
||||
: {}),
|
||||
@@ -616,6 +622,7 @@ function mergeMutableAgentPatch(
|
||||
patch.providers === undefined &&
|
||||
patch.metadataGeneration === undefined &&
|
||||
patch.skills === undefined &&
|
||||
patch.idleRuntimeCleanup === undefined &&
|
||||
removeProviders.length === 0
|
||||
) {
|
||||
return persistedAgents;
|
||||
@@ -648,6 +655,10 @@ function mergeMutableAgentPatch(
|
||||
next["skills"] = { selection: patch.skills.selection };
|
||||
}
|
||||
|
||||
if (patch.idleRuntimeCleanup !== undefined) {
|
||||
next["idleRuntimeCleanup"] = patch.idleRuntimeCleanup;
|
||||
}
|
||||
|
||||
return Object.keys(next).length > 0 ? (next as PersistedConfig["agents"]) : undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -339,6 +339,7 @@ export const PersistedConfigSchema = z
|
||||
agents: z
|
||||
.object({
|
||||
providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(),
|
||||
idleRuntimeCleanup: z.boolean().optional(),
|
||||
catalogRefreshTimeoutMs: z.number().int().positive().max(2_147_483_647).optional(),
|
||||
metadataGeneration: AgentMetadataGenerationSchema.optional(),
|
||||
skills: z.object({ selection: AgentSkillSelectionSchema.optional() }).strict().optional(),
|
||||
|
||||
@@ -883,6 +883,90 @@ export default function contribute(server: PluginServerContext) {
|
||||
await service.stopAllPlugins();
|
||||
}, 30_000);
|
||||
|
||||
it("preserves local Git commits and dirty files across repeated updates", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
roots.push(home);
|
||||
const repository = await mkdtemp(path.join(tmpdir(), "paseo-plugin-repository-"));
|
||||
roots.push(repository);
|
||||
await runGitCommand(["init", "-b", "main"], { cwd: repository });
|
||||
// Fixture commits must not run the developer's global hooks (core.hooksPath).
|
||||
await runGitCommand(["config", "core.hooksPath", "/dev/null"], { cwd: repository });
|
||||
await runGitCommand(["config", "user.name", "Paseo Tests"], { cwd: repository });
|
||||
await runGitCommand(["config", "user.email", "paseo@example.test"], { cwd: repository });
|
||||
await writeFile(
|
||||
path.join(repository, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "kept-update" }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(repository, "index.server.ts"),
|
||||
"export default function contribute(plugin: unknown) { void plugin; return () => undefined; }",
|
||||
);
|
||||
await writeFile(path.join(repository, "marker.txt"), "first");
|
||||
await runGitCommand(["add", "-A"], { cwd: repository });
|
||||
await runGitCommand(["commit", "-m", "first"], { cwd: repository });
|
||||
|
||||
const service = bindTestSessionHost(
|
||||
new PluginService(pino({ level: "silent" }), createStore(home), "0.4.0", {
|
||||
managedSources: new ManagedPluginSources(home),
|
||||
}),
|
||||
);
|
||||
await service.start();
|
||||
const installed = await service.installSource({ source: pathToFileURL(repository).href });
|
||||
const pluginRoot = path.dirname(path.dirname(installed.path));
|
||||
expect(await readdir(pluginRoot)).toHaveLength(1);
|
||||
|
||||
// Both committed and uncommitted work exist only in the installed checkout.
|
||||
await runGitCommand(["config", "user.name", "Paseo Tests"], { cwd: installed.path });
|
||||
await runGitCommand(["config", "user.email", "paseo@example.test"], { cwd: installed.path });
|
||||
await writeFile(path.join(installed.path, "local.txt"), "unpushed commit");
|
||||
await runGitCommand(["add", "local.txt"], { cwd: installed.path });
|
||||
await runGitCommand(["commit", "-m", "local work"], { cwd: installed.path });
|
||||
const localCommit = (
|
||||
await runGitCommand(["rev-parse", "HEAD"], { cwd: installed.path })
|
||||
).stdout.trim();
|
||||
await writeFile(path.join(installed.path, "marker.txt"), "uncommitted work");
|
||||
await writeFile(path.join(installed.path, "untracked.txt"), "untracked work");
|
||||
await writeFile(path.join(repository, "marker.txt"), "second");
|
||||
await runGitCommand(["add", "-A"], { cwd: repository });
|
||||
await runGitCommand(["commit", "-m", "second"], { cwd: repository });
|
||||
await applyReviewedUpdate(service, "kept-update");
|
||||
|
||||
const retired = (await readdir(pluginRoot)).filter((entry) => /\.old-\d+$/.test(entry));
|
||||
expect(retired).toHaveLength(1);
|
||||
expect(await readdir(pluginRoot)).toHaveLength(2);
|
||||
expect(
|
||||
await readFile(path.join(pluginRoot, retired[0]!, "checkout", "marker.txt"), "utf8"),
|
||||
).toBe("uncommitted work");
|
||||
|
||||
// Later updates must not delete the only copy of earlier local work.
|
||||
await writeFile(path.join(repository, "marker.txt"), "third");
|
||||
await runGitCommand(["add", "-A"], { cwd: repository });
|
||||
await runGitCommand(["commit", "-m", "third"], { cwd: repository });
|
||||
await applyReviewedUpdate(service, "kept-update");
|
||||
|
||||
const retained = (await readdir(pluginRoot)).filter((entry) => /\.old-\d+$/.test(entry));
|
||||
expect(retained).toHaveLength(2);
|
||||
expect(await readdir(pluginRoot)).toHaveLength(3);
|
||||
const preserved = path.join(pluginRoot, retired[0]!, "checkout");
|
||||
expect(await readFile(path.join(preserved, "marker.txt"), "utf8")).toBe("uncommitted work");
|
||||
expect(await readFile(path.join(preserved, "untracked.txt"), "utf8")).toBe("untracked work");
|
||||
expect((await runGitCommand(["rev-parse", "HEAD"], { cwd: preserved })).stdout.trim()).toBe(
|
||||
localCommit,
|
||||
);
|
||||
expect(
|
||||
(await runGitCommand(["show", `${localCommit}:local.txt`], { cwd: preserved })).stdout.trim(),
|
||||
).toBe("unpushed commit");
|
||||
const second = retained.find((entry) => entry !== retired[0])!;
|
||||
expect(await readFile(path.join(pluginRoot, second, "checkout", "marker.txt"), "utf8")).toBe(
|
||||
"second",
|
||||
);
|
||||
|
||||
// Uninstalling still clears the plugin root, kept copies included.
|
||||
await service.removePlugin("kept-update");
|
||||
await expect(stat(pluginRoot)).rejects.toThrow();
|
||||
await service.stopAllPlugins();
|
||||
}, 30_000);
|
||||
|
||||
it("activates an update when the enabled plugin previously failed to start", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
roots.push(home);
|
||||
|
||||
@@ -749,9 +749,10 @@ export class PluginService {
|
||||
this.notify(pluginId);
|
||||
let warning: string | undefined;
|
||||
try {
|
||||
await managedSources.removeVersion(pluginId, source.path);
|
||||
const retired = await managedSources.retireVersion(pluginId, source.path);
|
||||
this.logger.info({ pluginId, directory: retired }, "Kept the previous plugin installation");
|
||||
} catch (error) {
|
||||
warning = `Plugin updated, but previous installation cleanup failed: ${error instanceof Error ? error.message : String(error)}`;
|
||||
warning = `Plugin updated, but the previous installation could not be kept: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
return {
|
||||
id: pluginId,
|
||||
|
||||
@@ -26,6 +26,8 @@ import { acquireNpm, isNpmSource, readNpmArtifact, resolveNpm } from "./managed-
|
||||
|
||||
const GIT_TIMEOUT_MS = 120_000;
|
||||
const GIT_ENV = { GIT_TERMINAL_PROMPT: "0" } as const;
|
||||
/** A retired installation root keeps its version id and gains a timestamp: `<uuid>.old-<ms>`. */
|
||||
const RETIRED_VERSION_SUFFIX = ".old-";
|
||||
// COMPAT(plugin-source-record): v0.8.0; accept and strip pre-correction selector/revision/root copies until daemon floor supports artifact-derived metadata.
|
||||
const ManagedPluginRecordSchema = z.union([
|
||||
z.object({
|
||||
@@ -381,10 +383,17 @@ export class ManagedPluginSources {
|
||||
async discard(candidate: ManagedPluginCandidate): Promise<void> {
|
||||
await rm(candidate.versionRoot, { recursive: true, force: true });
|
||||
}
|
||||
async removeVersion(pluginId: string, configuredPath: string): Promise<void> {
|
||||
/**
|
||||
* Keeps the outgoing installation root instead of deleting it, so a Git checkout's uncommitted
|
||||
* files and unpushed commits survive later updates too. Only explicit removal deletes them.
|
||||
*/
|
||||
async retireVersion(pluginId: string, configuredPath: string): Promise<string> {
|
||||
const location = this.locate(pluginId, configuredPath);
|
||||
await assertRealContainment(this.root, path.dirname(location.versionRoot));
|
||||
await rm(location.versionRoot, { recursive: true, force: true });
|
||||
const pluginRoot = path.dirname(location.versionRoot);
|
||||
await assertRealContainment(this.root, pluginRoot);
|
||||
const retired = `${location.versionRoot}${RETIRED_VERSION_SUFFIX}${Date.now()}`;
|
||||
await rename(location.versionRoot, retired);
|
||||
return retired;
|
||||
}
|
||||
async remove(pluginId: string): Promise<void> {
|
||||
if (!this.records[pluginId]) return;
|
||||
|
||||
@@ -2008,6 +2008,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
pluginTimelineItems: true,
|
||||
// COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16.
|
||||
skillManagement: true,
|
||||
// COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, remove gate after 2027-04-26.
|
||||
idleRuntimeCleanupSetting: true,
|
||||
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
||||
"terminal-restore-modes": true,
|
||||
// COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.
|
||||
|
||||
@@ -552,6 +552,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"idleRuntimeCleanup": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"catalogRefreshTimeoutMs": {
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -74,7 +74,7 @@ Runtime-safe settings include relay enablement, MCP settings, browser tools, hos
|
||||
|
||||
New homes keep relay disabled when you remove `daemon.relay.enabled`. A daemon whose config already omitted this field when it started keeps the legacy relay-enabled behavior for compatibility. Set `daemon.relay.enabled` explicitly when editing an older config.
|
||||
|
||||
Listen addresses, authentication, relay endpoints and TLS, worktree allocation, service-proxy addresses, the bundled web UI, logging, speech, voice, credentials, and local model settings require a restart. Reload applies other valid edits in the same file before reporting those paths.
|
||||
Idle runtime cleanup, listen addresses, authentication, relay endpoints and TLS, worktree allocation, service-proxy addresses, the bundled web UI, logging, speech, voice, credentials, and local model settings require a restart. Reload applies other valid edits in the same file before reporting those paths.
|
||||
|
||||
Deployment environment variables and legacy supervisor flags remain authoritative. Reload reports a changed file setting under `overrideControlledPaths` when a launch override prevents it from taking effect. This includes startup settings such as listen addresses, passwords, relay endpoints and TLS, service-proxy and web UI settings, logging, speech, and voice configuration. List settings such as hostnames and CORS origins still append across sources, so values from `config.json` continue to apply. Stop and relaunch the deployment without the override to make the file value authoritative.
|
||||
|
||||
@@ -84,6 +84,22 @@ Agent providers, both the first-class ones Paseo ships with and custom entries y
|
||||
|
||||
See [Providers](/docs/providers) for the mental model and [Supported providers](/docs/supported-providers) for the full list of agents Paseo can launch. For pointing Claude at Anthropic-compatible endpoints (Z.AI, Alibaba/Qwen), multiple profiles, custom binaries, ACP agents, and the `additionalModels` merge behavior, see [Custom providers](/docs/custom-providers). The full field reference lives on GitHub at [docs/custom-providers.md](https://github.com/getpaseo/paseo/blob/main/docs/custom-providers.md).
|
||||
|
||||
## Idle runtimes
|
||||
|
||||
Paseo releases resumable idle agent processes after one hour when its daemon baseline allows
|
||||
cleanup and any implemented provider check agrees. Your next prompt resumes the same conversation. Disable this
|
||||
for the entire host by turning off **Release idle agents** in **Settings → your host → Agents**, or with:
|
||||
|
||||
```json
|
||||
{ "agents": { "idleRuntimeCleanup": false } }
|
||||
```
|
||||
|
||||
This setting defaults to `true`. Changes in Settings apply immediately; config-file edits apply
|
||||
on config reload. Re-enabling keeps existing idle timestamps. There is no per-agent setting. Providers without a runtime safety check use the same daemon baseline and one-hour idle
|
||||
period. Active work, descendants and pending finish notifications prevent cleanup. ACP adapters
|
||||
also retain runtimes with running ACP terminals, including silent ones; exited terminals do not
|
||||
prevent cleanup. This check does not inspect processes launched internally by the provider.
|
||||
|
||||
## Worktrees
|
||||
|
||||
New worktrees are created under `$PASEO_HOME/worktrees` by default. To place new worktrees somewhere else, set `worktrees.root`:
|
||||
|
||||
@@ -72,7 +72,7 @@ paseo daemon restart
|
||||
|
||||
当你删除 `daemon.relay.enabled` 时,新的主目录会保持中继关闭。如果 daemon 启动时其配置就已经省略了这个字段,它会为了兼容而保持旧版的中继开启行为。编辑较旧的配置时,请显式设置 `daemon.relay.enabled`。
|
||||
|
||||
监听地址、身份验证、中继端点和 TLS、worktree 分配、服务代理地址、内置的 Web UI、日志、语音合成与识别、语音、凭据以及本地模型设置都需要重启。重新加载会先应用同一文件中的其他有效编辑,再报告这些路径。
|
||||
空闲运行时清理、监听地址、身份验证、中继端点和 TLS、worktree 分配、服务代理地址、内置的 Web UI、日志、语音合成与识别、语音、凭据以及本地模型设置都需要重启。重新加载会先应用同一文件中的其他有效编辑,再报告这些路径。
|
||||
|
||||
部署环境变量和旧版进程管理器参数仍然具有最终决定权。当某个启动覆盖项阻止了文件中已更改的设置生效时,重新加载会在 `overrideControlledPaths` 下报告它。这包括启动设置,例如监听地址、密码、中继端点和 TLS、服务代理和 Web UI 设置、日志、语音合成与识别以及语音配置。主机名和 CORS 来源等列表设置仍然在各个来源之间追加,因此 `config.json` 中的值继续生效。要让文件中的值具有决定权,请停止该部署并在不带覆盖项的情况下重新启动。
|
||||
|
||||
@@ -82,6 +82,16 @@ Agent Provider,包括 Paseo 自带的一等 Provider 和你在 `agents.provide
|
||||
|
||||
心智模型见 [Provider](/docs/providers),Paseo 可以启动的全部 Agent 见[支持的 Provider](/docs/supported-providers)。要让 Claude 指向兼容 Anthropic 的端点(Z.AI、Alibaba/Qwen)、使用多个配置、自定义二进制文件、ACP Agent,以及了解 `additionalModels` 的合并行为,见[自定义 Provider](/docs/custom-providers)。完整的字段参考在 GitHub 上的 [docs/custom-providers.md](https://github.com/getpaseo/paseo/blob/main/docs/custom-providers.md)。
|
||||
|
||||
## 空闲运行时 {#idle-runtimes}
|
||||
|
||||
当 daemon 的基础条件允许清理,且 Provider 已实现的安全检查也允许时,Paseo 会在可恢复的 Agent 空闲一小时后释放其进程。你的下一条提示词会恢复同一个会话。要为整个主机禁用此功能,请在 **设置 → 你的主机 → Agents** 中关闭 **释放空闲 Agent**,或设置:
|
||||
|
||||
```json
|
||||
{ "agents": { "idleRuntimeCleanup": false } }
|
||||
```
|
||||
|
||||
此设置默认为 `true`。在设置中更改后立即生效;配置文件中的更改在重新加载配置后生效。重新启用会保留现有的空闲计时。没有单个 Agent 的设置。不提供运行时安全检查的 Provider 使用相同的 daemon 基础条件和一小时空闲期。活动中的工作、后代 Agent 和待发送的完成通知都会阻止清理。ACP 适配器还会在 ACP 终端仍运行时保留运行时,即使终端没有输出;已退出的终端不会阻止清理。此检查不检查 Provider 内部启动的进程。
|
||||
|
||||
## worktree {#worktrees}
|
||||
|
||||
新的 worktree 默认创建在 `$PASEO_HOME/worktrees` 下。要把新的 worktree 放到其他位置,请设置 `worktrees.root`:
|
||||
|
||||
@@ -306,6 +306,52 @@ turn or finish as a side effect with a `completed` prompt result.
|
||||
Advertise only capabilities you implement. If steering is unsupported, omit `prompt.steer`; Paseo
|
||||
can replace the active turn instead.
|
||||
|
||||
## Permit idle runtime cleanup
|
||||
|
||||
Paseo can release an idle resumable session after one hour when its daemon baseline allows cleanup.
|
||||
Add a provider-owned veto by advertising `session.can_stop_runtime` on the connection and the
|
||||
session's `session.opened` capabilities. Without this capability, the same baseline and one-hour
|
||||
idle period apply, and the plugin receives no safety request.
|
||||
|
||||
Handle the safety query in your existing `send(input)` handler. It addresses the provider's own
|
||||
session ID, including when one connection serves several sessions:
|
||||
|
||||
```ts
|
||||
case "session.can_stop_runtime": {
|
||||
try {
|
||||
const session = sessions.get(input.sessionId);
|
||||
if (!session) throw new Error("Session not found");
|
||||
emit({
|
||||
type: "session.can_stop_runtime",
|
||||
requestId: input.requestId,
|
||||
sessionId: input.sessionId,
|
||||
canStop: await session.canStopRuntime(),
|
||||
});
|
||||
} catch (error) {
|
||||
emit({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
`canStopRuntime(): Promise<boolean>` belongs to your provider's session implementation. Return
|
||||
`true` only when releasing the runtime loses no background shells, monitors, native children,
|
||||
queued completion reports, or pending autonomous replies. This query must not stop or mutate the
|
||||
session. Return `false` while work remains. A rejected query, invalid response, or timeout also
|
||||
keeps the runtime alive. Never respond with `request.completed` for this query.
|
||||
|
||||
Paseo always checks foreground work, permissions, queued deliveries, lifecycle operations,
|
||||
working managed/native descendants, and outstanding local or remote finish subscriptions for the
|
||||
caller. Subscription protection lasts through registration, recovery and notification dispatch
|
||||
acknowledgment. A positive provider response never overrides these blockers. Paseo rechecks
|
||||
activity before the existing `session.close` operation. After protected work finishes, a fresh
|
||||
one-hour idle period starts. The saved provider persistence must resume the same conversation.
|
||||
The host setting `agents.idleRuntimeCleanup: false` disables collection for every provider.
|
||||
|
||||
## Publish timeline items
|
||||
|
||||
Publish complete snapshots through `timeline.item`. Reuse an item `id` when updating streamed text,
|
||||
|
||||
@@ -269,6 +269,38 @@ Paseo 刷新 Agent 的方式是关闭它当前的 Provider 会话,然后用当
|
||||
|
||||
只声明你实现了的能力。如果不支持引导,请省略 `prompt.steer`;Paseo 可以改为替换当前轮次。
|
||||
|
||||
## 允许清理空闲运行时 {#permit-idle-runtime-cleanup}
|
||||
|
||||
当 daemon 的基础条件允许清理时,Paseo 可以在可恢复的会话空闲一小时后释放其运行时。在连接和会话的 `session.opened` capabilities 中声明 `session.can_stop_runtime`,即可添加 Provider 自己的否决检查。没有此能力时,会话仍使用相同的基础条件和一小时空闲期,插件不会收到安全查询。
|
||||
|
||||
在现有的 `send(input)` 处理函数中处理安全查询。请求使用 Provider 自己的会话 ID,包括同一连接服务多个会话的情况:
|
||||
|
||||
```ts
|
||||
case "session.can_stop_runtime": {
|
||||
try {
|
||||
const session = sessions.get(input.sessionId);
|
||||
if (!session) throw new Error("Session not found");
|
||||
emit({
|
||||
type: "session.can_stop_runtime",
|
||||
requestId: input.requestId,
|
||||
sessionId: input.sessionId,
|
||||
canStop: await session.canStopRuntime(),
|
||||
});
|
||||
} catch (error) {
|
||||
emit({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
`canStopRuntime(): Promise<boolean>` 由你的 Provider 会话实现。只有释放运行时不会丢失后台 shell、监控任务、原生子 Agent、排队的完成报告或待处理的自主回复时,才返回 `true`。此查询不得停止或修改会话。仍有工作时返回 `false`。查询拒绝、响应无效或超时也会保留运行时。不要用 `request.completed` 响应此查询。
|
||||
|
||||
Paseo 始终检查前台工作、权限、排队投递、生命周期操作、工作中的托管或原生后代 Agent,以及调用方尚未完成的本地或远程完成通知订阅。订阅保护覆盖注册、恢复,直到通知投递获确认。Provider 返回 `true` 也不能绕过这些阻止条件。Paseo 会在调用现有的 `session.close` 操作之前重新检查活动。受保护的工作完成后,会重新开始一小时的空闲期。保存的 Provider 持久化数据必须能恢复同一个会话。主机设置 `agents.idleRuntimeCleanup: false` 会禁用所有 Provider 的清理。
|
||||
|
||||
## 发布时间线条目 {#publish-timeline-items}
|
||||
|
||||
通过 `timeline.item` 发布完整的快照。更新流式文本、运行中的工具或待办列表时,复用条目的 `id`。Paseo 会推导出实时增量,并保持正常的时间线。
|
||||
|
||||
@@ -2827,7 +2827,11 @@ JSON and noninteractive ordinary updates require `--yes`.
|
||||
|
||||
Approval acquires exactly the reviewed commit or npm artifact. If it is unavailable or the installed
|
||||
plugin changed while reviewing, the update fails and asks you to check again. Failed preparation or
|
||||
activation retains the previous installation. Manual app update review is not available yet.
|
||||
activation retains the previous installation. Successful updates keep each previous installation in
|
||||
an adjacent `<install-id>.old-<timestamp>` directory, including its local files and Git commits.
|
||||
Later updates do not delete these copies, so disk use grows until you remove them manually or
|
||||
uninstall the plugin with `paseo plugin remove <id>`. Copy out any work you need before uninstalling.
|
||||
Manual app update review is not available yet.
|
||||
|
||||
Put `--host <url>` before a management command when the target is not the CLI's default daemon. `remove`
|
||||
never deletes a directory source; it deletes managed files for Git and npm sources. The install-time
|
||||
|
||||
@@ -2485,7 +2485,10 @@ paseo plugin remove my-plugin
|
||||
JSON 模式和非交互的普通更新需要 `--yes`。
|
||||
|
||||
批准后会获取被审查的那个确切提交或 npm 制品。如果它不可用,或者在审查期间已安装的插件发生了变化,更新会失败
|
||||
并要求你重新检查。准备或激活失败会保留之前的安装。目前还不支持在应用中手动审查更新。
|
||||
并要求你重新检查。准备或激活失败会保留之前的安装。更新成功后,每次被替换的安装都会保留在相邻的
|
||||
`<install-id>.old-<timestamp>` 目录中,包括本地文件和 Git 提交。后续更新不会删除这些副本,因此磁盘占用会持续
|
||||
增长,直到你手动删除它们或运行 `paseo plugin remove <id>` 卸载插件。卸载前请先复制出需要保留的工作。
|
||||
目前还不支持在应用中手动审查更新。
|
||||
|
||||
当目标不是 CLI 的默认 daemon 时,请把 `--host <url>` 放在管理命令之前。`remove` 永远不会删除目录来源;对于
|
||||
Git 和 npm 来源,它会删除托管文件。安装时的 `--id` 就是运行时 ID,允许同一个目录或仓库被安装多次。
|
||||
|
||||
Reference in New Issue
Block a user