mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e1e85e323 | ||
|
|
74d2c46f9b | ||
|
|
89e533875c | ||
|
|
686588c44b | ||
|
|
48623801a1 | ||
|
|
632a7f8859 | ||
|
|
48d2c9ec36 | ||
|
|
ca894d49a5 | ||
|
|
d6fa231894 | ||
|
|
fed19f3d29 | ||
|
|
74dc764f0d |
+44
-2
@@ -25,8 +25,50 @@ 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; this setting takes effect at daemon startup.
|
||||
|
||||
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 has no provider safety check, so its adapters use baseline-only collection.
|
||||
|
||||
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
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-iy1sFWI+odVTFG4jvNcJfv3xGAcQrbNkNQ+gtmInNCU=
|
||||
sha256-n6m3m0541/mBbMN1xrh/2uBCRQc62yicK1cdvyyUmmU=
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
delayBrowserAgentCreatedStatus,
|
||||
openNewWorkspaceComposer,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
// The creation response carries the descriptor captured when the workspace was provisioned. With an
|
||||
// initial agent it is sent only after the agent starts, so a pin made in between (by a plugin's
|
||||
// workspace.created hook, another client, or the CLI) reaches the app first as a live update. The
|
||||
// late response must not put the old, unpinned row back.
|
||||
|
||||
function pinnedRowTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
}
|
||||
|
||||
async function findCreatedWorkspaceId(seeded: SeededWorkspace): Promise<string> {
|
||||
let createdId: string | undefined;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { entries } = await seeded.client.fetchWorkspaces({
|
||||
filter: { projectId: seeded.projectId },
|
||||
});
|
||||
createdId = entries.find((entry) => entry.id !== seeded.workspaceId)?.id;
|
||||
return createdId;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeTruthy();
|
||||
return createdId!;
|
||||
}
|
||||
|
||||
// Samples every frame, so a row that leaves Pinned and comes back on a later update still counts.
|
||||
async function watchPinnedRow(page: Page, testId: string): Promise<() => Promise<number>> {
|
||||
await page.evaluate((rowTestId) => {
|
||||
const state = { missingFrames: 0, stopped: false };
|
||||
(window as unknown as { __pinnedRowWatch: typeof state }).__pinnedRowWatch = state;
|
||||
const sample = () => {
|
||||
if (state.stopped) return;
|
||||
const section = document.querySelector('[data-testid="sidebar-pinned-section"]');
|
||||
if (!section?.querySelector(`[data-testid="${rowTestId}"]`)) state.missingFrames += 1;
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
}, testId);
|
||||
return () =>
|
||||
page.evaluate(() => {
|
||||
const state = (
|
||||
window as unknown as { __pinnedRowWatch: { missingFrames: number; stopped: boolean } }
|
||||
).__pinnedRowWatch;
|
||||
state.stopped = true;
|
||||
return state.missingFrames;
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("New workspace live pin", () => {
|
||||
test("keeps a pin made before the creation response arrives", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const creation = await delayBrowserAgentCreatedStatus(page);
|
||||
const seeded = await seedWorkspace({ repoPrefix: "new-workspace-live-pin-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, seeded);
|
||||
await submitNewWorkspacePrompt(page, "Pin me while I start");
|
||||
|
||||
await creation.waitForCreateRequest();
|
||||
// The response is held here, carrying the descriptor from before the pin.
|
||||
await creation.waitForDelayedCreatedStatus();
|
||||
const workspaceId = await findCreatedWorkspaceId(seeded);
|
||||
const rowTestId = pinnedRowTestId(workspaceId);
|
||||
|
||||
await seeded.client.setWorkspacePinned(workspaceId, true);
|
||||
const pinnedRow = page.getByTestId("sidebar-pinned-section").getByTestId(rowTestId);
|
||||
await expect(pinnedRow).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const stopWatching = await watchPinnedRow(page, rowTestId);
|
||||
creation.release();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable({
|
||||
timeout: 30_000,
|
||||
});
|
||||
// Leave time for the released response to be merged and rendered.
|
||||
await page.waitForTimeout(1_000);
|
||||
|
||||
expect(await stopWatching()).toBe(0);
|
||||
await expect(pinnedRow).toBeVisible();
|
||||
} finally {
|
||||
creation.release();
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -123,7 +123,7 @@ test("projects and pinned chats reorder while ordinary workspaces stay newest fi
|
||||
}
|
||||
});
|
||||
|
||||
async function selectWorkspaceSort(page: Page, name: "Created" | "Last prompt") {
|
||||
async function selectWorkspaceSort(page: Page, name: "Manual" | "Created" | "Last prompt") {
|
||||
await page.getByLabel("Display preferences", { exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: /^Sort/ }).click();
|
||||
await page.getByRole("menuitem", { name, exact: true }).click();
|
||||
@@ -362,3 +362,60 @@ test("compact groups retain active overflow and reveal the current ordered rows"
|
||||
await fixture.seed.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function restorePreBetaOrder(page: Page, projectKey: string, workspaceKeys: string[]) {
|
||||
await page.evaluate(
|
||||
({ projectKey: scope, workspaceKeys: order }) => {
|
||||
localStorage.setItem(
|
||||
"sidebar-project-workspace-order",
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
state: {
|
||||
projectOrder: [scope],
|
||||
pinnedWorkspaceOrder: [],
|
||||
workspaceOrderByProject: { [scope]: order },
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ projectKey, workspaceKeys },
|
||||
);
|
||||
await reconnectSidebar(page);
|
||||
}
|
||||
|
||||
test("Manual restores pre-beta order and keeps drag order across sorting and reloads", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seed = await seedWorkspace({ repoPrefix: "sidebar-manual-" });
|
||||
try {
|
||||
const second = await seed.client.createWorkspace({
|
||||
source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId },
|
||||
title: "New manual workspace",
|
||||
});
|
||||
if (!second.workspace) throw new Error(second.error ?? "Workspace creation failed");
|
||||
const keys = [seed.workspaceId, second.workspace.id].map((id) => `${getServerId()}:${id}`);
|
||||
const ids = keys.map((key) => `sidebar-workspace-row-${key}`);
|
||||
const rows = page.locator(ids.map((id) => `[data-testid="${id}"]`).join(", "));
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await restorePreBetaOrder(page, projectEquivalenceViewKey(seed.projectKey), keys);
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
|
||||
await selectWorkspaceSort(page, "Manual");
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
|
||||
await selectWorkspaceSort(page, "Created");
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
|
||||
await selectWorkspaceSort(page, "Last prompt");
|
||||
await selectWorkspaceSort(page, "Manual");
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
|
||||
await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow);
|
||||
await reconnectSidebar(page);
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
|
||||
await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow);
|
||||
await selectWorkspaceSort(page, "Created");
|
||||
await reconnectSidebar(page);
|
||||
await selectWorkspaceSort(page, "Manual");
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
|
||||
} finally {
|
||||
await seed.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -72,6 +72,9 @@ async function expectSortingRequiresHostUpdate(page: Page) {
|
||||
).toBeDisabled();
|
||||
await expect(page.getByRole("menuitem", { name: "Created", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("menuitem", { name: "Last prompt", exact: true })).toHaveCount(0);
|
||||
await page.getByRole("menuitem", { name: "Manual", exact: true }).click();
|
||||
await page.getByLabel("Display preferences", { exact: true }).click();
|
||||
await expect(page.getByRole("menuitem", { name: /^Sort/ })).toContainText("Manual");
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Open a connected host with the wide sidebar visible, keyboard closed, and
|
||||
// three-button navigation enabled on an Android tablet. Run against a fresh build:
|
||||
// node packages/app/e2e/mobile/sidebar-system-insets/android.mjs <session> <adb-serial>
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const [, , session, serial] = process.argv;
|
||||
assert(session && serial, "Pass the agent-device session and adb serial");
|
||||
const windowState = execFileSync("adb", ["-s", serial, "shell", "dumpsys", "window"], {
|
||||
encoding: "utf8",
|
||||
timeout: 10_000,
|
||||
});
|
||||
const navBar = windowState.match(/type=navigationBars frame=\[0,(\d+)\]\[\d+,\d+\] visible=true/);
|
||||
assert(navBar, "A visible bottom navigation bar is required");
|
||||
assert(!/type=ime .*visible=true/.test(windowState), "Close the keyboard first");
|
||||
const navigationTop = Number(navBar[1]);
|
||||
const { data } = JSON.parse(
|
||||
execFileSync("agent-device", ["snapshot", "--raw", "--json", "--session", session], {
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
}),
|
||||
);
|
||||
const focusedApp = windowState.match(/mCurrentFocus=.*? u\d+ ([^/\s]+)\//)?.[1];
|
||||
assert.equal(
|
||||
focusedApp,
|
||||
data.appBundleId,
|
||||
"Snapshot must belong to the focused app on this device",
|
||||
);
|
||||
for (const id of [
|
||||
"sidebar-add-project",
|
||||
"sidebar-hosts-trigger",
|
||||
"sidebar-help",
|
||||
"sidebar-settings",
|
||||
]) {
|
||||
const button = data.nodes.find((node) => node.identifier === id);
|
||||
assert(button?.rect, `${id} must be visible above the system navigation bar`);
|
||||
const bottom = button.rect.y + button.rect.height;
|
||||
assert(bottom <= navigationTop, `${id} ends at ${bottom}, below navigation top ${navigationTop}`);
|
||||
console.log(`PASS: ${id} bottom=${bottom}, navigationTop=${navigationTop}`);
|
||||
}
|
||||
@@ -28,6 +28,23 @@ function failure(message: string) {
|
||||
}
|
||||
|
||||
describe("direct connection failure copy", () => {
|
||||
it.each([
|
||||
"paseo-qa-nonexistent.invalid",
|
||||
"ssl-paseo-qa.invalid",
|
||||
"tls-paseo-qa.invalid",
|
||||
"certificate-paseo-qa.invalid",
|
||||
])(
|
||||
"identifies Android DNS failures for %s without treating the hostname as TLS evidence",
|
||||
(host) => {
|
||||
const message = `Unable to resolve host "${host}": No address associated with hostname`;
|
||||
expect(failure(message)).toEqual({
|
||||
title: "Failed: tcp://example.test:443?ssl=true",
|
||||
detail: labels.hostNotFound,
|
||||
raw: `transport error — ${message}`,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("identifies Android certificate validation errors without calling them DNS failures", () => {
|
||||
const message =
|
||||
"java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.";
|
||||
@@ -43,6 +60,7 @@ describe("direct connection failure copy", () => {
|
||||
["SSL handshake failed", labels.tlsError],
|
||||
["TLS handshake failed", labels.tlsError],
|
||||
["getaddrinfo ENOTFOUND example.test", labels.hostNotFound],
|
||||
["getaddrinfo ENOTFOUND ssl.example.test", labels.hostNotFound],
|
||||
["Host not found", labels.hostNotFound],
|
||||
["connect ECONNREFUSED", labels.refused],
|
||||
["connection timed out", labels.timedOut],
|
||||
|
||||
@@ -83,6 +83,8 @@ export function buildConnectionFailureCopy(input: {
|
||||
rawLower.includes("err_connection_refused")
|
||||
) {
|
||||
detail = labels.refused;
|
||||
} else if (rawLower.includes("unable to resolve host") || rawLower.includes("enotfound")) {
|
||||
detail = labels.hostNotFound;
|
||||
} else if (
|
||||
rawLower.includes("certificate") ||
|
||||
rawLower.includes("certpathvalidator") ||
|
||||
@@ -91,7 +93,7 @@ export function buildConnectionFailureCopy(input: {
|
||||
rawLower.includes("ssl")
|
||||
) {
|
||||
detail = labels.tlsError;
|
||||
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
|
||||
} else if (rawLower.includes("not found")) {
|
||||
detail = labels.hostNotFound;
|
||||
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
|
||||
detail = labels.hostUnreachable;
|
||||
|
||||
@@ -719,8 +719,11 @@ function DesktopSidebar({
|
||||
[active, resizeAnimatedStyle],
|
||||
);
|
||||
const desktopSidebarBorderStyle = useMemo(
|
||||
() => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }],
|
||||
[insetsTop],
|
||||
() => [
|
||||
styles.desktopSidebarBorder,
|
||||
{ flex: 1, paddingTop: insetsTop, paddingBottom: insets.bottom },
|
||||
],
|
||||
[insetsTop, insets.bottom],
|
||||
);
|
||||
const sidebarHeaderGroupStyle = useMemo(
|
||||
() => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome],
|
||||
|
||||
@@ -1550,6 +1550,11 @@ function ProjectBlock({
|
||||
shortcutIndexByWorkspaceKey,
|
||||
onToggleCollapsed,
|
||||
onWorkspacePress,
|
||||
parentGestureRef,
|
||||
onWorkspaceReorder,
|
||||
useNestable,
|
||||
dragGestureHostActive,
|
||||
manualSort,
|
||||
onWorktreeCreated,
|
||||
drag,
|
||||
isDragging,
|
||||
@@ -1571,6 +1576,11 @@ function ProjectBlock({
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
onToggleCollapsed: (projectViewKey: string) => void;
|
||||
onWorkspacePress?: () => void;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void;
|
||||
useNestable: boolean;
|
||||
dragGestureHostActive?: boolean;
|
||||
manualSort: boolean;
|
||||
onWorktreeCreated?: (workspaceId: string) => void;
|
||||
drag: () => void;
|
||||
isDragging: boolean;
|
||||
@@ -1624,7 +1634,14 @@ function ProjectBlock({
|
||||
});
|
||||
|
||||
const renderWorkspaceRow = useCallback(
|
||||
(item: SidebarWorkspacePlacement) => {
|
||||
(
|
||||
item: SidebarWorkspacePlacement,
|
||||
input?: {
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
},
|
||||
) => {
|
||||
return (
|
||||
<MemoWorkspaceRowItem
|
||||
key={item.workspaceKey}
|
||||
@@ -1640,6 +1657,9 @@ function ProjectBlock({
|
||||
selectionEnabled={selectionEnabled}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
drag={input?.drag}
|
||||
isDragging={input?.isDragging}
|
||||
dragHandleProps={input?.dragHandleProps}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -1658,6 +1678,29 @@ function ProjectBlock({
|
||||
],
|
||||
);
|
||||
|
||||
const renderWorkspace = useCallback(
|
||||
({
|
||||
item,
|
||||
drag: workspaceDrag,
|
||||
isActive,
|
||||
dragHandleProps: workspaceDragHandleProps,
|
||||
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
|
||||
return renderWorkspaceRow(item, {
|
||||
drag: workspaceDrag,
|
||||
isDragging: isActive,
|
||||
dragHandleProps: workspaceDragHandleProps,
|
||||
});
|
||||
},
|
||||
[renderWorkspaceRow],
|
||||
);
|
||||
|
||||
const handleWorkspaceDragEnd = useCallback(
|
||||
(workspaces: SidebarWorkspacePlacement[]) => {
|
||||
onWorkspaceReorder(project.viewKey, workspaces);
|
||||
},
|
||||
[onWorkspaceReorder, project.viewKey],
|
||||
);
|
||||
|
||||
const toast = useToast();
|
||||
const { t } = useTranslation();
|
||||
const [isRemovingProject, setIsRemovingProject] = useState(false);
|
||||
@@ -1723,12 +1766,29 @@ function ProjectBlock({
|
||||
if (project.workspaces.length > 0) {
|
||||
projectChildren = (
|
||||
<>
|
||||
<View
|
||||
testID={`sidebar-workspace-list-${project.viewKey}`}
|
||||
style={styles.workspaceListContainer}
|
||||
>
|
||||
{visibleWorkspaces.map(renderWorkspaceRow)}
|
||||
</View>
|
||||
{manualSort ? (
|
||||
<DraggableList
|
||||
testID={`sidebar-workspace-list-${project.viewKey}`}
|
||||
data={visibleWorkspaces}
|
||||
keyExtractor={workspaceKeyExtractor}
|
||||
renderItem={renderWorkspace}
|
||||
onDragEnd={handleWorkspaceDragEnd}
|
||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
testID={`sidebar-workspace-list-${project.viewKey}`}
|
||||
style={styles.workspaceListContainer}
|
||||
>
|
||||
{visibleWorkspaces.map((item) => renderWorkspaceRow(item))}
|
||||
</View>
|
||||
)}
|
||||
{canToggleWorkspaces ? (
|
||||
<SidebarGroupToggleRow
|
||||
expanded={workspacesExpanded}
|
||||
@@ -1803,6 +1863,11 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
|
||||
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
|
||||
previous.onToggleCollapsed === next.onToggleCollapsed &&
|
||||
previous.onWorkspacePress === next.onWorkspacePress &&
|
||||
previous.parentGestureRef === next.parentGestureRef &&
|
||||
previous.onWorkspaceReorder === next.onWorkspaceReorder &&
|
||||
previous.useNestable === next.useNestable &&
|
||||
previous.dragGestureHostActive === next.dragGestureHostActive &&
|
||||
previous.manualSort === next.manualSort &&
|
||||
previous.onWorktreeCreated === next.onWorktreeCreated &&
|
||||
previous.drag === next.drag &&
|
||||
previous.isDragging === next.isDragging &&
|
||||
@@ -2112,6 +2177,9 @@ function ProjectModeList({
|
||||
(state) => state.togglePinnedCollapsed,
|
||||
);
|
||||
|
||||
const manualSort = useSidebarViewStore((state) => state.workspaceSort === "manual");
|
||||
const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder);
|
||||
const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder);
|
||||
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder);
|
||||
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
|
||||
|
||||
@@ -2211,6 +2279,30 @@ function ProjectModeList({
|
||||
[getProjectOrder, setProjectOrder],
|
||||
);
|
||||
|
||||
const handleWorkspaceReorder = useCallback(
|
||||
(projectViewKey: string, reorderedWorkspaces: SidebarWorkspacePlacement[]) => {
|
||||
const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey);
|
||||
const currentWorkspaceOrder = getWorkspaceOrder(projectViewKey);
|
||||
if (
|
||||
!hasVisibleOrderChanged({
|
||||
currentOrder: currentWorkspaceOrder,
|
||||
reorderedVisibleKeys: reorderedWorkspaceKeys,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkspaceOrder(
|
||||
projectViewKey,
|
||||
mergeWithRemainder({
|
||||
currentOrder: currentWorkspaceOrder,
|
||||
reorderedVisibleKeys: reorderedWorkspaceKeys,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[getWorkspaceOrder, setWorkspaceOrder],
|
||||
);
|
||||
|
||||
const handleWorktreeCreated = useCallback((workspaceId: string) => {
|
||||
setCreatingWorkspaceIds((current) => {
|
||||
const next = new Set(current);
|
||||
@@ -2259,6 +2351,11 @@ function ProjectModeList({
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
onToggleCollapsed={onToggleProjectCollapsed}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
manualSort={manualSort}
|
||||
parentGestureRef={parentGestureRef}
|
||||
onWorkspaceReorder={handleWorkspaceReorder}
|
||||
useNestable={platformIsNative}
|
||||
dragGestureHostActive={dragGestureHostActive}
|
||||
onWorktreeCreated={handleWorktreeCreated}
|
||||
drag={dragState.drag}
|
||||
isDragging={dragState.isDragging}
|
||||
@@ -2276,6 +2373,10 @@ function ProjectModeList({
|
||||
collapsedProjectKeys,
|
||||
activeWorkspaceSelection,
|
||||
handleWorktreeCreated,
|
||||
manualSort,
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
handleWorkspaceReorder,
|
||||
hostBadgeByServerId,
|
||||
supportsMultiplicityByServerId,
|
||||
supportsPinningByServerId,
|
||||
|
||||
@@ -244,10 +244,8 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
definitions.push({
|
||||
id: "sort",
|
||||
title: t("sidebar.display.sort.label"),
|
||||
content: workspaceSortSupported ? (
|
||||
<SortPage preferences={preferences} />
|
||||
) : (
|
||||
<MenuItem disabled>{t("sidebar.display.sort.updateHost")}</MenuItem>
|
||||
content: (
|
||||
<SortPage preferences={preferences} automaticSortSupported={workspaceSortSupported} />
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -325,7 +323,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
<MenuSubTrigger
|
||||
id="sort"
|
||||
value={
|
||||
workspaceSortSupported
|
||||
workspaceSortSupported || preferences.workspaceSort === "manual"
|
||||
? t(`sidebar.display.sort.${preferences.workspaceSort}`)
|
||||
: undefined
|
||||
}
|
||||
@@ -399,19 +397,38 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
function SortPage({ preferences }: { preferences: Preferences }): ReactElement {
|
||||
function SortPage({
|
||||
preferences,
|
||||
automaticSortSupported,
|
||||
}: {
|
||||
preferences: Preferences;
|
||||
automaticSortSupported: boolean;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { setWorkspaceSort } = preferences;
|
||||
const selectManual = useCallback(() => setWorkspaceSort("manual"), [setWorkspaceSort]);
|
||||
const selectCreated = useCallback(() => setWorkspaceSort("created"), [setWorkspaceSort]);
|
||||
const selectLastPrompt = useCallback(() => setWorkspaceSort("lastPrompt"), [setWorkspaceSort]);
|
||||
return (
|
||||
<>
|
||||
<MenuItem selected={preferences.workspaceSort === "created"} onSelect={selectCreated}>
|
||||
{t("sidebar.display.sort.created")}
|
||||
</MenuItem>
|
||||
<MenuItem selected={preferences.workspaceSort === "lastPrompt"} onSelect={selectLastPrompt}>
|
||||
{t("sidebar.display.sort.lastPrompt")}
|
||||
<MenuItem selected={preferences.workspaceSort === "manual"} onSelect={selectManual}>
|
||||
{t("sidebar.display.sort.manual")}
|
||||
</MenuItem>
|
||||
{automaticSortSupported ? (
|
||||
<>
|
||||
<MenuItem selected={preferences.workspaceSort === "created"} onSelect={selectCreated}>
|
||||
{t("sidebar.display.sort.created")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
selected={preferences.workspaceSort === "lastPrompt"}
|
||||
onSelect={selectLastPrompt}
|
||||
>
|
||||
{t("sidebar.display.sort.lastPrompt")}
|
||||
</MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<MenuItem disabled>{t("sidebar.display.sort.updateHost")}</MenuItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -617,7 +617,7 @@ describe("computeSidebarOrderUpdates", () => {
|
||||
persistedProjectOrder: ["stale-project"],
|
||||
});
|
||||
|
||||
expect(updates).toEqual({ projectOrder: null });
|
||||
expect(updates).toEqual({ projectOrder: null, workspaceOrders: [] });
|
||||
});
|
||||
|
||||
it("appends unseen projects without changing saved project order", () => {
|
||||
@@ -979,3 +979,20 @@ it("retains unrelated projects and keyed row placements when one project reorder
|
||||
expect(after.projects[0].workspaces[0]).toBe(before.projects[0].workspaces[1]);
|
||||
expect(after.projects[0].workspaces[1]).toBe(before.projects[0].workspaces[0]);
|
||||
});
|
||||
|
||||
it("prepends newly discovered manual workspaces without dropping filtered saved keys", () => {
|
||||
const projects = [sidebarProject({ projectKey: "p", workspaceKeys: ["new", "b", "a"] })];
|
||||
expect(
|
||||
computeSidebarOrderUpdates({
|
||||
projects,
|
||||
persistedProjectOrder: ["p"],
|
||||
getWorkspaceOrder: () => ["srv:hidden", "srv:a", "srv:b"],
|
||||
}),
|
||||
).toEqual({
|
||||
projectOrder: null,
|
||||
workspaceOrders: [{ projectViewKey: "p", order: ["srv:new", "srv:hidden", "srv:a", "srv:b"] }],
|
||||
});
|
||||
expect(
|
||||
computeSidebarOrderUpdates({ projects, persistedProjectOrder: ["p"] }).workspaceOrders,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -535,21 +535,57 @@ export function appendMissingOrderKeys(input: {
|
||||
return [...input.currentOrder, ...missingKeys];
|
||||
}
|
||||
|
||||
export function prependMissingOrderKeys(input: {
|
||||
currentOrder: string[];
|
||||
visibleKeys: string[];
|
||||
}): string[] {
|
||||
if (input.visibleKeys.length === 0) {
|
||||
return input.currentOrder;
|
||||
}
|
||||
|
||||
const existingKeys = new Set(input.currentOrder);
|
||||
const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key));
|
||||
if (missingKeys.length === 0) {
|
||||
return input.currentOrder;
|
||||
}
|
||||
|
||||
return [...missingKeys, ...input.currentOrder];
|
||||
}
|
||||
|
||||
export interface SidebarOrderUpdates {
|
||||
projectOrder: string[] | null;
|
||||
workspaceOrders: Array<{ projectViewKey: string; order: string[] }>;
|
||||
}
|
||||
|
||||
export function computeSidebarOrderUpdates(input: {
|
||||
projects: SidebarProjectEntry[];
|
||||
persistedProjectOrder: string[];
|
||||
getWorkspaceOrder?: (projectViewKey: string) => string[];
|
||||
}): SidebarOrderUpdates {
|
||||
if (input.projects.length === 0) {
|
||||
return { projectOrder: null, workspaceOrders: [] };
|
||||
}
|
||||
|
||||
const nextProjectOrder = appendMissingOrderKeys({
|
||||
currentOrder: input.persistedProjectOrder,
|
||||
visibleKeys: input.projects.map((project) => project.viewKey),
|
||||
});
|
||||
return {
|
||||
projectOrder: nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder,
|
||||
};
|
||||
const projectOrder = nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder;
|
||||
|
||||
const workspaceOrders: Array<{ projectViewKey: string; order: string[] }> = [];
|
||||
for (const project of input.projects) {
|
||||
if (!input.getWorkspaceOrder) break;
|
||||
const persistedWorkspaceOrder = input.getWorkspaceOrder(project.viewKey);
|
||||
const nextWorkspaceOrder = prependMissingOrderKeys({
|
||||
currentOrder: persistedWorkspaceOrder,
|
||||
visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey),
|
||||
});
|
||||
if (nextWorkspaceOrder !== persistedWorkspaceOrder) {
|
||||
workspaceOrders.push({ projectViewKey: project.viewKey, order: nextWorkspaceOrder });
|
||||
}
|
||||
}
|
||||
|
||||
return { projectOrder, workspaceOrders };
|
||||
}
|
||||
|
||||
export interface SidebarLoadingState {
|
||||
|
||||
@@ -144,7 +144,10 @@ export function useSidebarWorkspacesList(options?: {
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
const sortSupport = useHostFeatureMap(directoryServerIds, "workspaceSortTimes");
|
||||
const workspaceSortSupported = [...sortSupport.values()].every(Boolean);
|
||||
const sort = workspaceSortSupported && groupMode === "project" ? workspaceSort : undefined;
|
||||
const sort =
|
||||
groupMode === "project" && (workspaceSort === "manual" || workspaceSortSupported)
|
||||
? workspaceSort
|
||||
: undefined;
|
||||
const { projects: hostProjects } = useWorkspaceStructure(directoryServerIds, sort);
|
||||
|
||||
const selectPlacements = useMemo(createSidebarWorkspacePlacementSelector, []);
|
||||
@@ -166,12 +169,16 @@ export function useSidebarWorkspacesList(options?: {
|
||||
const updates = computeSidebarOrderUpdates({
|
||||
projects,
|
||||
persistedProjectOrder,
|
||||
getWorkspaceOrder: sort === "manual" ? orderStore.getWorkspaceOrder : undefined,
|
||||
});
|
||||
|
||||
if (updates.projectOrder) {
|
||||
orderStore.setProjectOrder(updates.projectOrder);
|
||||
}
|
||||
}, [persistedProjectOrder, projects]);
|
||||
for (const update of updates.workspaceOrders) {
|
||||
orderStore.setWorkspaceOrder(update.projectViewKey, update.order);
|
||||
}
|
||||
}, [persistedProjectOrder, projects, sort]);
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
if (!isActive) return;
|
||||
|
||||
@@ -1169,6 +1169,7 @@ export const ar: TranslationResources = {
|
||||
sort: {
|
||||
label: "الترتيب",
|
||||
created: "تاريخ الإنشاء",
|
||||
manual: "يدوي",
|
||||
lastPrompt: "آخر طلب",
|
||||
updateHost: "حدّث المضيفين لترتيب مساحات العمل",
|
||||
},
|
||||
|
||||
@@ -1177,6 +1177,7 @@ export const en = {
|
||||
sort: {
|
||||
label: "Sort",
|
||||
created: "Created",
|
||||
manual: "Manual",
|
||||
lastPrompt: "Last prompt",
|
||||
updateHost: "Update hosts to sort workspaces",
|
||||
},
|
||||
|
||||
@@ -1205,6 +1205,7 @@ export const es: TranslationResources = {
|
||||
sort: {
|
||||
label: "Orden",
|
||||
created: "Creación",
|
||||
manual: "Manual",
|
||||
lastPrompt: "Último prompt",
|
||||
updateHost: "Actualiza los hosts para ordenar los espacios de trabajo",
|
||||
},
|
||||
|
||||
@@ -1198,6 +1198,7 @@ export const fr: TranslationResources = {
|
||||
sort: {
|
||||
label: "Tri",
|
||||
created: "Création",
|
||||
manual: "Manuel",
|
||||
lastPrompt: "Dernier prompt",
|
||||
updateHost: "Mettez les hôtes à jour pour trier les espaces de travail",
|
||||
},
|
||||
|
||||
@@ -1182,6 +1182,7 @@ export const ja: TranslationResources = {
|
||||
sort: {
|
||||
label: "並び順",
|
||||
created: "作成日時",
|
||||
manual: "手動",
|
||||
lastPrompt: "最後のプロンプト",
|
||||
updateHost: "ワークスペースを並べ替えるにはホストを更新してください",
|
||||
},
|
||||
|
||||
@@ -1176,6 +1176,7 @@ export const ko: TranslationResources = {
|
||||
sort: {
|
||||
label: "정렬",
|
||||
created: "생성일",
|
||||
manual: "수동",
|
||||
lastPrompt: "마지막 프롬프트",
|
||||
updateHost: "작업 공간을 정렬하려면 호스트를 업데이트하세요",
|
||||
},
|
||||
|
||||
@@ -1195,6 +1195,7 @@ export const ptBR: TranslationResources = {
|
||||
sort: {
|
||||
label: "Ordenação",
|
||||
created: "Criação",
|
||||
manual: "Manual",
|
||||
lastPrompt: "Último prompt",
|
||||
updateHost: "Atualize os hosts para ordenar os espaços de trabalho",
|
||||
},
|
||||
|
||||
@@ -1186,6 +1186,7 @@ export const ru: TranslationResources = {
|
||||
sort: {
|
||||
label: "Сортировка",
|
||||
created: "Дата создания",
|
||||
manual: "Вручную",
|
||||
lastPrompt: "Последний запрос",
|
||||
updateHost: "Обновите хосты для сортировки рабочих пространств",
|
||||
},
|
||||
|
||||
@@ -1161,6 +1161,7 @@ export const zhCN: TranslationResources = {
|
||||
sort: {
|
||||
label: "排序",
|
||||
created: "创建时间",
|
||||
manual: "手动",
|
||||
lastPrompt: "最近提示词",
|
||||
updateHost: "请更新主机以排序工作区",
|
||||
},
|
||||
|
||||
@@ -30,7 +30,7 @@ interface WorkspaceStructureSession {
|
||||
workspaces: Iterable<WorkspaceDescriptor>;
|
||||
}
|
||||
|
||||
export type WorkspaceSort = "created" | "lastPrompt";
|
||||
export type WorkspaceSort = "manual" | "created" | "lastPrompt";
|
||||
|
||||
interface WorkspaceSortItem {
|
||||
workspaceName: string;
|
||||
|
||||
@@ -266,3 +266,115 @@ it("setup metadata from directory snapshots and transitions resets failure surfa
|
||||
expect(store.claimFailedSetupSurface({ ...identity, status: "failed" })).toBe(false);
|
||||
expect(useWorkspaceSetupStore.getState().snapshots).toEqual({});
|
||||
});
|
||||
|
||||
it("keeps a live workspace update when an older creation snapshot arrives", () => {
|
||||
const serverId = "workspace-create-after-pin";
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
const replica = new WorkspaceDirectoryReplica(serverId);
|
||||
const initial = workspace("new");
|
||||
const pinned = {
|
||||
...initial,
|
||||
pinnedAt: "2026-09-26T00:00:00.000Z",
|
||||
title: "Updated title",
|
||||
};
|
||||
|
||||
try {
|
||||
replica.applyDelta({ kind: "upsert", workspace: pinned });
|
||||
const accepted = useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id);
|
||||
|
||||
expect(replica.acceptWorkspaces([normalizeWorkspaceDescriptor(initial)])).toEqual([]);
|
||||
expect(replica.snapshot().workspaces.get(initial.id)).toEqual(
|
||||
normalizeWorkspaceDescriptor(pinned),
|
||||
);
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toBe(
|
||||
accepted,
|
||||
);
|
||||
} finally {
|
||||
store.clearSession(serverId);
|
||||
}
|
||||
});
|
||||
|
||||
it("seeds missing workspaces before live updates and in a mixed creation batch", () => {
|
||||
const serverId = "workspace-create-before-pin";
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
const replica = new WorkspaceDirectoryReplica(serverId);
|
||||
const initial = normalizeWorkspaceDescriptor(workspace("new"));
|
||||
const second = normalizeWorkspaceDescriptor(workspace("second"));
|
||||
const pinned = { ...workspace(initial.id), pinnedAt: "2026-09-26T00:00:00.000Z" };
|
||||
|
||||
try {
|
||||
expect(replica.acceptWorkspaces([initial])).toEqual([
|
||||
{ kind: "workspace", type: "upsert", id: initial.id, value: initial },
|
||||
]);
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toEqual(
|
||||
initial,
|
||||
);
|
||||
|
||||
replica.applyDelta({ kind: "upsert", workspace: pinned });
|
||||
expect(replica.acceptWorkspaces([initial, second])).toEqual([
|
||||
{ kind: "workspace", type: "upsert", id: second.id, value: second },
|
||||
]);
|
||||
expect([...useSessionStore.getState().sessions[serverId]!.workspaces.values()]).toEqual([
|
||||
normalizeWorkspaceDescriptor(pinned),
|
||||
second,
|
||||
]);
|
||||
} finally {
|
||||
store.clearSession(serverId);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a manual unpin when a stale pinned snapshot arrives", () => {
|
||||
const serverId = "workspace-manual-unpin";
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
const replica = new WorkspaceDirectoryReplica(serverId);
|
||||
const pinned = { ...workspace("new"), pinnedAt: "2026-09-26T00:00:00.000Z" };
|
||||
const unpinned = { ...pinned, pinnedAt: null };
|
||||
|
||||
try {
|
||||
replica.acceptWorkspaces([normalizeWorkspaceDescriptor(pinned)]);
|
||||
replica.applyDelta({ kind: "upsert", workspace: unpinned });
|
||||
expect(replica.acceptWorkspaces([normalizeWorkspaceDescriptor(pinned)])).toEqual([]);
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(pinned.id)).toEqual(
|
||||
normalizeWorkspaceDescriptor(unpinned),
|
||||
);
|
||||
} finally {
|
||||
store.clearSession(serverId);
|
||||
}
|
||||
});
|
||||
|
||||
it("restores a missing workspace after an archive failure without replacing a live row", () => {
|
||||
const serverId = "workspace-archive-rollback";
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
const replica = new WorkspaceDirectoryReplica(serverId);
|
||||
const initial = normalizeWorkspaceDescriptor(workspace("new"));
|
||||
|
||||
try {
|
||||
replica.acceptWorkspaces([initial]);
|
||||
markWorkspaceArchivePending({ serverId, workspaceId: initial.id });
|
||||
replica.removeWorkspaceSnapshot(initial.id);
|
||||
expect(replica.acceptWorkspaces([initial])).toEqual([]);
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(initial.id)).toBe(false);
|
||||
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId: initial.id });
|
||||
expect(replica.acceptWorkspaces([initial])).toEqual([
|
||||
{ kind: "workspace", type: "upsert", id: initial.id, value: initial },
|
||||
]);
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toEqual(
|
||||
initial,
|
||||
);
|
||||
|
||||
const updated = { ...workspace(initial.id), title: "Updated during archive" };
|
||||
replica.applyDelta({ kind: "upsert", workspace: updated });
|
||||
expect(replica.acceptWorkspaces([initial])).toEqual([]);
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toEqual(
|
||||
normalizeWorkspaceDescriptor(updated),
|
||||
);
|
||||
} finally {
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId: initial.id });
|
||||
store.clearSession(serverId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -82,7 +82,12 @@ export class WorkspaceDirectoryReplica {
|
||||
}
|
||||
|
||||
acceptWorkspaces(workspaces: readonly WorkspaceDescriptor[]): DirectoryReplicaMutation[] {
|
||||
return workspaces.flatMap((workspace) => this.upsertWorkspace(workspace));
|
||||
// Creation responses and progress snapshots can arrive after live updates.
|
||||
// They seed missing rows; the directory owns rows it has already received.
|
||||
return workspaces.flatMap((workspace) => {
|
||||
if (this.workspaces.has(workspace.id)) return [];
|
||||
return this.upsertWorkspace(workspace);
|
||||
});
|
||||
}
|
||||
|
||||
acceptProject(project: ProjectDescriptor): DirectoryReplicaMutation[] {
|
||||
|
||||
@@ -117,13 +117,17 @@ export function useWorkspaceStructure(
|
||||
(state) => selectProjectOrder(state),
|
||||
workspaceEqualityFns.deep,
|
||||
);
|
||||
const workspaceOrderByProject = useSidebarOrderStore((state) =>
|
||||
workspaceSort === "manual" ? state.workspaceOrderByProject : undefined,
|
||||
);
|
||||
return useMemo(
|
||||
() =>
|
||||
composeWorkspaceStructure({
|
||||
projects,
|
||||
projectOrder,
|
||||
workspaceOrderByProject,
|
||||
}),
|
||||
[projectOrder, projects],
|
||||
[projectOrder, projects, workspaceOrderByProject],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -640,3 +640,27 @@ it("a prompt changes only its project's structure and output preserves both proj
|
||||
expect(output[0]).toBe(after[0]);
|
||||
expect(output[1]).toBe(after[1]);
|
||||
});
|
||||
|
||||
it("applies manual order only when requested without mutating the automatic baseline", () => {
|
||||
const project = {
|
||||
viewKey: "p",
|
||||
projectKey: "p",
|
||||
projectName: "P",
|
||||
projectKind: "git" as const,
|
||||
iconWorkingDir: "/repo",
|
||||
hosts: [],
|
||||
workspaceKeys: ["h:new", "h:b", "h:a"],
|
||||
};
|
||||
const input = { projects: [project], projectOrder: ["p"] };
|
||||
expect(
|
||||
composeWorkspaceStructure({
|
||||
...input,
|
||||
workspaceOrderByProject: { p: ["h:filtered", "h:a", "h:b"] },
|
||||
}).projects[0].workspaceKeys,
|
||||
).toEqual(["h:new", "h:a", "h:b"]);
|
||||
expect(composeWorkspaceStructure(input).projects[0].workspaceKeys).toEqual([
|
||||
"h:new",
|
||||
"h:b",
|
||||
"h:a",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -254,6 +254,7 @@ export function selectProjectOrder(state: SidebarOrderSnapshot): string[] {
|
||||
export function composeWorkspaceStructure(input: {
|
||||
projects: WorkspaceStructureProject[];
|
||||
projectOrder: readonly string[];
|
||||
workspaceOrderByProject?: Readonly<Record<string, string[]>>;
|
||||
}): WorkspaceStructure {
|
||||
if (input.projects.length === 0) {
|
||||
return EMPTY_WORKSPACE_STRUCTURE;
|
||||
@@ -265,7 +266,21 @@ export function composeWorkspaceStructure(input: {
|
||||
getKey: (project) => project.viewKey,
|
||||
});
|
||||
|
||||
return { projects: orderedProjects };
|
||||
return {
|
||||
// Keep the shared automatic projection immutable when applying a sidebar preference.
|
||||
// eslint-disable-next-line oxc/no-map-spread
|
||||
projects: orderedProjects.map((project) => {
|
||||
const storedOrder = input.workspaceOrderByProject?.[project.viewKey];
|
||||
if (!storedOrder) return project;
|
||||
const orderedProject = { ...project };
|
||||
orderedProject.workspaceKeys = applyStoredOrdering({
|
||||
items: project.workspaceKeys,
|
||||
storedOrder,
|
||||
getKey: (key) => key,
|
||||
});
|
||||
return orderedProject;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function selectWorkspaceKeys(state: SessionsSnapshot, serverId: string | null): string[] {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import { migrateSidebarOrderState, useSidebarOrderStore } from "./sidebar-order-store";
|
||||
|
||||
describe("migrateSidebarOrderState", () => {
|
||||
it("discards legacy workspace ordering while retaining project ordering", () => {
|
||||
it("preserves legacy workspace ordering across hosts", () => {
|
||||
const migrated = migrateSidebarOrderState({
|
||||
projectOrderByServerId: {
|
||||
"host-a": ["project-a"],
|
||||
@@ -21,6 +21,7 @@ describe("migrateSidebarOrderState", () => {
|
||||
expect(migrated).toEqual({
|
||||
projectOrder: ["project-a"],
|
||||
pinnedWorkspaceOrder: [],
|
||||
workspaceOrderByProject: { "project-a": ["host-a:main", "host-a:feature", "host-b:main"] },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,14 +89,18 @@ describe("sidebar order keys that end in whitespace", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves project and pinned order while discarding ordinary workspace order", () => {
|
||||
it("preserves project, pinned and ordinary workspace order", () => {
|
||||
expect(
|
||||
migrateSidebarOrderState({
|
||||
projectOrder: ["b", "a"],
|
||||
pinnedWorkspaceOrder: ["h:y", "h:x"],
|
||||
workspaceOrderByProject: { a: ["h:old", "h:new"] },
|
||||
}),
|
||||
).toEqual({ projectOrder: ["b", "a"], pinnedWorkspaceOrder: ["h:y", "h:x"] });
|
||||
).toEqual({
|
||||
projectOrder: ["b", "a"],
|
||||
pinnedWorkspaceOrder: ["h:y", "h:x"],
|
||||
workspaceOrderByProject: { a: ["h:old", "h:new"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves whitespace in current project and pin identities during upgrade", () => {
|
||||
@@ -105,5 +110,37 @@ it("preserves whitespace in current project and pin identities during upgrade",
|
||||
pinnedWorkspaceOrder: ["host:workspace "],
|
||||
workspaceOrderByProject: {},
|
||||
}),
|
||||
).toEqual({ projectOrder: ["project "], pinnedWorkspaceOrder: ["host:workspace "] });
|
||||
).toEqual({
|
||||
projectOrder: ["project "],
|
||||
pinnedWorkspaceOrder: ["host:workspace "],
|
||||
workspaceOrderByProject: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("retains a pre-beta manual order when upgrading", () => {
|
||||
expect(
|
||||
migrateSidebarOrderState({
|
||||
projectOrder: ["project"],
|
||||
pinnedWorkspaceOrder: [],
|
||||
workspaceOrderByProject: { project: ["host:b", "host:a"] },
|
||||
}),
|
||||
).toEqual({
|
||||
projectOrder: ["project"],
|
||||
pinnedWorkspaceOrder: [],
|
||||
workspaceOrderByProject: { project: ["host:b", "host:a"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("starts with an empty manual order for beta users whose order was discarded", () => {
|
||||
expect(migrateSidebarOrderState({ projectOrder: ["p"], pinnedWorkspaceOrder: ["h:a"] })).toEqual({
|
||||
projectOrder: ["p"],
|
||||
pinnedWorkspaceOrder: ["h:a"],
|
||||
workspaceOrderByProject: {},
|
||||
});
|
||||
});
|
||||
it("preserves spaces in saved workspace scopes and identities", () => {
|
||||
expect(
|
||||
migrateSidebarOrderState({ workspaceOrderByProject: { "project ": ["host:b ", "host:a "] } })
|
||||
.workspaceOrderByProject,
|
||||
).toEqual({ "project ": ["host:b ", "host:a "] });
|
||||
});
|
||||
|
||||
@@ -7,10 +7,13 @@ import { createValidatedPersistStorage } from "@/storage/validated-persist-stora
|
||||
interface SidebarOrderStoreState {
|
||||
projectOrder: string[];
|
||||
pinnedWorkspaceOrder: string[];
|
||||
workspaceOrderByProject: Record<string, string[]>;
|
||||
getProjectOrder: () => string[];
|
||||
setProjectOrder: (keys: string[]) => void;
|
||||
getPinnedWorkspaceOrder: () => string[];
|
||||
setPinnedWorkspaceOrder: (keys: string[]) => void;
|
||||
getWorkspaceOrder: (projectViewKey: string) => string[];
|
||||
setWorkspaceOrder: (projectViewKey: string, keys: string[]) => void;
|
||||
}
|
||||
|
||||
interface SidebarOrderPersistedState {
|
||||
@@ -25,12 +28,16 @@ const StringArrayRecordSchema = z.record(z.string(), z.array(z.string()));
|
||||
const SidebarOrderPersistedStateSchema = z.strictObject({
|
||||
projectOrder: z.array(z.string()).optional(),
|
||||
pinnedWorkspaceOrder: z.array(z.string()).optional(),
|
||||
// COMPAT(automaticWorkspaceOrder): v0.13.0; accept discarded keys until 2027-04-22 so strict parsing preserves projects/pins.
|
||||
workspaceOrderByProject: StringArrayRecordSchema.optional(),
|
||||
projectOrderByServerId: StringArrayRecordSchema.optional(),
|
||||
workspaceOrderByServerAndProject: StringArrayRecordSchema.optional(),
|
||||
});
|
||||
|
||||
interface SidebarWorkspaceOrderScope {
|
||||
serverId: string;
|
||||
projectViewKey: string;
|
||||
}
|
||||
|
||||
// Trims each key. Only for persisted state read at migration time, where a
|
||||
// stray space is an artifact of an older format rather than part of the key.
|
||||
function normalizeKeys(keys: string[]): string[] {
|
||||
@@ -71,13 +78,42 @@ function dedupeKeys(keys: string[]): string[] {
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function normalizeWorkspaceOrderByProject(
|
||||
workspaceOrderByProject: Record<string, string[]> | undefined,
|
||||
): Record<string, string[]> {
|
||||
const normalized: Record<string, string[]> = {};
|
||||
for (const [projectViewKey, order] of Object.entries(workspaceOrderByProject ?? {})) {
|
||||
const scope = projectViewKey;
|
||||
if (!scope.trim()) continue;
|
||||
normalized[scope] = dedupeKeys(order);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function extractWorkspaceOrderScope(scopeKey: string): SidebarWorkspaceOrderScope | null {
|
||||
const separatorIndex = scopeKey.indexOf("::");
|
||||
if (separatorIndex < 0) return null;
|
||||
const serverId = scopeKey.slice(0, separatorIndex).trim();
|
||||
const projectViewKey = scopeKey.slice(separatorIndex + 2).trim();
|
||||
if (!serverId || !projectViewKey) return null;
|
||||
return { serverId, projectViewKey };
|
||||
}
|
||||
|
||||
function normalizeLegacyWorkspaceKey(serverId: string, rawWorkspaceKey: string): string | null {
|
||||
const workspaceKey = rawWorkspaceKey.trim();
|
||||
if (!workspaceKey) return null;
|
||||
const serverPrefix = `${serverId}:`;
|
||||
return workspaceKey.startsWith(serverPrefix) ? workspaceKey : `${serverPrefix}${workspaceKey}`;
|
||||
}
|
||||
|
||||
export function migrateSidebarOrderState(persistedState: unknown): {
|
||||
projectOrder: string[];
|
||||
pinnedWorkspaceOrder: string[];
|
||||
workspaceOrderByProject: Record<string, string[]>;
|
||||
} {
|
||||
const result = SidebarOrderPersistedStateSchema.safeParse(persistedState);
|
||||
if (!result.success) {
|
||||
return { projectOrder: [], pinnedWorkspaceOrder: [] };
|
||||
return { projectOrder: [], pinnedWorkspaceOrder: [], workspaceOrderByProject: {} };
|
||||
}
|
||||
const state: SidebarOrderPersistedState = result.data;
|
||||
|
||||
@@ -91,9 +127,26 @@ export function migrateSidebarOrderState(persistedState: unknown): {
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceOrderByProject = normalizeWorkspaceOrderByProject(state.workspaceOrderByProject);
|
||||
for (const [scopeKey, order] of Object.entries(state.workspaceOrderByServerAndProject ?? {})) {
|
||||
const scope = extractWorkspaceOrderScope(scopeKey);
|
||||
if (!scope) continue;
|
||||
const existing = workspaceOrderByProject[scope.projectViewKey] ?? [];
|
||||
const merged = [...existing];
|
||||
const seen = new Set(merged);
|
||||
for (const key of order) {
|
||||
const workspaceKey = normalizeLegacyWorkspaceKey(scope.serverId, key);
|
||||
if (!workspaceKey || seen.has(workspaceKey)) continue;
|
||||
seen.add(workspaceKey);
|
||||
merged.push(workspaceKey);
|
||||
}
|
||||
workspaceOrderByProject[scope.projectViewKey] = merged;
|
||||
}
|
||||
|
||||
return {
|
||||
projectOrder,
|
||||
pinnedWorkspaceOrder: dedupeKeys(state.pinnedWorkspaceOrder ?? []),
|
||||
workspaceOrderByProject,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -102,6 +155,7 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
|
||||
(set, get) => ({
|
||||
projectOrder: [],
|
||||
pinnedWorkspaceOrder: [],
|
||||
workspaceOrderByProject: {},
|
||||
getProjectOrder: () => get().projectOrder,
|
||||
setProjectOrder: (keys) => {
|
||||
set({ projectOrder: dedupeKeys(keys) });
|
||||
@@ -110,6 +164,19 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
|
||||
setPinnedWorkspaceOrder: (keys) => {
|
||||
set({ pinnedWorkspaceOrder: dedupeKeys(keys) });
|
||||
},
|
||||
getWorkspaceOrder: (projectViewKey) => {
|
||||
if (!projectViewKey.trim()) return [];
|
||||
return get().workspaceOrderByProject[projectViewKey] ?? [];
|
||||
},
|
||||
setWorkspaceOrder: (projectViewKey, keys) => {
|
||||
if (!projectViewKey.trim()) return;
|
||||
set((state) => ({
|
||||
workspaceOrderByProject: {
|
||||
...state.workspaceOrderByProject,
|
||||
[projectViewKey]: dedupeKeys(keys),
|
||||
},
|
||||
}));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "sidebar-project-workspace-order",
|
||||
@@ -117,8 +184,9 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
|
||||
partialize: (state) => ({
|
||||
projectOrder: state.projectOrder,
|
||||
pinnedWorkspaceOrder: state.pinnedWorkspaceOrder,
|
||||
workspaceOrderByProject: state.workspaceOrderByProject,
|
||||
}),
|
||||
version: 2,
|
||||
version: 3,
|
||||
migrate: migrateSidebarOrderState,
|
||||
},
|
||||
),
|
||||
|
||||
@@ -300,3 +300,7 @@ it("defaults to Created and persists the Last prompt choice across migration", (
|
||||
migrateSidebarViewState({ workspaceSort: "lastPrompt", groupMode: "status" }),
|
||||
).toMatchObject({ workspaceSort: "lastPrompt", groupMode: "status" });
|
||||
});
|
||||
|
||||
it("retains the manual sorting preference on reload", () => {
|
||||
expect(migrateSidebarViewState({ workspaceSort: "manual" }).workspaceSort).toBe("manual");
|
||||
});
|
||||
|
||||
@@ -91,7 +91,7 @@ const SidebarLabelFilterSchema = z.object({
|
||||
labels: z.array(z.string()),
|
||||
});
|
||||
const SidebarViewPersistedStateSchema = z.strictObject({
|
||||
workspaceSort: z.enum(["created", "lastPrompt"]).optional(),
|
||||
workspaceSort: z.enum(["manual", "created", "lastPrompt"]).optional(),
|
||||
showBackground: z.boolean().optional(),
|
||||
groupMode: PersistedSidebarGroupModeSchema.optional(),
|
||||
hostFilters: z.array(z.string()).optional(),
|
||||
|
||||
@@ -374,13 +374,19 @@ class AcpRuntime {
|
||||
/**
|
||||
* Fallback ids for streamed chunks that carry no `messageId`, one per
|
||||
* chunk kind. Consecutive chunks of a kind continue the same timeline
|
||||
* item; a chunk of the other kind, a tool call, a plan, a user message,
|
||||
* or a turn boundary ends it (#4699).
|
||||
* item; a chunk of another kind, a tool call, a plan, or a turn boundary
|
||||
* ends it (#4699).
|
||||
*/
|
||||
private readonly fallbackChunkIds = new Map<
|
||||
"agent_message_chunk" | "agent_thought_chunk",
|
||||
"agent_message_chunk" | "agent_thought_chunk" | "user_message_chunk",
|
||||
string
|
||||
>();
|
||||
/**
|
||||
* Active while a persisted session loads. ACP agents replay the loaded
|
||||
* session's updates before session/load resolves; the value follows the
|
||||
* open request's history contract.
|
||||
*/
|
||||
private replayWindow: "replay" | "skip" | null = null;
|
||||
private closing = false;
|
||||
private processFailed = false;
|
||||
private configTransaction = false;
|
||||
@@ -499,15 +505,23 @@ class AcpRuntime {
|
||||
};
|
||||
let response: Pick<NewSessionResponse, "modes" | "configOptions">;
|
||||
if (nativeSessionId) {
|
||||
response = await this.call(
|
||||
this.connection.loadSession({
|
||||
sessionId: nativeSessionId,
|
||||
cwd: input.config.cwd,
|
||||
mcpServers,
|
||||
_meta: metadata,
|
||||
}),
|
||||
);
|
||||
// ACP agents replay the loaded session's updates before session/load resolves,
|
||||
// so the native id must be registered first for them to pass the session filter.
|
||||
this.nativeSessionId = nativeSessionId;
|
||||
this.replayWindow = input.history === "replay" ? "replay" : "skip";
|
||||
try {
|
||||
response = await this.call(
|
||||
this.connection.loadSession({
|
||||
sessionId: nativeSessionId,
|
||||
cwd: input.config.cwd,
|
||||
mcpServers,
|
||||
_meta: metadata,
|
||||
}),
|
||||
);
|
||||
await this.drainNotifications();
|
||||
} finally {
|
||||
this.replayWindow = null;
|
||||
}
|
||||
} else {
|
||||
const newSession = await this.call(
|
||||
this.connection.newSession({
|
||||
@@ -898,6 +912,14 @@ class AcpRuntime {
|
||||
|
||||
private sessionUpdate(notification: SessionNotification): void {
|
||||
if (notification.sessionId !== this.nativeSessionId) return;
|
||||
if (this.replayWindow === "skip") return;
|
||||
if (
|
||||
this.replayWindow === "replay" &&
|
||||
notification.update.sessionUpdate === "user_message_chunk"
|
||||
) {
|
||||
this.reduceUserMessageChunk(notification.update);
|
||||
return;
|
||||
}
|
||||
this.reduceUpdate(notification.update);
|
||||
}
|
||||
|
||||
@@ -911,13 +933,14 @@ class AcpRuntime {
|
||||
this.fallbackChunkIds.clear();
|
||||
return;
|
||||
}
|
||||
// A chunk of one kind ends the other kind's item, whatever its content
|
||||
// or id: reasoning, then text, then reasoning again are three items.
|
||||
// A chunk of one kind ends every other kind's open item, whatever its
|
||||
// content or id: reasoning, then text, then reasoning again are three items.
|
||||
this.fallbackChunkIds.delete(
|
||||
update.sessionUpdate === "agent_message_chunk"
|
||||
? "agent_thought_chunk"
|
||||
: "agent_message_chunk",
|
||||
);
|
||||
this.fallbackChunkIds.delete("user_message_chunk");
|
||||
if (update.content.type !== "text") return;
|
||||
const id = this.resolveChunkId(update.sessionUpdate, update.messageId);
|
||||
const text = `${this.messages.get(id) ?? ""}${update.content.text}`;
|
||||
@@ -945,8 +968,31 @@ class AcpRuntime {
|
||||
this.reduceStateUpdate(update);
|
||||
}
|
||||
|
||||
// Historical user messages arrive only as replayed chunks; a live prompt
|
||||
// already echoes its own user message when it is admitted.
|
||||
private reduceUserMessageChunk(
|
||||
update: Extract<SessionUpdate, { sessionUpdate: "user_message_chunk" }>,
|
||||
): void {
|
||||
this.fallbackChunkIds.delete("agent_message_chunk");
|
||||
this.fallbackChunkIds.delete("agent_thought_chunk");
|
||||
if (update.content.type !== "text") return;
|
||||
const id = this.resolveChunkId("user_message_chunk", update.messageId);
|
||||
const text = `${this.messages.get(id) ?? ""}${update.content.text}`;
|
||||
this.messages.set(id, text);
|
||||
this.emit({
|
||||
type: "timeline.item",
|
||||
sessionId: this.options.boundarySessionId,
|
||||
item: {
|
||||
type: "user_message",
|
||||
id,
|
||||
text,
|
||||
...(update.messageId ? { messageId: update.messageId } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private resolveChunkId(
|
||||
kind: "agent_message_chunk" | "agent_thought_chunk",
|
||||
kind: "agent_message_chunk" | "agent_thought_chunk" | "user_message_chunk",
|
||||
messageId: string | null | undefined,
|
||||
): string {
|
||||
if (messageId) {
|
||||
|
||||
@@ -1500,3 +1500,187 @@ describe("ACP streamed message boundaries", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
function userChunk(text: string, messageId?: string): SessionUpdate {
|
||||
return {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "text", text },
|
||||
...(messageId === undefined ? {} : { messageId }),
|
||||
};
|
||||
}
|
||||
|
||||
async function openReplaySession(
|
||||
replay: SessionUpdate[],
|
||||
history: "replay" | "skip",
|
||||
): Promise<{ connection: ProviderConnection; events: ProviderEvent[] }> {
|
||||
const harness = connectorHarness({
|
||||
capabilities: { loadSession: true },
|
||||
handleRequest(instance, request) {
|
||||
if (request.method !== "session/load") return false;
|
||||
for (const update of replay) {
|
||||
instance.notify("session/update", { sessionId: "connector-session", update });
|
||||
}
|
||||
instance.respond(request, {
|
||||
sessionId: "connector-session",
|
||||
modes: null,
|
||||
configOptions: [],
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const registration = runAcpProvider({
|
||||
id: "replay-acp",
|
||||
label: "Replay ACP",
|
||||
connector: harness.connector,
|
||||
});
|
||||
const connection = await registration.connect({
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message", "session.persistence"],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
const input = openInput();
|
||||
input.persistence = { version: 1, data: { sessionId: "connector-session" } };
|
||||
input.history = history;
|
||||
await connection.send(input);
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
return { connection, events };
|
||||
}
|
||||
|
||||
function timelineProjection(events: ProviderEvent[]): Array<Record<string, string>> {
|
||||
return events.flatMap((event) => {
|
||||
if (event.type !== "timeline.item") return [];
|
||||
const item = event.item;
|
||||
if (
|
||||
item.type === "user_message" ||
|
||||
item.type === "assistant_message" ||
|
||||
item.type === "reasoning"
|
||||
) {
|
||||
return [{ type: item.type, id: item.id, text: item.text }];
|
||||
}
|
||||
if (item.type === "tool_call") return [{ type: item.type, id: item.id, status: item.status }];
|
||||
return [{ type: item.type, id: item.id }];
|
||||
});
|
||||
}
|
||||
|
||||
describe("ACP session history replay", () => {
|
||||
it("restores the timeline replayed before session/load resolves", async () => {
|
||||
const { connection, events } = await openReplaySession(
|
||||
[
|
||||
userChunk("What is the ", "user-1"),
|
||||
userChunk("temperature?", "user-1"),
|
||||
textChunk("Checking the sensor", "agent_thought_chunk"),
|
||||
{
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "tool-1",
|
||||
title: "Read sensor",
|
||||
kind: "read",
|
||||
status: "in_progress",
|
||||
},
|
||||
{ sessionUpdate: "tool_call_update", toolCallId: "tool-1", status: "completed" },
|
||||
textChunk("It is 25°C"),
|
||||
],
|
||||
"replay",
|
||||
);
|
||||
try {
|
||||
expect(timelineProjection(events)).toEqual([
|
||||
{ type: "user_message", id: "user-1", text: "What is the " },
|
||||
{ type: "user_message", id: "user-1", text: "What is the temperature?" },
|
||||
{ type: "reasoning", id: "agent_thought_chunk:1", text: "Checking the sensor" },
|
||||
{ type: "tool_call", id: "tool-1", status: "running" },
|
||||
{ type: "tool_call", id: "tool-1", status: "completed" },
|
||||
{ type: "assistant_message", id: "agent_message_chunk:2", text: "It is 25°C" },
|
||||
]);
|
||||
const lastReplayed = events.findLastIndex((event) => event.type === "timeline.item");
|
||||
const opened = events.findIndex((event) => event.type === "session.opened");
|
||||
expect(lastReplayed).toBeLessThan(opened);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("continues anonymous user chunks into one message and starts a new one after an agent reply", async () => {
|
||||
const { connection, events } = await openReplaySession(
|
||||
[
|
||||
userChunk("first "),
|
||||
userChunk("question"),
|
||||
textChunk("answer"),
|
||||
userChunk("second question"),
|
||||
],
|
||||
"replay",
|
||||
);
|
||||
try {
|
||||
expect(timelineProjection(events)).toEqual([
|
||||
{ type: "user_message", id: "user_message_chunk:1", text: "first " },
|
||||
{ type: "user_message", id: "user_message_chunk:1", text: "first question" },
|
||||
{ type: "assistant_message", id: "agent_message_chunk:2", text: "answer" },
|
||||
{ type: "user_message", id: "user_message_chunk:3", text: "second question" },
|
||||
]);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not duplicate the live prompt echo when the agent echoes it as a user chunk", async () => {
|
||||
const harness = connectorHarness({
|
||||
capabilities: { loadSession: true },
|
||||
promptUpdates: (promptText) => [
|
||||
{ sessionUpdate: "user_message_chunk", content: { type: "text", text: promptText } },
|
||||
{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Acknowledged" } },
|
||||
],
|
||||
});
|
||||
const registration = runAcpProvider({
|
||||
id: "echo-acp",
|
||||
label: "Echo ACP",
|
||||
connector: harness.connector,
|
||||
});
|
||||
const connection = await registration.connect({
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message", "session.persistence"],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
const input = openInput();
|
||||
input.persistence = { version: 1, data: { sessionId: "connector-session" } };
|
||||
input.history = "replay";
|
||||
await connection.send(input);
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
|
||||
await connection.send({
|
||||
type: "session.prompt",
|
||||
sessionId: "session-1",
|
||||
prompt: {
|
||||
clientMessageId: "echo-1",
|
||||
delivery: "auto",
|
||||
input: { type: "message", content: [{ type: "text", text: "hello" }] },
|
||||
},
|
||||
});
|
||||
try {
|
||||
await waitForEvent(
|
||||
events,
|
||||
(event) =>
|
||||
event.type === "session.turn" &&
|
||||
event.turnId === "acp:echo-1" &&
|
||||
event.state === "completed",
|
||||
);
|
||||
expect(timelineProjection(events)).toEqual([
|
||||
{ type: "user_message", id: "echo-1", text: "hello" },
|
||||
{ type: "assistant_message", id: "agent_message_chunk:1", text: "Acknowledged" },
|
||||
]);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("drops replayed updates when the open request asks to skip history", async () => {
|
||||
const { connection, events } = await openReplaySession(
|
||||
[userChunk("What is in the README?", "user-1"), textChunk("It describes the project.")],
|
||||
"skip",
|
||||
);
|
||||
try {
|
||||
expect(timelineProjection(events)).toEqual([]);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,196 @@
|
||||
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;
|
||||
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) => {
|
||||
if (!eligible()) return false;
|
||||
this.closed = true;
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
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;
|
||||
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[]) {
|
||||
for (const runtime of family) {
|
||||
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: [] };
|
||||
if (!options.enabled || stopped) return result;
|
||||
const timestamp = now();
|
||||
const runtimes = options.runtimes();
|
||||
for (const runtime of runtimes) {
|
||||
if (!runtime.sessionId) continue;
|
||||
const family = familyOf(runtime, runtimes);
|
||||
try {
|
||||
if (!baselineAllows(family) || !(await providersAllow(family))) {
|
||||
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 (
|
||||
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 {
|
||||
protectedAt.set(runtime.identity, { at: timestamp, protected: true });
|
||||
}
|
||||
} catch (error) {
|
||||
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,
|
||||
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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { AgentTimelineItem } from "../../agent-sdk-types.js";
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import { claudeProjectDirSync } from "./project-dir.js";
|
||||
import { projectTimelineRows } from "../../timeline-projection.js";
|
||||
|
||||
describe("Claude session request journal replay", () => {
|
||||
let root: string;
|
||||
@@ -137,4 +138,100 @@ describe("Claude session request journal replay", () => {
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test.each(["remove", "user"])(
|
||||
"replays a background completion once across queue enqueue and %s records",
|
||||
async (delivery) => {
|
||||
const content =
|
||||
"<task-notification>\n<task-id>bg-1</task-id>\n<status>completed</status>\n<summary>Background command completed</summary>\n</task-notification>";
|
||||
const items = await replay([
|
||||
{ type: "queue-operation", operation: "enqueue", content },
|
||||
delivery === "remove"
|
||||
? { type: "queue-operation", operation: "remove", content }
|
||||
: {
|
||||
type: "user",
|
||||
uuid: "notification-delivery",
|
||||
origin: { kind: "task-notification" },
|
||||
message: { role: "user", content },
|
||||
},
|
||||
]);
|
||||
const projected = projectTimelineRows({
|
||||
rows: items.map((item, seq) => ({ item, seq, timestamp: "2026-10-23T00:00:00.000Z" })),
|
||||
mode: "projected",
|
||||
});
|
||||
expect(projected.map((row) => row.item)).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "tool_call",
|
||||
name: "task_notification",
|
||||
status: "completed",
|
||||
metadata: expect.objectContaining({ taskId: "bg-1" }),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
);
|
||||
test("restores each Monitor event and completion of one task once", async () => {
|
||||
const notifications = ["1", "2", "3", "4"].map(
|
||||
(event) =>
|
||||
`<task-notification>\n<task-id>monitor-1</task-id>\n<summary>Monitor event</summary>\n<event>${event}</event>\n</task-notification>`,
|
||||
);
|
||||
notifications.push(
|
||||
"<task-notification>\n<task-id>monitor-1</task-id>\n<status>completed</status>\n<summary>Monitor stream ended</summary>\n</task-notification>",
|
||||
);
|
||||
const items = await replay(
|
||||
notifications.flatMap((content, index) => [
|
||||
{ type: "queue-operation", operation: "enqueue", content },
|
||||
{ type: "user", uuid: `delivery-${index}`, message: { role: "user", content } },
|
||||
]),
|
||||
);
|
||||
const projected = projectTimelineRows({
|
||||
rows: items.map((item, seq) => ({ item, seq, timestamp: "2026-10-23T00:00:00.000Z" })),
|
||||
mode: "projected",
|
||||
});
|
||||
expect(projected.map(({ item }) => item.type === "tool_call" && item.detail)).toEqual(
|
||||
notifications.map((text, index) =>
|
||||
expect.objectContaining({
|
||||
label: index === 4 ? "Monitor stream ended" : "Monitor event",
|
||||
text,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
test.each(["user", "remove", "system"])(
|
||||
"preserves identical notifications from separate occurrences with %s deliveries",
|
||||
async (delivery) => {
|
||||
const content =
|
||||
"<task-notification>\n<task-id>monitor-repeat</task-id>\n<status>completed</status>\n<summary>Monitor event</summary>\n</task-notification>";
|
||||
const queued = [1, 2].map(() => ({ type: "queue-operation", operation: "enqueue", content }));
|
||||
const delivered = [1, 2].map((index) => {
|
||||
if (delivery === "user") {
|
||||
return { type: "user", uuid: `repeat-${index}`, message: { role: "user", content } };
|
||||
}
|
||||
if (delivery === "remove") {
|
||||
return { type: "queue-operation", operation: "remove", content };
|
||||
}
|
||||
return {
|
||||
type: "system",
|
||||
subtype: "task_notification",
|
||||
uuid: `repeat-${index}`,
|
||||
task_id: "monitor-repeat",
|
||||
status: "completed",
|
||||
summary: "Monitor event",
|
||||
};
|
||||
});
|
||||
const items = await replay([
|
||||
...queued,
|
||||
...delivered,
|
||||
{ type: "queue-operation", operation: "enqueue", content },
|
||||
]);
|
||||
const projected = projectTimelineRows({
|
||||
rows: items.map((item, seq) => ({ item, seq, timestamp: "2026-10-23T00:00:00.000Z" })),
|
||||
mode: "projected",
|
||||
});
|
||||
expect(projected.map(({ item }) => item.type === "tool_call" && item.detail?.label)).toEqual([
|
||||
"Monitor event",
|
||||
"Monitor event",
|
||||
"Monitor event",
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -834,7 +834,7 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
|
||||
.filter((item) => item.type === "tool_call" && item.name === "task_notification"),
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
callId: "task_notification_workflow-notification",
|
||||
callId: expect.stringMatching(/^task_notification_[a-f0-9]+_1$/),
|
||||
status: "completed",
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
@@ -2930,6 +2933,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(
|
||||
{
|
||||
@@ -3423,6 +3438,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(
|
||||
@@ -4078,13 +4094,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) {
|
||||
@@ -6313,6 +6333,7 @@ export function convertClaudeHistoryEntry(
|
||||
const userTaskNotificationItem = mapTaskNotificationUserContentToToolCall({
|
||||
content,
|
||||
messageId: userMessageId,
|
||||
notificationId: typeof entry.notificationId === "string" ? entry.notificationId : undefined,
|
||||
});
|
||||
if (userTaskNotificationItem) {
|
||||
return [userTaskNotificationItem];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -97,4 +97,17 @@ describe("task-notification-tool-call", () => {
|
||||
|
||||
expect(item).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps notifications without task IDs distinct by their message IDs", () => {
|
||||
const items = ["message-1", "message-2"].map((messageId) =>
|
||||
mapTaskNotificationUserContentToToolCall({
|
||||
content: "<task-notification><status>completed</status></task-notification>",
|
||||
messageId,
|
||||
}),
|
||||
);
|
||||
expect(items.map((item) => item?.callId)).toEqual([
|
||||
"task_notification_message-1",
|
||||
"task_notification_message-2",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ const OptionalNonEmptyTrimmedStringSchema = z.preprocess(
|
||||
);
|
||||
|
||||
const TaskNotificationEnvelopeSchema = z.object({
|
||||
notificationId: z.string().optional(),
|
||||
messageId: z.string().nullable(),
|
||||
taskId: z.string().nullable(),
|
||||
toolUseId: z.string().nullable(),
|
||||
@@ -35,7 +36,9 @@ const TaskNotificationUserContentSchema = z.union([
|
||||
|
||||
const TaskNotificationHistoryRecordSchema = z
|
||||
.object({
|
||||
notificationId: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
operation: z.string().optional(),
|
||||
subtype: z.string().optional(),
|
||||
uuid: z.string().optional(),
|
||||
message_id: z.string().optional(),
|
||||
@@ -52,6 +55,7 @@ const TaskNotificationHistoryRecordSchema = z
|
||||
const MapTaskNotificationUserContentToToolCallInputSchema = z.object({
|
||||
content: z.unknown(),
|
||||
messageId: z.string().optional().nullable(),
|
||||
notificationId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TaskNotificationEnvelope = z.infer<typeof TaskNotificationEnvelopeSchema>;
|
||||
@@ -155,6 +159,7 @@ function parseTaskNotificationFromUserContent(
|
||||
}
|
||||
|
||||
return TaskNotificationEnvelopeSchema.parse({
|
||||
notificationId: parsedInput.data.notificationId,
|
||||
messageId: toNonEmptyString(parsedInput.data.messageId),
|
||||
taskId: readTaskNotificationTagValue({ text: rawText, tagName: "task-id" }),
|
||||
toolUseId:
|
||||
@@ -186,6 +191,7 @@ function parseTaskNotificationFromSystemRecord(record: unknown): TaskNotificatio
|
||||
const rawText = toNonEmptyString(systemRecord.content);
|
||||
|
||||
return TaskNotificationEnvelopeSchema.parse({
|
||||
notificationId: systemRecord.notificationId,
|
||||
messageId: toNonEmptyString(systemRecord.uuid) ?? toNonEmptyString(systemRecord.message_id),
|
||||
taskId:
|
||||
toNonEmptyString(systemRecord.task_id) ??
|
||||
@@ -212,25 +218,76 @@ function parseTaskNotificationFromSystemRecord(record: unknown): TaskNotificatio
|
||||
});
|
||||
}
|
||||
|
||||
/** Normalize notification identity for one transcript traversal. Queue bookkeeping and delivery
|
||||
* are separate records of the same occurrence; each source counts repeated identical bodies
|
||||
* independently so the second event is not merged with the first. */
|
||||
export function* assignTaskNotificationReplayIds<T>(entries: Iterable<T>): Generator<T> {
|
||||
const occurrences = new Map<string, Map<string, number>>();
|
||||
for (const entry of entries) {
|
||||
const record = TaskNotificationHistoryRecordSchema.safeParse(entry);
|
||||
if (!record.success) {
|
||||
yield entry;
|
||||
continue;
|
||||
}
|
||||
const data = record.data;
|
||||
const envelope =
|
||||
data.type === "user"
|
||||
? parseTaskNotificationFromUserContent({ content: data.message?.content })
|
||||
: parseTaskNotificationFromSystemRecord(data);
|
||||
if (!envelope?.taskId || !data.type) {
|
||||
yield entry;
|
||||
continue;
|
||||
}
|
||||
const signature = taskNotificationReplaySignature(envelope);
|
||||
const source = data.type === "queue-operation" ? `queue-${data.operation}` : data.type;
|
||||
const counts = occurrences.get(signature) ?? new Map<string, number>();
|
||||
const occurrence = (counts.get(source) ?? 0) + 1;
|
||||
counts.set(source, occurrence);
|
||||
occurrences.set(signature, counts);
|
||||
yield { ...entry, notificationId: `task_notification_${signature}_${occurrence}` };
|
||||
}
|
||||
}
|
||||
|
||||
function taskNotificationReplaySignature(envelope: TaskNotificationEnvelope): string {
|
||||
// Known fields are canonical across XML user/queue records and structured system records.
|
||||
// Keep the remaining body too: Monitor events share a summary but carry different <event>s.
|
||||
const remainder = (envelope.rawText ?? "")
|
||||
.replace(/<\/?task-notification>/gi, "")
|
||||
.replace(
|
||||
/<(task-id|tool-use-id|tool_use_id|status|summary|output-file|output_file)>[\s\S]*?<\/\1>/gi,
|
||||
"",
|
||||
)
|
||||
.trim();
|
||||
return createHash("sha1")
|
||||
.update(
|
||||
JSON.stringify([
|
||||
envelope.taskId,
|
||||
envelope.toolUseId,
|
||||
envelope.status,
|
||||
envelope.summary,
|
||||
envelope.outputFile,
|
||||
remainder,
|
||||
]),
|
||||
)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function normalizeTaskNotificationCallIdSegment(segment: string): string | null {
|
||||
const normalized = segment.trim().replace(/[^a-zA-Z0-9._:-]+/g, "_");
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function buildTaskNotificationCallId(envelope: TaskNotificationEnvelope): string {
|
||||
if (envelope.notificationId) return envelope.notificationId;
|
||||
const messageSegment = envelope.messageId
|
||||
? normalizeTaskNotificationCallIdSegment(envelope.messageId)
|
||||
: null;
|
||||
if (messageSegment) {
|
||||
return `task_notification_${messageSegment}`;
|
||||
}
|
||||
if (messageSegment) return `task_notification_${messageSegment}`;
|
||||
|
||||
const taskSegment = envelope.taskId
|
||||
? normalizeTaskNotificationCallIdSegment(envelope.taskId)
|
||||
: null;
|
||||
if (taskSegment) {
|
||||
return `task_notification_${taskSegment}`;
|
||||
}
|
||||
if (taskSegment) return `task_notification_${taskSegment}`;
|
||||
|
||||
const seed =
|
||||
[envelope.status, envelope.summary, envelope.outputFile, envelope.rawText]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assignTaskNotificationReplayIds } from "./task-notification-tool-call.js";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
|
||||
@@ -88,21 +89,24 @@ function indexedRecords(
|
||||
): Iterable<TranscriptEntry> {
|
||||
return {
|
||||
*[Symbol.iterator]() {
|
||||
for (const location of locations) {
|
||||
const fd = descriptors.get(location.file)!;
|
||||
const buffer = Buffer.allocUnsafe(location.length);
|
||||
let read = 0;
|
||||
while (read < buffer.length) {
|
||||
const count = fs.readSync(fd, buffer, read, buffer.length - read, location.offset + read);
|
||||
if (count === 0)
|
||||
throw new Error(`Claude transcript was truncated during replay: ${location.file}`);
|
||||
read += count;
|
||||
}
|
||||
const entry = parseRecord(buffer.toString("utf8"));
|
||||
if (entry) yield entry;
|
||||
}
|
||||
yield* assignTaskNotificationReplayIds(readEntries());
|
||||
},
|
||||
};
|
||||
function* readEntries(): Generator<TranscriptEntry> {
|
||||
for (const location of locations) {
|
||||
const fd = descriptors.get(location.file)!;
|
||||
const buffer = Buffer.allocUnsafe(location.length);
|
||||
let read = 0;
|
||||
while (read < buffer.length) {
|
||||
const count = fs.readSync(fd, buffer, read, buffer.length - read, location.offset + read);
|
||||
if (count === 0)
|
||||
throw new Error(`Claude transcript was truncated during replay: ${location.file}`);
|
||||
read += count;
|
||||
}
|
||||
const entry = parseRecord(buffer.toString("utf8"));
|
||||
if (entry) yield entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isToolResult(entry: TranscriptEntry): boolean {
|
||||
|
||||
@@ -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,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<{
|
||||
@@ -1398,6 +1400,7 @@ export async function createPaseoDaemon(
|
||||
archiveWorkspace: archiveScheduleWorkspaceExternal,
|
||||
});
|
||||
await scheduleService.start();
|
||||
|
||||
agentManager.setAgentArchivedCallback(async (agentId) => {
|
||||
try {
|
||||
await scheduleService.completeForAgent(agentId);
|
||||
@@ -1445,6 +1448,15 @@ export async function createPaseoDaemon(
|
||||
}),
|
||||
});
|
||||
|
||||
const idleRuntimeCleanup = createIdleRuntimeCleanup({
|
||||
enabled: config.idleRuntimeCleanup !== false,
|
||||
runtimes: () => agentManager.getRuntimeCleanupCandidates(),
|
||||
isProtected: (agentId) =>
|
||||
finishSubscriptions.hasPendingForCaller(agentId) ||
|
||||
(wsServer?.agentMessages.hasPending(agentId) ?? false),
|
||||
logger,
|
||||
});
|
||||
|
||||
const createAgentToolHostDependencies = (
|
||||
runtime: PaseoToolRuntimeContext,
|
||||
): PaseoToolHostDependencies => ({
|
||||
@@ -1907,6 +1919,7 @@ export async function createPaseoDaemon(
|
||||
await hubRelationships.stop();
|
||||
workspaceReconciliation.dispose();
|
||||
scriptHealthMonitor.stop();
|
||||
await idleRuntimeCleanup.close();
|
||||
// Freeze both ingress and registration before taking the agent closure snapshot.
|
||||
wsServer?.prepareForShutdown();
|
||||
await finishSubscriptions.close();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createServer } from "node:http";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import pino from "pino";
|
||||
import { OpenAITTS } from "../tts.js";
|
||||
import { TTSManager } from "../../../../agent/tts-manager.js";
|
||||
|
||||
if (!global.gc) throw new Error("Run with --expose-gc");
|
||||
|
||||
const sentences = ["First sentence.", "Second sentence.", "Third sentence."];
|
||||
const audio = sentences.map((_, index) => Buffer.alloc(192_000, index + 1));
|
||||
const requests: string[] = [];
|
||||
const server = createServer(async (request, response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
||||
const { input } = JSON.parse(Buffer.concat(chunks).toString()) as { input: string };
|
||||
requests.push(input);
|
||||
const body = audio[sentences.indexOf(input)];
|
||||
response.writeHead(200, { "Content-Type": "audio/pcm", "Content-Length": body.length });
|
||||
response.end(body);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing server address");
|
||||
const logger = pino({ level: "silent" });
|
||||
const provider = new OpenAITTS(
|
||||
{ apiKey: "test", baseUrl: `http://127.0.0.1:${address.port}/v1` },
|
||||
logger,
|
||||
);
|
||||
const manager = new TTSManager("prefetched-playback", logger, provider);
|
||||
const emitted: { bytes: number; value: number; format: string }[] = [];
|
||||
|
||||
try {
|
||||
await manager.generateAndWaitForPlayback(
|
||||
sentences.join(" "),
|
||||
(message) => {
|
||||
if (message.type !== "audio_output") return;
|
||||
const buffer = Buffer.from(message.payload.audio, "base64");
|
||||
emitted.push({ bytes: buffer.length, value: buffer[0], format: message.payload.format });
|
||||
if (emitted.length === 1) {
|
||||
// Hold the playback acknowledgement while the other responses are prefetched.
|
||||
void (async () => {
|
||||
while (requests.length < sentences.length) await delay(10);
|
||||
for (let cycle = 0; cycle < 20; cycle++) {
|
||||
await delay(25);
|
||||
global.gc!();
|
||||
}
|
||||
manager.confirmAudioPlayed(message.payload.id);
|
||||
})();
|
||||
} else {
|
||||
setImmediate(() => manager.confirmAudioPlayed(message.payload.id));
|
||||
}
|
||||
},
|
||||
new AbortController().signal,
|
||||
true,
|
||||
);
|
||||
console.log(JSON.stringify({ requests, emitted }));
|
||||
} finally {
|
||||
manager.cleanup();
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
test("prefetched speech survives garbage collection until its playback turn", () => {
|
||||
const fixture = fileURLToPath(new URL("./test-utils/prefetched-playback.ts", import.meta.url));
|
||||
const output = execFileSync(process.execPath, ["--expose-gc", "--import", "tsx", fixture], {
|
||||
encoding: "utf8",
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
const result = JSON.parse(output);
|
||||
expect(result.requests.sort()).toEqual(
|
||||
["First sentence.", "Second sentence.", "Third sentence."].sort(),
|
||||
);
|
||||
expect(result.emitted).toEqual([
|
||||
{ bytes: 192_000, value: 1, format: "pcm" },
|
||||
{ bytes: 192_000, value: 2, format: "pcm" },
|
||||
{ bytes: 192_000, value: 3, format: "pcm" },
|
||||
]);
|
||||
});
|
||||
@@ -67,7 +67,9 @@ export class OpenAITTS implements TextToSpeechProvider {
|
||||
| "pcm",
|
||||
});
|
||||
|
||||
const audioStream = response.body as unknown as Readable;
|
||||
// Prefetched speech can wait through playback of an earlier sentence. Consume
|
||||
// the response here so fetch cannot collect it and cancel its unread body.
|
||||
const audioStream = Readable.from([Buffer.from(await response.arrayBuffer())]);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
this.logger.debug({ duration }, "Speech synthesis stream ready");
|
||||
|
||||
@@ -552,6 +552,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"idleRuntimeCleanup": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"catalogRefreshTimeoutMs": {
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
|
||||
@@ -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,20 @@ 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 with:
|
||||
|
||||
```json
|
||||
{ "agents": { "idleRuntimeCleanup": false } }
|
||||
```
|
||||
|
||||
This setting defaults to `true` and requires a daemon restart to change. 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.
|
||||
|
||||
## 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 空闲一小时后释放其进程。你的下一条提示词会恢复同一个会话。要为整个主机禁用此功能,请设置:
|
||||
|
||||
```json
|
||||
{ "agents": { "idleRuntimeCleanup": false } }
|
||||
```
|
||||
|
||||
此设置默认为 `true`,更改后需要重启 daemon。没有单个 Agent 的设置。不提供运行时安全检查的 Provider 使用相同的 daemon 基础条件和一小时空闲期。活动中的工作、后代 Agent 和待发送的完成通知都会阻止清理。
|
||||
|
||||
## 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 会推导出实时增量,并保持正常的时间线。
|
||||
|
||||
Reference in New Issue
Block a user