mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 18:52:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7db5385a96 |
+3
-45
@@ -25,50 +25,8 @@ 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 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).
|
||||
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
|
||||
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
|
||||
|
||||
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
|
||||
@@ -284,7 +242,7 @@ parentAgentId === thisAgent.id AND !archivedAt
|
||||
|
||||
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
|
||||
|
||||
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Every subagent pane links back to its parent (`packages/app/src/subagents/parent/`): a pill above the composer when there is one, a parent box in the composer's place when there is not. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
|
||||
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
|
||||
|
||||
|
||||
@@ -498,11 +498,6 @@ 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
|
||||
|
||||
@@ -99,6 +99,16 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
|
||||
await expectSubagentRowVisible(page, child.id);
|
||||
});
|
||||
|
||||
await test.step("the parent box in a subagent's sheet closes it onto the parent", async () => {
|
||||
await page.getByTestId(`subagents-track-row-${child.id}`).click();
|
||||
const childSheet = sheet(page, child.title);
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
await expect(childSheet.getByTestId("composer-parent-agent-pill")).toHaveCount(0);
|
||||
await childSheet.getByTestId("parent-agent-callout-open").click();
|
||||
await expect(childSheet).toHaveCount(0);
|
||||
await expectOpenTabCount(page, 1);
|
||||
});
|
||||
|
||||
await test.step("Open as tab promotes the subagent to a full tab and ends the list", async () => {
|
||||
await page.getByTestId(`subagents-track-row-${child.id}`).click();
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
|
||||
@@ -136,7 +136,8 @@ test.describe("real provider subagent timelines", () => {
|
||||
panel.getByText("Start chatting with this agent...", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.getByTestId(`workspace-tab-agent_${handle.agentId}`).first().click();
|
||||
// A provider subagent has no composer; its callout is the way back to the parent.
|
||||
await page.getByTestId("parent-agent-callout-open").click();
|
||||
await expect(
|
||||
page.getByTestId("assistant-message").filter({ hasText: "ROOT_DONE" }).last(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
@@ -125,6 +125,29 @@ test("opening an already-tabbed subagent reveals its left tab without a duplicat
|
||||
}
|
||||
});
|
||||
|
||||
test("a subagent's parent pill returns to its parent", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "subagent-parent-pill-" });
|
||||
try {
|
||||
const pair = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Refactor auth flow",
|
||||
childTitle: "Write session tests",
|
||||
});
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, { workspaceId: pair.workspaceId, agentId: pair.child.id });
|
||||
|
||||
const pill = page.getByTestId("composer-parent-agent-pill");
|
||||
await expect(pill).toHaveAccessibleName("Back to Refactor auth flow");
|
||||
await pill.click();
|
||||
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${pair.parent.id}`)).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a preferred chat file link still opens beside its right-pane agent", async ({ page }) => {
|
||||
const target = "linked.ts:3";
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
|
||||
@@ -276,7 +276,11 @@ export function ComposerTrackRow({
|
||||
* knockout for marks that sit on top of an icon, and nothing sits under this one, so it comes off
|
||||
* too — leaving every mark's visible edge on the same rail whatever state it is in.
|
||||
*/
|
||||
function ComposerTrackMark({ bucket }: { bucket: SidebarStateBucket | null }): ReactElement | null {
|
||||
export function ComposerTrackMark({
|
||||
bucket,
|
||||
}: {
|
||||
bucket: SidebarStateBucket | null;
|
||||
}): ReactElement | null {
|
||||
if (!bucket) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1908,6 +1908,13 @@ export const ar: TranslationResources = {
|
||||
backdrop: "خلفية القائمة",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "العودة إلى الوكيل الأصلي",
|
||||
backTo: "العودة إلى {{label}}",
|
||||
parent: "الأصل",
|
||||
parentAgent: "الوكيل الأصلي",
|
||||
openParent: "فتح الوكيل الأصلي",
|
||||
},
|
||||
title: "الوكلاء الفرعيون",
|
||||
pillLabelOne: "وكيل فرعي واحد",
|
||||
pillLabelMany: "{{count}} وكلاء فرعيين",
|
||||
|
||||
@@ -1933,6 +1933,13 @@ export const en = {
|
||||
backdrop: "Menu backdrop",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "Back to parent",
|
||||
backTo: "Back to {{label}}",
|
||||
parent: "Parent",
|
||||
parentAgent: "Parent agent",
|
||||
openParent: "Open parent",
|
||||
},
|
||||
title: "Subagents",
|
||||
pillLabelOne: "1 subagent",
|
||||
pillLabelMany: "{{count}} subagents",
|
||||
|
||||
@@ -1956,6 +1956,13 @@ export const es: TranslationResources = {
|
||||
backdrop: "Fondo del menú",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "Volver al padre",
|
||||
backTo: "Volver a {{label}}",
|
||||
parent: "Padre",
|
||||
parentAgent: "Agente padre",
|
||||
openParent: "Abrir padre",
|
||||
},
|
||||
title: "Subagentes",
|
||||
pillLabelOne: "1 subagente",
|
||||
pillLabelMany: "{{count}} subagentes",
|
||||
|
||||
@@ -1954,6 +1954,13 @@ export const fr: TranslationResources = {
|
||||
backdrop: "Arrière-plan du menu",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "Retour au parent",
|
||||
backTo: "Retour à {{label}}",
|
||||
parent: "Parent",
|
||||
parentAgent: "Agent parent",
|
||||
openParent: "Ouvrir le parent",
|
||||
},
|
||||
title: "Sous-agents",
|
||||
pillLabelOne: "1 sous-agent",
|
||||
pillLabelMany: "{{count}} sous-agents",
|
||||
|
||||
@@ -1927,6 +1927,13 @@ export const ja: TranslationResources = {
|
||||
backdrop: "メニューの背景",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "親に戻る",
|
||||
backTo: "{{label}} に戻る",
|
||||
parent: "親",
|
||||
parentAgent: "親エージェント",
|
||||
openParent: "親を開く",
|
||||
},
|
||||
title: "サブエージェント",
|
||||
pillLabelOne: "サブエージェント 1 件",
|
||||
pillLabelMany: "サブエージェント {{count}} 件",
|
||||
|
||||
@@ -1918,6 +1918,13 @@ export const ko: TranslationResources = {
|
||||
backdrop: "메뉴 배경",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "상위 에이전트로 돌아가기",
|
||||
backTo: "{{label}}(으)로 돌아가기",
|
||||
parent: "상위",
|
||||
parentAgent: "상위 에이전트",
|
||||
openParent: "상위 에이전트 열기",
|
||||
},
|
||||
title: "하위 에이전트",
|
||||
pillLabelOne: "하위 에이전트 1개",
|
||||
pillLabelMany: "하위 에이전트 {{count}}개",
|
||||
|
||||
@@ -1941,6 +1941,13 @@ export const ptBR: TranslationResources = {
|
||||
backdrop: "Fundo do menu",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "Voltar ao pai",
|
||||
backTo: "Voltar para {{label}}",
|
||||
parent: "Pai",
|
||||
parentAgent: "Agente pai",
|
||||
openParent: "Abrir pai",
|
||||
},
|
||||
title: "Subagentes",
|
||||
pillLabelOne: "1 subagente",
|
||||
pillLabelMany: "{{count}} subagentes",
|
||||
|
||||
@@ -1940,6 +1940,13 @@ export const ru: TranslationResources = {
|
||||
backdrop: "Фон меню",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "Назад к родителю",
|
||||
backTo: "Назад к {{label}}",
|
||||
parent: "Родитель",
|
||||
parentAgent: "Родительский агент",
|
||||
openParent: "Открыть родителя",
|
||||
},
|
||||
title: "Субагенты",
|
||||
pillLabelOne: "1 субагент",
|
||||
pillLabelMany: "Субагентов: {{count}}",
|
||||
|
||||
@@ -1886,6 +1886,13 @@ export const zhCN: TranslationResources = {
|
||||
backdrop: "菜单背景",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "返回父代理",
|
||||
backTo: "返回 {{label}}",
|
||||
parent: "父代理",
|
||||
parentAgent: "父代理",
|
||||
openParent: "打开父代理",
|
||||
},
|
||||
title: "Subagent",
|
||||
pillLabelOne: "1 个 subagent",
|
||||
pillLabelMany: "{{count}} 个 subagent",
|
||||
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
|
||||
import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab";
|
||||
import { AgentTracks, hasAgentTracks } from "@/panels/agent-tracks";
|
||||
import { ParentAgentCallout, useParentAgentId } from "@/subagents/parent";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
|
||||
import {
|
||||
@@ -1109,7 +1110,11 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
});
|
||||
const hasPluginComposerPills = useHasPluginComposerPills(serverId, workspaceId, agentId);
|
||||
const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
|
||||
const parentAgentId = useParentAgentId(serverId, agentId);
|
||||
// With a composer the parent is a pill above it; without one, the box takes the composer's place.
|
||||
const parentPillAgentId = showsComposer ? parentAgentId : null;
|
||||
const hasVisibleAgentTracks = hasAgentTracks({
|
||||
parentAgentId: parentPillAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus: archiveFinishedSubagents.status,
|
||||
@@ -1161,6 +1166,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
composerState,
|
||||
],
|
||||
);
|
||||
const parentCallout = parentAgentId ? (
|
||||
<ParentAgentCallout serverId={serverId} parentAgentId={parentAgentId} />
|
||||
) : null;
|
||||
const composerSection = showsComposer ? (
|
||||
<RenderProfile id={`AgentComposerSection:${agentId}`}>
|
||||
<AgentComposerSection
|
||||
@@ -1179,7 +1187,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
onMessageSent={handleMessageSent}
|
||||
/>
|
||||
</RenderProfile>
|
||||
) : null;
|
||||
) : (
|
||||
parentCallout
|
||||
);
|
||||
const streamContent = (
|
||||
<View style={animatedStaticStyles.content}>
|
||||
{showsComposer ? null : <SheetAgentFacts serverId={serverId} agentId={agentId} />}
|
||||
@@ -1203,6 +1213,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={agentId}
|
||||
parentAgentId={parentPillAgentId}
|
||||
subagentRows={subagentRows}
|
||||
tasks={tasks}
|
||||
archiveFinishedStatus={archiveFinishedSubagents.status}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
useSubagentOpensInSheet,
|
||||
type SubagentRow,
|
||||
} from "@/subagents";
|
||||
import { ParentAgentPill } from "@/subagents/parent";
|
||||
import { SubagentsTrack } from "@/subagents/track";
|
||||
import type { TodoEntry } from "@/types/stream";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
@@ -34,6 +35,7 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
parentAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
@@ -43,6 +45,7 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
parentAgentId: string | null;
|
||||
subagentRows: SubagentRow[];
|
||||
tasks: TodoEntry[] | undefined;
|
||||
archiveFinishedStatus: ArchiveFinishedStatus;
|
||||
@@ -88,8 +91,12 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
[openSubagentTarget, serverId, workspaceId],
|
||||
);
|
||||
const handleOpenProviderSubagent = useCallback(
|
||||
(parentAgentId: string, subagentId: string) => {
|
||||
openSubagentTarget({ kind: "provider_subagent", parentAgentId, subagentId });
|
||||
(ownerAgentId: string, subagentId: string) => {
|
||||
openSubagentTarget({
|
||||
kind: "provider_subagent",
|
||||
parentAgentId: ownerAgentId,
|
||||
subagentId,
|
||||
});
|
||||
},
|
||||
[openSubagentTarget],
|
||||
);
|
||||
@@ -108,6 +115,7 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
if (
|
||||
!hasWorkspaceDiffStat &&
|
||||
!hasAgentTracks({
|
||||
parentAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
@@ -119,6 +127,7 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
|
||||
return (
|
||||
<ComposerTrackBar>
|
||||
{parentAgentId ? <ParentAgentPill serverId={serverId} parentAgentId={parentAgentId} /> : null}
|
||||
<AgentTaskList tasks={tasks} />
|
||||
<SubagentsTrack
|
||||
serverId={serverId}
|
||||
@@ -147,17 +156,20 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
});
|
||||
|
||||
export function hasAgentTracks({
|
||||
parentAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
hasPluginComposerPills = false,
|
||||
}: {
|
||||
parentAgentId: string | null;
|
||||
subagentRows: readonly SubagentRow[];
|
||||
tasks: readonly TodoEntry[] | undefined;
|
||||
archiveFinishedStatus: ArchiveFinishedStatus;
|
||||
hasPluginComposerPills?: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
parentAgentId !== null ||
|
||||
subagentRows.length > 0 ||
|
||||
Boolean(tasks?.length) ||
|
||||
archiveFinishedStatus.kind !== "idle" ||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { usePaneContext } from "@/panels/pane-context";
|
||||
import { PaneSubtitle } from "@/panels/pane-subtitle";
|
||||
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { ParentAgentCallout } from "@/subagents/parent";
|
||||
import { type SubagentRow, useSubagentsForParent } from "@/subagents/select";
|
||||
import { useSubagentOpensInSheet } from "@/subagents/opens-in-sheet";
|
||||
import { SubagentsTrack } from "@/subagents/track";
|
||||
@@ -253,33 +254,37 @@ function ProviderSubagentPanel() {
|
||||
return (
|
||||
<View style={styles.container} testID="provider-subagent-panel">
|
||||
{subtitle ? <PaneSubtitle text={subtitle} testID="provider-subagent-pane-subtitle" /> : null}
|
||||
<AgentStreamView
|
||||
agentId={streamId}
|
||||
serverId={serverId}
|
||||
context={streamContext}
|
||||
streamItems={timeline?.tail ?? EMPTY_STREAM_ITEMS}
|
||||
streamHead={timeline?.head ?? EMPTY_STREAM_ITEMS}
|
||||
turnPresentation={turnPresentation}
|
||||
pendingPermissions={EMPTY_PERMISSIONS}
|
||||
isAuthoritativeHistoryReady
|
||||
onOpenWorkspaceFile={openFileInWorkspace}
|
||||
readOnly
|
||||
historyPagination={historyPagination}
|
||||
bottomOverlayTailClearance={childTrackClearance.tail}
|
||||
bottomOverlayControlClearance={childTrackClearance.controls}
|
||||
/>
|
||||
<ProviderSubagentChildTrack
|
||||
serverId={serverId}
|
||||
rows={childRows}
|
||||
onOpenProviderSubagent={openProviderChild}
|
||||
opensInSheet={opensInSheet}
|
||||
/>
|
||||
<View style={styles.stream}>
|
||||
<AgentStreamView
|
||||
agentId={streamId}
|
||||
serverId={serverId}
|
||||
context={streamContext}
|
||||
streamItems={timeline?.tail ?? EMPTY_STREAM_ITEMS}
|
||||
streamHead={timeline?.head ?? EMPTY_STREAM_ITEMS}
|
||||
turnPresentation={turnPresentation}
|
||||
pendingPermissions={EMPTY_PERMISSIONS}
|
||||
isAuthoritativeHistoryReady
|
||||
onOpenWorkspaceFile={openFileInWorkspace}
|
||||
readOnly
|
||||
historyPagination={historyPagination}
|
||||
bottomOverlayTailClearance={childTrackClearance.tail}
|
||||
bottomOverlayControlClearance={childTrackClearance.controls}
|
||||
/>
|
||||
<ProviderSubagentChildTrack
|
||||
serverId={serverId}
|
||||
rows={childRows}
|
||||
onOpenProviderSubagent={openProviderChild}
|
||||
opensInSheet={opensInSheet}
|
||||
/>
|
||||
</View>
|
||||
<ParentAgentCallout serverId={serverId} parentAgentId={target.parentAgentId} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: { flex: 1, minHeight: 0 },
|
||||
stream: { flex: 1, minHeight: 0 },
|
||||
unsupported: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
|
||||
unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" },
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CornerUpLeft } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ComposerTrackMark } from "@/composer/tracks";
|
||||
import { FOOTER_HEIGHT } from "@/constants/layout";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useParentAgent, type ParentAgent } from "./use-parent-agent";
|
||||
|
||||
function ParentIcon({
|
||||
icon: Icon,
|
||||
size,
|
||||
color = "",
|
||||
}: {
|
||||
icon: ParentAgent["icon"];
|
||||
size: number;
|
||||
color?: string;
|
||||
}) {
|
||||
return <Icon size={size} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedParentIcon = withUnistyles(ParentIcon);
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
/** Stands in the composer's place on a subagent shown without one: who the parent is, and the way back. */
|
||||
export function ParentAgentCallout({
|
||||
serverId,
|
||||
parentAgentId,
|
||||
}: {
|
||||
serverId: string;
|
||||
parentAgentId: string;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const parent = useParentAgent(serverId, parentAgentId);
|
||||
|
||||
return (
|
||||
<View style={styles.rail}>
|
||||
<View style={styles.callout} testID="parent-agent-callout">
|
||||
<View style={styles.avatar}>
|
||||
<ThemedParentIcon icon={parent.icon} size={18} uniProps={foregroundColorMapping} />
|
||||
</View>
|
||||
<View style={styles.body}>
|
||||
<Text style={styles.eyebrow}>{t("subagents.parent.parent")}</Text>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{parent.label ?? t("subagents.parent.parentAgent")}
|
||||
</Text>
|
||||
<ComposerTrackMark bucket={parent.statusBucket} />
|
||||
</View>
|
||||
</View>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
leftIcon={CornerUpLeft}
|
||||
onPress={parent.open}
|
||||
testID="parent-agent-callout-open"
|
||||
>
|
||||
{t("subagents.parent.openParent")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
rail: {
|
||||
minHeight: FOOTER_HEIGHT,
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
callout: {
|
||||
width: "100%",
|
||||
maxWidth: theme.contentMaxWidth,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
padding: theme.spacing[3],
|
||||
paddingRight: { xs: theme.spacing[3], md: theme.spacing[4] },
|
||||
},
|
||||
avatar: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[0.5],
|
||||
},
|
||||
eyebrow: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minWidth: 0,
|
||||
},
|
||||
title: {
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ParentAgentCallout } from "./callout";
|
||||
export { ParentAgentPill } from "./pill";
|
||||
export { useParentAgentId } from "./use-parent-agent";
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { Text } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronLeft } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { composerPillStyles } from "@/composer/pill-styles";
|
||||
import { ComposerTrackMark } from "@/composer/tracks";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useParentAgent } from "./use-parent-agent";
|
||||
|
||||
const ThemedChevronLeft = withUnistyles(ChevronLeft);
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
/**
|
||||
* The way back from a subagent to the agent that started it. It leads the pill rail and names
|
||||
* the parent rather than saying "parent": in a stack of subagents the title is what tells you
|
||||
* where you will land.
|
||||
*/
|
||||
export function ParentAgentPill({
|
||||
serverId,
|
||||
parentAgentId,
|
||||
}: {
|
||||
serverId: string;
|
||||
parentAgentId: string;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const parent = useParentAgent(serverId, parentAgentId);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
||||
const handleHoverOut = useCallback(() => setIsHovered(false), []);
|
||||
const bodyStyle = useMemo(
|
||||
() => [composerPillStyles.body, styles.body, isHovered && composerPillStyles.bodyActive],
|
||||
[isHovered],
|
||||
);
|
||||
const labelStyle = useMemo(
|
||||
() => [composerPillStyles.label, styles.label, isHovered && composerPillStyles.labelActive],
|
||||
[isHovered],
|
||||
);
|
||||
const label = parent.label ?? t("subagents.parent.backToParent");
|
||||
|
||||
return (
|
||||
<PressHighlight
|
||||
testID="composer-parent-agent-pill"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("subagents.parent.backTo", { label })}
|
||||
onPress={parent.open}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
style={bodyStyle}
|
||||
highlightStyle={composerPillStyles.bodyActive}
|
||||
>
|
||||
<ThemedChevronLeft
|
||||
size={14}
|
||||
uniProps={isHovered ? foregroundColorMapping : mutedColorMapping}
|
||||
/>
|
||||
<ComposerTrackMark bucket={parent.statusBucket} />
|
||||
<Text style={labelStyle} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</PressHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// The chevron's box is wider than its stroke, so the pill pulls it toward the edge to keep the
|
||||
// visible glyph on the same inset as other pills' leading marks.
|
||||
body: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[1.5],
|
||||
paddingLeft: theme.spacing[2],
|
||||
},
|
||||
label: {
|
||||
flexShrink: 1,
|
||||
maxWidth: { xs: 140, md: 240 },
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAgentDescriptor } from "@/agent-presentation";
|
||||
import type { PanelDescriptor } from "@/panels/panel-registry";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
|
||||
export interface ParentAgent {
|
||||
/** The parent's title, or null while it has none — callers fall back to a generic name. */
|
||||
label: string | null;
|
||||
icon: PanelDescriptor["icon"];
|
||||
statusBucket: SidebarStateBucket | null;
|
||||
open: () => void;
|
||||
}
|
||||
|
||||
/** The parent of a managed agent, or null for a root or detached agent. */
|
||||
export function useParentAgentId(serverId: string, agentId: string): string | null {
|
||||
return useSessionStore((state) => {
|
||||
const session = state.sessions[serverId];
|
||||
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
|
||||
return agent?.parentAgentId ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
/** The parent as its own tab presents it, and the way back to it. */
|
||||
export function useParentAgent(serverId: string, parentAgentId: string): ParentAgent {
|
||||
const descriptor = useAgentDescriptor({ kind: "agent", agentId: parentAgentId }, { serverId });
|
||||
// The parent may live in another workspace; navigateToAgent opens it wherever it is.
|
||||
const open = useCallback(() => {
|
||||
navigateToAgent({ serverId, agentId: parentAgentId });
|
||||
}, [parentAgentId, serverId]);
|
||||
return {
|
||||
label: descriptor.label || null,
|
||||
icon: descriptor.icon,
|
||||
statusBucket: descriptor.statusBucket,
|
||||
open,
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,6 @@ export const PROVIDER_CAPABILITIES = [
|
||||
"prompt.image",
|
||||
"prompt.output_schema",
|
||||
"prompt.steer",
|
||||
"session.can_stop_runtime",
|
||||
"session.archive",
|
||||
"session.configure",
|
||||
"session.list",
|
||||
@@ -278,7 +277,6 @@ 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 {
|
||||
@@ -554,7 +552,6 @@ 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 }
|
||||
@@ -637,8 +634,6 @@ 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":
|
||||
@@ -960,13 +955,6 @@ 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(),
|
||||
]);
|
||||
|
||||
@@ -1338,14 +1326,6 @@ 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,4 +1,3 @@
|
||||
import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js";
|
||||
import {
|
||||
prepareAgentMessage,
|
||||
formatAgentMessage,
|
||||
@@ -10008,47 +10007,6 @@ 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;
|
||||
@@ -10993,12 +10951,12 @@ test("tryRunOutOfBand gives providers the command another agent sent, not its en
|
||||
|
||||
try {
|
||||
const fromAgent = prepareAgentMessage("/handled", { kind: "agent-message", agentId: "sender" });
|
||||
expect(await manager.tryRunOutOfBand(agent.id, fromAgent.prompt)).toBe(true);
|
||||
expect(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(await manager.tryRunOutOfBand(agent.id, pasted.prompt)).toBe(false);
|
||||
expect(manager.tryRunOutOfBand(agent.id, pasted.prompt)).toBe(false);
|
||||
expect(seenPrompts.at(-1)).toBe(pasted.prompt);
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
@@ -12125,176 +12083,3 @@ 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,5 +1,3 @@
|
||||
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";
|
||||
@@ -760,8 +758,6 @@ 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;
|
||||
@@ -1751,64 +1747,6 @@ 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) {
|
||||
@@ -1829,7 +1767,7 @@ export class AgentManager {
|
||||
return close;
|
||||
}
|
||||
|
||||
private async closeAgentRuntime(agentId: string, canClose?: () => boolean): Promise<boolean> {
|
||||
private async closeAgentRuntime(agentId: string): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
this.logger.trace(
|
||||
{
|
||||
@@ -1844,7 +1782,6 @@ 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();
|
||||
@@ -1870,7 +1807,6 @@ export class AgentManager {
|
||||
if (persistError !== undefined) {
|
||||
throw persistError;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private cancelRunningProviderSubagents(parentAgentId: string): void {
|
||||
@@ -2523,19 +2459,7 @@ export class AgentManager {
|
||||
* emitted by the handler flow through dispatchStream so they persist and
|
||||
* broadcast like normal timeline events.
|
||||
*/
|
||||
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,
|
||||
});
|
||||
}
|
||||
tryRunOutOfBand(agentId: string, prompt: AgentPromptInput, options?: AgentRunOptions): boolean {
|
||||
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.
|
||||
@@ -2546,7 +2470,6 @@ export class AgentManager {
|
||||
if (!handler) {
|
||||
return false;
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
if (options?.clientMessageId) {
|
||||
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "accepted");
|
||||
this.emitState(agent);
|
||||
@@ -2566,7 +2489,7 @@ export class AgentManager {
|
||||
}
|
||||
this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() });
|
||||
};
|
||||
void this.runForegroundMutation(agentId, async () => {
|
||||
void (async () => {
|
||||
try {
|
||||
await handler.run({ emit: dispatch });
|
||||
} catch (error) {
|
||||
@@ -2577,7 +2500,7 @@ export class AgentManager {
|
||||
item: { type: "assistant_message", text: `[Error] ${text}` },
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2661,27 +2584,11 @@ 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,33 +283,3 @@ 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,8 +43,6 @@ 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. */
|
||||
@@ -140,7 +138,6 @@ 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 (await agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) {
|
||||
if (agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) {
|
||||
options?.onDispatch?.("out_of_band");
|
||||
return { disposition: "out_of_band" };
|
||||
}
|
||||
|
||||
@@ -694,10 +694,6 @@ 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,4 +1,3 @@
|
||||
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";
|
||||
@@ -413,87 +412,3 @@ 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,8 +49,6 @@ 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,
|
||||
@@ -74,8 +72,6 @@ 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);
|
||||
@@ -304,13 +300,6 @@ 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[]>();
|
||||
@@ -320,11 +309,9 @@ 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) => {
|
||||
registering.set(input.callerAgentId, (registering.get(input.callerAgentId) ?? 0) + 1);
|
||||
return serialize(async () => {
|
||||
register: (input, pendingDispatch = false) =>
|
||||
serialize(async () => {
|
||||
if (closed) throw new Error("Finish subscriptions are closed");
|
||||
const record: FinishSubscription = {
|
||||
...input,
|
||||
@@ -350,18 +337,13 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio
|
||||
},
|
||||
reject: () =>
|
||||
serialize(async () => {
|
||||
await remove(watch);
|
||||
stop(watch, actualGroup);
|
||||
await remove(watch);
|
||||
}),
|
||||
};
|
||||
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;
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
async tryRunOutOfBand(): Promise<boolean> {
|
||||
tryRunOutOfBand(): boolean {
|
||||
return this.outOfBandHandled;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
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,
|
||||
@@ -1239,85 +1234,3 @@ 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,15 +113,9 @@ function providerError(error: ProviderError): Error {
|
||||
|
||||
function isProviderRequestReply(
|
||||
event: ProviderEvent,
|
||||
): event is Extract<
|
||||
ProviderEvent,
|
||||
{ type: "request.completed" | "catalog" | "sessions" | "session.can_stop_runtime" }
|
||||
> {
|
||||
): event is Extract<ProviderEvent, { type: "request.completed" | "catalog" | "sessions" }> {
|
||||
return (
|
||||
event.type === "request.completed" ||
|
||||
event.type === "catalog" ||
|
||||
event.type === "sessions" ||
|
||||
event.type === "session.can_stop_runtime"
|
||||
event.type === "request.completed" || event.type === "catalog" || event.type === "sessions"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -570,21 +564,10 @@ class ProviderRuntime {
|
||||
}
|
||||
|
||||
private finishRequest(
|
||||
event: Extract<
|
||||
ProviderEvent,
|
||||
{ type: "request.completed" | "catalog" | "sessions" | "session.can_stop_runtime" }
|
||||
>,
|
||||
event: Extract<ProviderEvent, { type: "request.completed" | "catalog" | "sessions" }>,
|
||||
): 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);
|
||||
}
|
||||
@@ -652,27 +635,6 @@ 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",
|
||||
@@ -1239,11 +1201,6 @@ 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,7 +469,6 @@ 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,5 +1,3 @@
|
||||
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";
|
||||
@@ -708,137 +706,3 @@ describe("background Claude subagents", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("Claude runtime safety protects silent shells, monitors and pending completion notifications", () => {
|
||||
const safety = new ClaudeRuntimeSafety();
|
||||
const observe = (message: unknown) => safety.observe(message as SDKMessage);
|
||||
observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" });
|
||||
expect(safety.canStopRuntime()).toBe(true);
|
||||
observe({
|
||||
type: "system",
|
||||
subtype: "background_tasks_changed",
|
||||
tasks: [
|
||||
{ task_id: "quiet-shell", task_type: "local_bash", description: "sleep 7200" },
|
||||
{ task_id: "monitor", task_type: "local_bash", description: "watch build" },
|
||||
{ task_id: "native-child", task_type: "local_agent", description: "research" },
|
||||
],
|
||||
});
|
||||
expect(safety.canStopRuntime()).toBe(false);
|
||||
observe({ type: "system", subtype: "background_tasks_changed", tasks: [] });
|
||||
expect(safety.canStopRuntime()).toBe(false);
|
||||
for (const task_id of ["quiet-shell", "monitor"])
|
||||
observe({ type: "system", subtype: "task_notification", task_id });
|
||||
expect(safety.canStopRuntime()).toBe(false);
|
||||
observe({ type: "system", subtype: "task_notification", task_id: "native-child" });
|
||||
expect(safety.canStopRuntime()).toBe(true);
|
||||
safety.reset();
|
||||
observe({ type: "system", subtype: "init", claude_code_version: "2.1.220" });
|
||||
expect(safety.canStopRuntime()).toBe(false);
|
||||
observe({ type: "system", subtype: "background_tasks_changed", tasks: [] });
|
||||
expect(safety.canStopRuntime()).toBe(true);
|
||||
});
|
||||
|
||||
test("Claude session runtime safety observes pumped background work and autonomous replies", async () => {
|
||||
let wake: (() => void) | undefined;
|
||||
const channel = { closed: false };
|
||||
const queue: Array<{ message: SDKMessage; consumed(): void }> = [];
|
||||
const pump = (async function* () {
|
||||
while (!channel.closed) {
|
||||
const entry = queue.shift();
|
||||
if (!entry) {
|
||||
await new Promise<void>((resolve) => {
|
||||
wake = resolve;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
yield entry.message;
|
||||
entry.consumed();
|
||||
}
|
||||
})();
|
||||
const push = (message: unknown) =>
|
||||
new Promise<void>((consumed) => {
|
||||
queue.push({ message: message as SDKMessage, consumed });
|
||||
wake?.();
|
||||
});
|
||||
const end = () => {
|
||||
channel.closed = true;
|
||||
wake?.();
|
||||
};
|
||||
let nativeInput!: AsyncIterator<SDKUserMessage>;
|
||||
let connected!: () => void;
|
||||
const connection = new Promise<void>((resolve) => {
|
||||
connected = resolve;
|
||||
});
|
||||
// The SDK Query is injected through the production adapter's existing launch port.
|
||||
const query = {
|
||||
next: () => pump.next(),
|
||||
return: async () => {
|
||||
end();
|
||||
return pump.return(undefined);
|
||||
},
|
||||
close: end,
|
||||
interrupt: async () => {},
|
||||
setPermissionMode: async () => {},
|
||||
setModel: async () => {},
|
||||
supportedModels: async () => [],
|
||||
supportedCommands: async () => [],
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
} as unknown as Query;
|
||||
const session = await new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
queryFactory: ({ prompt }) => {
|
||||
if (typeof prompt === "string") throw new Error("Expected streaming input");
|
||||
nativeInput = prompt[Symbol.asyncIterator]();
|
||||
connected();
|
||||
return query;
|
||||
},
|
||||
}).createSession({ provider: "claude", cwd: process.cwd() });
|
||||
const init = {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "runtime-safety",
|
||||
claude_code_version: "2.1.292",
|
||||
permissionMode: "default",
|
||||
};
|
||||
const result = {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
total_cost_usd: 0,
|
||||
};
|
||||
try {
|
||||
await session.startTurn("start background work");
|
||||
await connection;
|
||||
const input = await nativeInput.next();
|
||||
await push({ type: "command_lifecycle", command_uuid: input.value.uuid, state: "started" });
|
||||
await push(init);
|
||||
await push(result);
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
await push({
|
||||
type: "system",
|
||||
subtype: "background_tasks_changed",
|
||||
tasks: [{ task_id: "monitor", task_type: "local_bash", description: "quiet monitor" }],
|
||||
});
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
await push({ type: "system", subtype: "background_tasks_changed", tasks: [] });
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
await push({
|
||||
type: "system",
|
||||
subtype: "task_notification",
|
||||
task_id: "monitor",
|
||||
status: "completed",
|
||||
output: "ready",
|
||||
summary: "ready",
|
||||
});
|
||||
// Completion can wake Claude autonomously after the foreground turn has ended.
|
||||
await push(init);
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
await push(result);
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
|
||||
import { validateProviderOptions } from "../../provider-options.js";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -2256,8 +2255,6 @@ 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;
|
||||
@@ -2933,18 +2930,6 @@ 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(
|
||||
{
|
||||
@@ -3438,7 +3423,6 @@ 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(
|
||||
@@ -4094,17 +4078,13 @@ class ClaudeAgentSession implements AgentSession {
|
||||
);
|
||||
};
|
||||
const handlePumpedMessage = async (message: SDKMessage): Promise<boolean> => {
|
||||
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--;
|
||||
logRawMessage(message);
|
||||
consecutiveInterruptAbortRecoveries = 0;
|
||||
if (await this.handleMissingResumedConversation(message, activeQuery)) {
|
||||
return true;
|
||||
}
|
||||
await this.routeSdkMessageFromPump(message);
|
||||
return false;
|
||||
};
|
||||
const drainActiveQuery = async (): Promise<boolean> => {
|
||||
for await (const message of activeQuery) {
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { parseClaudeCodeVersion } from "./model-manifest.js";
|
||||
|
||||
/** Process-local background ownership. Completion delivery can follow an empty roster. */
|
||||
export class ClaudeRuntimeSafety {
|
||||
private supported = false;
|
||||
private liveTasks = new Set<string>();
|
||||
private readonly awaitingNotifications = new Set<string>();
|
||||
|
||||
reset(): void {
|
||||
this.supported = false;
|
||||
this.liveTasks.clear();
|
||||
this.awaitingNotifications.clear();
|
||||
}
|
||||
|
||||
observe(message: SDKMessage): void {
|
||||
if (message.type !== "system") return;
|
||||
if (message.subtype === "init") {
|
||||
// 2.1.292 is a known roster-capable release. Older binaries can demonstrate
|
||||
// support by sending a roster; a fresh process starts with an empty roster.
|
||||
const version = parseClaudeCodeVersion(message.claude_code_version ?? "");
|
||||
if (
|
||||
version &&
|
||||
(version[0] > 2 ||
|
||||
(version[0] === 2 && (version[1] > 1 || (version[1] === 1 && version[2] >= 292))))
|
||||
)
|
||||
this.supported = true;
|
||||
}
|
||||
if (message.subtype === "background_tasks_changed") {
|
||||
this.supported = true;
|
||||
this.liveTasks = new Set(message.tasks.map((task) => task.task_id));
|
||||
for (const id of this.liveTasks) this.awaitingNotifications.add(id);
|
||||
}
|
||||
if (message.subtype === "task_notification") {
|
||||
this.awaitingNotifications.delete(message.task_id);
|
||||
}
|
||||
}
|
||||
|
||||
canStopRuntime(): boolean {
|
||||
return this.supported && this.liveTasks.size === 0 && this.awaitingNotifications.size === 0;
|
||||
}
|
||||
}
|
||||
@@ -7167,35 +7167,3 @@ 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,34 +5029,6 @@ 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,7 +202,6 @@ 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;
|
||||
@@ -256,10 +255,6 @@ 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"));
|
||||
@@ -397,7 +392,6 @@ 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) {
|
||||
@@ -405,8 +399,6 @@ 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, OmpSessionStateSchema } from "./rpc-types.js";
|
||||
import { OmpRuntimeEventSchema } 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,86 +1544,3 @@ 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,27 +1172,6 @@ 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,8 +152,6 @@ 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,21 +7010,3 @@ 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,43 +5055,6 @@ 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,7 +170,6 @@ export class TestOpenCodeClient {
|
||||
null;
|
||||
sessionPromptAsyncEvents: unknown[] = [idleEvent()];
|
||||
sessionPromptAsyncResponse: OpenCodeResponse = {};
|
||||
ptyListResponse: OpenCodeResponse = { data: [] };
|
||||
sessionStatusResponse: OpenCodeResponse = { data: {} };
|
||||
sessionStatusImplementation:
|
||||
| ((parameters: unknown, options: unknown) => Promise<OpenCodeResponse>)
|
||||
@@ -196,7 +195,6 @@ export class TestOpenCodeClient {
|
||||
|
||||
asSdkClient(): OpencodeClient {
|
||||
return {
|
||||
pty: { list: async () => this.ptyListResponse },
|
||||
app: {
|
||||
agents: async (parameters: unknown, options: unknown) => {
|
||||
this.calls.appAgents.push(parameters);
|
||||
|
||||
@@ -90,9 +90,6 @@ 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 },
|
||||
@@ -134,7 +131,6 @@ 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,110 +1074,3 @@ 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,7 +2,6 @@ import type { OpenCodeClient } from "@opencode/client";
|
||||
|
||||
export interface V2Api {
|
||||
server: OpenCodeClient["server"];
|
||||
shell: Pick<OpenCodeClient["shell"], "list">;
|
||||
session: Pick<
|
||||
OpenCodeClient["session"],
|
||||
| "create"
|
||||
@@ -21,7 +20,6 @@ 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,36 +516,6 @@ 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,23 +3488,3 @@ 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,22 +1581,6 @@ 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,4 +1,3 @@
|
||||
import { createIdleRuntimeCleanup } from "./agent/idle-runtime-cleanup/index.js";
|
||||
import {
|
||||
createFinishSubscriptions,
|
||||
createFinishObservation,
|
||||
@@ -454,7 +453,6 @@ export interface PaseoDaemonConfig {
|
||||
dictationFinalTimeoutMs?: number;
|
||||
downloadTokenTtlMs?: number;
|
||||
agentProviderSettings?: AgentProviderRuntimeSettingsMap;
|
||||
idleRuntimeCleanup?: boolean;
|
||||
providerCatalogRefreshTimeoutMs?: number;
|
||||
metadataGeneration?: {
|
||||
providers?: Array<{
|
||||
@@ -1400,7 +1398,6 @@ export async function createPaseoDaemon(
|
||||
archiveWorkspace: archiveScheduleWorkspaceExternal,
|
||||
});
|
||||
await scheduleService.start();
|
||||
|
||||
agentManager.setAgentArchivedCallback(async (agentId) => {
|
||||
try {
|
||||
await scheduleService.completeForAgent(agentId);
|
||||
@@ -1448,15 +1445,6 @@ 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 => ({
|
||||
@@ -1919,7 +1907,6 @@ 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,17 +27,6 @@ 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,7 +660,6 @@ 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,7 +339,6 @@ 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(),
|
||||
|
||||
@@ -552,9 +552,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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.
|
||||
|
||||
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.
|
||||
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,20 +84,6 @@ 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,16 +82,6 @@ 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,52 +306,6 @@ 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,38 +269,6 @@ 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