diff --git a/doc/AGENT-LIFECYCLE.md b/doc/AGENT-LIFECYCLE.md index 87033f8d56..96767c8313 100644 --- a/doc/AGENT-LIFECYCLE.md +++ b/doc/AGENT-LIFECYCLE.md @@ -156,7 +156,12 @@ It shows `paused` during preparation, verification, pause, and resume. It shows `terminated` as soon as termination starts. Execution code can change this field only when the lifecycle is `ready`. Use `lifecycleState` to distinguish the steps. -This change does not change the user interface. +The agent page shows preparation, verification, pause, resume, and cleanup +progress from `lifecycleState`. Failed steps show the host error and a Retry +action. The page refreshes pending steps every two seconds and other states +every thirty seconds. The lifecycle query does not replace the agent record in +editable forms, so background updates preserve unsaved settings. It does not +query plugins for a separate readiness state. ## Required plugin work diff --git a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md index 4030c0104d..cae51f0cdc 100644 --- a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md +++ b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md @@ -57,6 +57,63 @@ the visible operation finishes. Its timeout and terminal cleanup paths need stronger completion receipts before that restriction can be relaxed. Restarting does not bypass the separate persisted lease and recovery checks. +## Required agent lifecycle work + +Use `onAgentLifecycle` for resources that must be ready before an agent works or +removed before termination completes. Declare both `agentLifecycle: true` and +`agents.lifecycle.manage` in the manifest. The host calls the plugin after the +lifecycle command commits. The lifecycle module owns admission, progress, and +retries; a plugin does not need a readiness data handler or a polling job. + +```ts +import { definePlugin } from "@paperclipai/plugin-sdk"; +import { reconcileResources } from "./resources.js"; + +export default definePlugin({ + async setup() {}, + async onAgentLifecycle(request) { + // Provider code must persist revision fences and tolerate repeated calls. + const complete = await reconcileResources(request); + return { + operationId: request.operationId, + version: request.version, + status: complete ? "complete" : "pending", + }; + }, +}); +``` + +`reconcileResources` above is plugin-owned provider code, not an SDK function. +Use `companyId` and `agentId` from the request to scope resources. +Request `agents.read` separately if the handler needs `ctx.agents.get`. +Lifecycle authority does not grant agent configuration writes or secret access. + +| Phase | Resource action | +| --- | --- | +| `preparing` | Create or reconcile the agent's resources and wait for readiness. | +| `verifying` | Complete any remaining checks after the host tests the saved harness. | +| `pausing` | Stop resources after the host confirms execution has stopped. | +| `resuming` | Start resources and wait for readiness. | +| `terminating` | Stop resources after the host revokes keys and confirms execution has stopped. | +| `cleaning_up` | Remove owned resources and wait until removal completes. | + +Return `pending` while an external operation continues. Return `complete` only +when its effect is confirmed; accepting a deletion request is not completion. +Throw on failure. The host records a fixed error without provider error text. +The agent page shows the phase and offers Retry for failed work. + +Calls can repeat, including after a timeout or restart. Retain the highest +version per company and agent, reject older requests, and serialize external +effects or clean up late results. The host's revision fence protects its state; +it cannot cancel a provider request that has already left the process. + +The host saves required plugin IDs at first selection. Company-disabled plugins +are excluded then. Disabling or removing a selected plugin blocks progress; +restore it to complete the step. Later installations do not backfill existing +agents. The durable event inbox below is separate: acknowledging an event does +not complete required lifecycle work. See [Agent lifecycle](../AGENT-LIFECYCLE.md) +for phase ordering, recovery, and existing-installation limits. + ## Durable resource lifecycle inbox Plugins with `events.subscribe` can read durable, company-scoped resource hooks diff --git a/packages/plugins/sdk/src/define-plugin.ts b/packages/plugins/sdk/src/define-plugin.ts index 43a2991229..f6f2021470 100644 --- a/packages/plugins/sdk/src/define-plugin.ts +++ b/packages/plugins/sdk/src/define-plugin.ts @@ -352,17 +352,23 @@ export interface PluginDefinition { params: DetectExternalObjectsParams, ): Promise; + /** + * Complete required work for the committed agent lifecycle phase. + * Requires manifest `agentLifecycle: true` and `agents.lifecycle.manage`. + * Echo operationId/version; return pending until the effect finishes. + * Calls can repeat. Fence older versions and make external effects idempotent. + */ + onAgentLifecycle?(params: AgentLifecycleRequest): Promise; + + /** Propose a member from host-authorized candidates. Requires ai.connections.route. */ + onRouteAiConnection?(params: AiConnectionRouterRequest): Promise; + /** * Called when Paperclip needs the current normalized status for one external * object owned by a manifest-declared provider. * * Requires `external.objects.read`. */ - /** Propose a member from host-authorized candidates. Requires ai.connections.route. */ - onAgentLifecycle?(params: AgentLifecycleRequest): Promise; - - onRouteAiConnection?(params: AiConnectionRouterRequest): Promise; - onResolveExternalObject?( params: ResolveExternalObjectParams, ): Promise; diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 8bf53cb713..95fa9dc8a5 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -687,9 +687,9 @@ export interface PaperclipPluginManifestV1 { minimumPaperclipVersion?: PluginMinimumHostVersion; /** Capabilities this plugin requires from the host. Enforced at runtime. */ capabilities: PluginCapability[]; - /** Opt into the native pooled-connection catalog, setup, and management UI. */ /** Required participant in agent preparation, stop, resume, and cleanup. */ agentLifecycle?: true; + /** Opt into the native pooled-connection catalog, setup, and management UI. */ aiConnectionRouter?: { name: string; description: string }; /** Entrypoint paths relative to the package root. */ entrypoints: { diff --git a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts index 6884957481..782be4c615 100644 --- a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts +++ b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts @@ -48,6 +48,7 @@ let currentAccessCanUser = false; const mockAgentService = vi.hoisted(() => ({ getById: vi.fn(), + retryLifecycle: vi.fn(), pause: vi.fn(), resume: vi.fn(), clearError: vi.fn(), @@ -302,6 +303,7 @@ function resetMockDefaults() { currentKeyAgentId = agentId; currentAccessCanUser = false; mockAgentService.getById.mockImplementation(async () => ({ ...baseAgent })); + mockAgentService.retryLifecycle.mockImplementation(async () => ({ ...baseAgent, lifecycleState: "preparing", lifecycleError: null })); mockAgentService.pause.mockImplementation(async () => ({ ...baseAgent })); mockAgentService.resume.mockImplementation(async () => ({ ...baseAgent })); mockAgentService.clearError.mockImplementation(async () => ({ ...baseAgent, status: "idle" })); @@ -760,8 +762,38 @@ vi.mock("../services/agent-lifecycle.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, createAgentLifecycle: () => ({ requestHire: (...args: unknown[]) => mockAgentService.create(...args), + retry: (...args: unknown[]) => mockAgentService.retryLifecycle(...args), pauseAgent: (...args: unknown[]) => mockAgentService.pause(...args), resumeAgent: (...args: unknown[]) => mockAgentService.resume(...args), terminateAgent: (...args: unknown[]) => mockAgentService.terminate(...args), }) }; }); + + +describe("agent lifecycle retry authorization", () => { + beforeEach(resetMockDefaults); + + it("retries and audits a board-authorized agent in its company", async () => { + const app = await createApp({ type: "board", userId: "board", source: "local_implicit" }); + const res = await requestApp(app, base => request(base).post(`/api/agents/${agentId}/lifecycle/retry`).send({})); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ id: agentId, lifecycleState: "preparing", lifecycleError: null }); + expect(mockAgentService.retryLifecycle).toHaveBeenCalledWith(agentId); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + companyId, entityId: agentId, action: "agent.lifecycle_retried", actorType: "user", actorId: "board", + })); + }); + + it.each([ + [{ type: "none" }, 403], + [{ type: "agent", agentId, companyId }, 403], + [{ type: "board", userId: "outsider", source: "session", companyIds: ["other-company"] }, 404], + [{ type: "board", userId: "reader", source: "session", companyIds: [companyId] }, 403], + ])("refuses retry for %j", async (actor, status) => { + const app = await createApp(actor); + const res = await requestApp(app, base => request(base).post(`/api/agents/${agentId}/lifecycle/retry`).send({})); + expect(res.status).toBe(status); + expect(mockAgentService.retryLifecycle).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/plugin-idle-rpc.test.ts b/server/src/__tests__/plugin-idle-rpc.test.ts index a60365612e..d9afe5233e 100644 --- a/server/src/__tests__/plugin-idle-rpc.test.ts +++ b/server/src/__tests__/plugin-idle-rpc.test.ts @@ -21,8 +21,8 @@ function fixture(definition: PluginDefinition, rpcTimeoutMs = 30_000) { } describe("plugin idle RPC", () => { - it("advertises and dispatches the agent lifecycle method", async () => { - const request = { companyId: "company", agentId: "agent", operationId: "operation", version: 7, phase: "preparing" }; + it.each(["preparing", "verifying", "pausing", "resuming", "terminating", "cleaning_up"] as const)("advertises and dispatches the %s lifecycle phase", async phase => { + const request = { companyId: "company", agentId: "agent", operationId: "operation", version: 7, phase }; const f = fixture({ async setup() {}, async onAgentLifecycle(input) { expect(input).toEqual(request); return { operationId: input.operationId, version: input.version, status: "pending" }; @@ -33,6 +33,14 @@ describe("plugin idle RPC", () => { } finally { f.close(); } }); + it("does not acknowledge lifecycle work without a handler", async () => { + const f = fixture({ async setup() {} }); + try { + expect((await f.initialize()).result.supportedMethods).not.toContain("agentLifecycle"); + expect((await f.call("agentLifecycle", { companyId: "company", agentId: "agent", operationId: "operation", version: 1, phase: "preparing" })).error).toBeDefined(); + } finally { f.close(); } + }); + const hold = () => ({ ownerId: "idle-owner", expiresAt: Date.now() + 30_000 }); it("seals an idle worker, fences stale releases, and restores normal calls", async () => { diff --git a/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts b/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts index b2110f065f..aaf1198d1c 100644 --- a/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts +++ b/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts @@ -486,6 +486,51 @@ const support = await getEmbeddedPostgresTestSupport(); await expect(driver.runPlugin(snapshot, pluginId)).rejects.toThrow("Invalid lifecycle result"); }); + it("drives required resource work through setup, pause, resume, and confirmed cleanup", async () => { + const pluginId = randomUUID(); + await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "resource-lifecycle-test", version: "1.0.0", status: "ready", + manifestJson: { agentLifecycle: true, capabilities: ["agents.lifecycle.manage"] } as never }); + let resourcesReady = false; + let resourcesRemoved = false; + const requests: import("@paperclipai/shared").AgentLifecycleRequest[] = []; + const call = async (id: string, method: string, input: import("@paperclipai/shared").AgentLifecycleRequest) => { + expect(id).toBe(pluginId); + expect(method).toBe("agentLifecycle"); + expect(input.companyId).toBe(companyId); + expect((await current(input.agentId)).lifecycleOperation?.hostComplete).toBe(true); + requests.push(input); + const pending = (input.phase === "preparing" && !resourcesReady) || (input.phase === "cleaning_up" && !resourcesRemoved); + return { operationId: input.operationId, version: input.version, status: pending ? "pending" : "complete" }; + }; + const driver = createLifecycleDriver(db, { call } as never); + // Provider/harness execution is stubbed; plugin dispatch and durable transitions are real. + const work = configureAgentLifecycle(db, { ...driver, requiredPluginIds: async () => [pluginId], runHost: async () => "complete" }); + workers.push(work); + const lifecycle = createAgentLifecycle(db); + const agent = await hire(); + await work.process(agent.id); + expect(await current(agent.id)).toMatchObject({ lifecycleState: "preparing", status: "paused", lifecycleError: null }); + const preparation = requests[0]; + resourcesReady = true; + await lifecycle.retry(agent.id); await work.process(agent.id); + expect(requests[1]).toEqual(preparation); + expect((await current(agent.id)).lifecycleState).toBe("ready"); + await lifecycle.pauseAgent(agent.id); await work.process(agent.id); + expect((await current(agent.id)).lifecycleState).toBe("paused"); + await lifecycle.resumeAgent(agent.id); await work.process(agent.id); + expect((await current(agent.id)).lifecycleState).toBe("ready"); + await lifecycle.terminateAgent(agent.id); await work.process(agent.id); + expect((await current(agent.id)).lifecycleState).toBe("cleaning_up"); + await expect(lifecycle.purgeAgent(agent.id)).rejects.toThrow("Complete termination"); + resourcesRemoved = true; + await lifecycle.retry(agent.id); await work.process(agent.id); + expect((await current(agent.id)).lifecycleState).toBe("terminated"); + expect(requests.map(request => request.phase)).toEqual([ + "preparing", "preparing", "verifying", "pausing", "resuming", "terminating", "cleaning_up", "cleaning_up", + ]); + expect(requests.every(request => request.agentId === agent.id)).toBe(true); + }); + it("keeps host completion and required plugin results across a worker restart", async () => { const pluginIds = [randomUUID(), randomUUID()]; const runHost = vi.fn(async () => "complete" as const); diff --git a/ui/src/api/agents.test.ts b/ui/src/api/agents.test.ts index a1ac8e8704..f398a66566 100644 --- a/ui/src/api/agents.test.ts +++ b/ui/src/api/agents.test.ts @@ -34,3 +34,10 @@ describe("agentsApi.retryFailedRun", () => { .rejects.toThrow("Task execution is paused."); }); }); + +it("retries lifecycle work through the company-scoped agent route", async () => { + const result = { id: "agent-1", lifecycleState: "preparing" }; + const post = vi.spyOn(api, "post").mockResolvedValue(result); + await expect(agentsApi.retryLifecycle("agent-1", "company-1")).resolves.toEqual(result); + expect(post).toHaveBeenCalledWith("/agents/agent-1/lifecycle/retry?companyId=company-1", {}); +}); diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index b3dd11025c..da04cf2aed 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -202,6 +202,7 @@ export const agentsApi = { api.delete( agentPath(id, companyId, `/instructions-bundle/file?path=${encodeURIComponent(relativePath)}${baseHash ? `&baseHash=${baseHash}` : ""}`), ), + retryLifecycle: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/lifecycle/retry"), {}), pause: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/pause"), {}), resume: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/resume"), {}), clearError: (id: string, companyId?: string) => diff --git a/ui/src/components/AgentActionButtons.test.tsx b/ui/src/components/AgentActionButtons.test.tsx index b90994ac9a..322f673181 100644 --- a/ui/src/components/AgentActionButtons.test.tsx +++ b/ui/src/components/AgentActionButtons.test.tsx @@ -134,6 +134,29 @@ describe("AgentActionButtons", () => { ); } + it.each(["preparing", "verifying", "resuming"] as const)("offers pause rather than resume during %s", async lifecycleState => { + render(makeAgent({ status: "paused", lifecycleState })); + await flushReact(); + const buttons = Array.from(container.querySelectorAll("button")); + expect(buttons.some(button => button.textContent === "Resume")).toBe(false); + const pause = buttons.find(button => button.textContent === "Pause")!; + expect(pause.disabled).toBe(false); + await act(async () => pause.click()); + await flushReact(); + expect(mockAgentsApi.pause).toHaveBeenCalledWith("agent-1", "company-1"); + }); + + it.each(["pausing", "terminating", "cleaning_up", "terminated", "rejected"] as const)("disables pause and resume during %s", async lifecycleState => { + render(makeAgent({ status: lifecycleState === "pausing" ? "paused" : "terminated", lifecycleState })); + await flushReact(); + const buttons = Array.from(container.querySelectorAll("button")); + expect(buttons.find(button => button.textContent === "Pause")!.disabled).toBe(true); + if (lifecycleState !== "pausing") { + expect(buttons.find(button => button.textContent === "Run Heartbeat")!.disabled).toBe(true); + expect(buttons.find(button => button.textContent === "Assign Task")!.disabled).toBe(true); + } + }); + it("replaces the pause slot with Clear error for error agents", async () => { render(makeAgent({ status: "error" })); await flushReact(); @@ -197,6 +220,20 @@ describe("AgentActionButtons", () => { expect(mockNavigate).toHaveBeenCalledWith("/agents/alpha/runs/run-1"); }); + it.each(["terminating", "cleaning_up"] as const)("keeps the detail page open during %s", async lifecycleState => { + mockAgentsApi.terminate.mockResolvedValue(makeAgent({ status: "terminated", lifecycleState })); + const onTerminateSuccess = vi.fn(); + render(makeAgent(), { onTerminateSuccess }); + await flushReact(); + await act(async () => { container.querySelector('[aria-label="Open actions for Alpha Agent"]')!.click(); }); + await flushReact(); + await act(async () => { Array.from(document.body.querySelectorAll("button")).find(button => button.textContent?.includes("Terminate"))!.click(); }); + await flushReact(); + expect(mockAgentsApi.terminate).toHaveBeenCalledWith("agent-1", "company-1"); + expect(onTerminateSuccess).not.toHaveBeenCalled(); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "detail", "agent-1"] }); + }); + it("calls the terminate success handler after terminating an agent", async () => { const onTerminateSuccess = vi.fn(); render(makeAgent(), { onTerminateSuccess }); diff --git a/ui/src/components/AgentActionButtons.tsx b/ui/src/components/AgentActionButtons.tsx index 6ed44aaeb1..860327ec39 100644 --- a/ui/src/components/AgentActionButtons.tsx +++ b/ui/src/components/AgentActionButtons.tsx @@ -241,7 +241,7 @@ export function AgentActionButtons({ const resolvedCompanyId = companyId ?? agent.companyId; const canonicalAgentRef = agentRouteRef(agent); - const isPaused = agent.status === "paused"; + const isPaused = agent.lifecycleState ? agent.lifecycleState === "paused" : agent.status === "paused"; const isError = agent.status === "error"; const reportError = useCallback( @@ -282,6 +282,8 @@ export function AgentActionButtons({ onActionError?.(null); invalidateAgent(); if (action === "terminate") { + const state = (data as Agent).lifecycleState; + if (state === "terminating" || state === "cleaning_up") return; if (!confirmLateNavigationChanges(agentActionStartedDirtyRef)) return; onTerminateSuccess?.(data as Agent); } @@ -369,8 +371,9 @@ export function AgentActionButtons({ const isPendingApproval = agent.status === "pending_approval"; const disabled = actionsDisabled || agentAction.isPending || providerTraceAction.isPending; - const assignAndRunDisabled = disabled || isPendingApproval || workActionsDisabled; - const pauseResumeDisabled = disabled || isPendingApproval || (isPaused && workActionsDisabled); + const assignAndRunDisabled = disabled || isPendingApproval || workActionsDisabled || agent.status === "terminated"; + const pauseResumeDisabled = disabled || isPendingApproval || (isPaused && workActionsDisabled) + || Boolean(agent.lifecycleState && ["pausing", "terminating", "cleaning_up", "terminated", "rejected"].includes(agent.lifecycleState)); const clearErrorDisabled = disabled; const runtimeConfig = agent.runtimeConfig as Record | null; const runtimeDebug = diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index e649c736a9..57103160fa 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -10,6 +10,8 @@ import { getEnvironmentCapabilities } from "@paperclipai/shared"; import { TooltipProvider } from "@/components/ui/tooltip"; import { ToastProvider } from "../context/ToastContext"; import { AgentConfigForm, AdapterLoginPanel, subtractPersistedOverlay, type AdapterLoginDescriptor } from "./AgentConfigForm"; +import { AgentLifecycleStatus, useAgentLifecycleStatus } from "./AgentLifecycleStatus"; +import { queryKeys } from "../lib/queryKeys"; import { defaultCreateValues } from "./agent-config-defaults"; import { buildNewAgentHirePayload } from "../lib/new-agent-hire-payload"; import { ApiError } from "../api/client"; @@ -19,6 +21,7 @@ import type { AdapterConfigFieldsProps } from "../adapters/types"; import { DEFAULT_CODEX_LOCAL_MODEL } from "@paperclipai/adapter-codex-local"; const mockAgentsApi = vi.hoisted(() => ({ + get: vi.fn(), adapterModels: vi.fn(), detectModel: vi.fn(), list: vi.fn(), @@ -2110,6 +2113,53 @@ describe("AgentConfigForm environment selector", () => { expect(signInAfterSave!.disabled).toBe(false); }); + it("keeps unsaved configuration while a lifecycle poll updates progress", async () => { + const saved = makeAgent({ lifecycleState: "preparing", lifecycleVersion: 1 }); + mockAgentsApi.get.mockResolvedValue(saved); + mockEnvironmentsApi.list.mockResolvedValue([]); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const onSave = vi.fn(); + function Harness() { + const lifecycle = useAgentLifecycleStatus(saved); + return <> + {}} retryPending={false} /> + + ; + } + await act(async () => root.render()); + await flushReact(); + const nameInput = container.querySelector('input[placeholder="Agent name"]')!; + setInputValue(nameInput, "Unsaved name"); + await flushReact(); + let finishPoll!: (agent: Agent) => void; + mockAgentsApi.get.mockReturnValueOnce(new Promise(resolve => { finishPoll = resolve; })); + let poll!: Promise; + await act(async () => { poll = queryClient.refetchQueries({ queryKey: [...queryKeys.agents.detail(saved.id), "lifecycle"] }); }); + // Editing can continue while the request is in flight. + setInputValue(nameInput, "Edited during poll"); + await flushReact(); + await act(async () => { + finishPoll({ ...saved, name: "Another saved name", lifecycleState: "verifying", lifecycleVersion: 2 }); + await poll; + }); + await flushReact(); + expect(mockAgentsApi.get).toHaveBeenCalledWith(saved.id, saved.companyId); + expect(container.textContent).toContain("Verifying agent"); + expect(nameInput.value).toBe("Edited during poll"); + await act(async () => findButton(container, "Save")!.click()); + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: "Edited during poll" })); + mockAgentsApi.get.mockRejectedValueOnce(new Error("Status unavailable")); + await act(async () => { await queryClient.refetchQueries({ queryKey: [...queryKeys.agents.detail(saved.id), "lifecycle"] }); }); + await flushReact(); + expect(container.textContent).toContain("Could not refresh agent lifecycle status"); + expect(nameInput.value).toBe("Edited during poll"); + queryClient.clear(); + }); + it("keeps edits made while the bind save is pending after the agent refresh", async () => { // The bind save runs in the background while the form stays editable. The // agent refresh that follows the save must subtract only what the save diff --git a/ui/src/components/AgentLifecycleStatus.test.tsx b/ui/src/components/AgentLifecycleStatus.test.tsx new file mode 100644 index 0000000000..8e4467f2f4 --- /dev/null +++ b/ui/src/components/AgentLifecycleStatus.test.tsx @@ -0,0 +1,47 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { AgentLifecycleState } from "@paperclipai/shared"; +import { expect, it, vi } from "vitest"; +import { AgentLifecycleStatus, agentLifecycleRefetchInterval } from "./AgentLifecycleStatus"; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +it.each([ + ["preparing", "Preparing agent"], ["verifying", "Verifying agent"], + ["pausing", "Pausing agent"], ["resuming", "Resuming agent"], + ["terminating", "Terminating agent"], ["cleaning_up", "Cleaning up agent"], +] as const)("shows and refreshes %s, including incomplete termination", (lifecycleState, label) => { + const html = renderToStaticMarkup( {}} retryPending={false} />); + expect(html).toContain(label); + expect(html).toContain('role="status"'); + expect(html).toContain('aria-hidden="true"'); + expect(html).not.toContain("Retry"); + expect(agentLifecycleRefetchInterval(lifecycleState)).toBe(2_000); +}); + +it.each([undefined, "ready", "paused", "pending_approval", "terminated", "rejected"] as const)("removes progress for %s while still checking for external changes", lifecycleState => { + expect(renderToStaticMarkup( {}} retryPending={false} />)).toBe(""); + expect(agentLifecycleRefetchInterval(lifecycleState)).toBe(30_000); +}); + +it("retries a failed step once while pending and removes the failure after recovery", async () => { + const container = document.createElement("div"); + const root = createRoot(container); + const retry = vi.fn(); + const agent = { lifecycleState: "cleaning_up" as AgentLifecycleState, lifecycleError: "The lifecycle step failed. Retry the operation." }; + try { + await act(async () => root.render()); + expect(container.textContent).toContain(agent.lifecycleError); + expect(container.textContent).toContain("Termination is not complete"); + await act(async () => container.querySelector("button")!.click()); + expect(retry).toHaveBeenCalledOnce(); + await act(async () => root.render()); + expect(container.querySelector("button")!.disabled).toBe(true); + await act(async () => container.querySelector("button")!.click()); + expect(retry).toHaveBeenCalledOnce(); + await act(async () => root.render()); + expect(container.textContent).toBe(""); + } finally { await act(async () => root.unmount()); } +}); diff --git a/ui/src/components/AgentLifecycleStatus.tsx b/ui/src/components/AgentLifecycleStatus.tsx new file mode 100644 index 0000000000..29a6984da7 --- /dev/null +++ b/ui/src/components/AgentLifecycleStatus.tsx @@ -0,0 +1,62 @@ +import { useQuery } from "@tanstack/react-query"; +import { agentsApi } from "../api/agents"; +import { queryKeys } from "../lib/queryKeys"; +import type { Agent, AgentLifecycleState } from "@paperclipai/shared"; +import { Loader2 } from "lucide-react"; +import { Badge } from "./ui/badge"; +import { Button } from "./ui/button"; + +const phases = { + preparing: ["Preparing agent", "Required resources are being prepared. Work stays queued."], + verifying: ["Verifying agent", "The saved execution configuration is being checked. Work stays queued."], + pausing: ["Pausing agent", "Waiting for execution and required resources to stop."], + resuming: ["Resuming agent", "Required resources are being started. Work stays queued."], + terminating: ["Terminating agent", "Stopping execution before resource cleanup."], + cleaning_up: ["Cleaning up agent", "Required resources are being removed. Termination is not complete."], +} satisfies Partial>; + +function pendingPhase(state?: AgentLifecycleState) { + return state && state in phases ? phases[state as keyof typeof phases] : undefined; +} + +export function agentLifecycleRefetchInterval(state?: AgentLifecycleState) { + return pendingPhase(state) ? 2_000 : 30_000; +} + +/** Poll lifecycle fields without refreshing the record used by editable forms. */ +export function useAgentLifecycleStatus(agent?: Agent) { + return useQuery({ + queryKey: [...queryKeys.agents.detail(agent?.id ?? ""), "lifecycle", agent?.companyId], + queryFn: () => agentsApi.get(agent!.id, agent!.companyId), + enabled: Boolean(agent), + select: current => ({ status: current.status, lifecycleState: current.lifecycleState, + lifecycleVersion: current.lifecycleVersion, lifecycleError: current.lifecycleError }), + refetchInterval: query => agentLifecycleRefetchInterval(query.state.data?.lifecycleState ?? agent?.lifecycleState), + }); +} + +export function AgentLifecycleStatus({ agent, onRetry, retryPending, refreshError = false }: { + agent: Pick; + onRetry: () => void; + retryPending: boolean; + refreshError?: boolean; +}) { + const phase = pendingPhase(agent.lifecycleState); + if (!phase && !refreshError) return null; + return
+ {refreshError &&

Could not refresh agent lifecycle status.

} + {phase &&
+ + {!agent.lifecycleError && + {phase[1]} +
} + {phase && agent.lifecycleError &&
+ {agent.lifecycleError} + +
} +
; +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index ffc57a489a..cb7ea54649 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -44,6 +44,7 @@ import { assetsApi } from "../api/assets"; import { toolsApi } from "../api/tools"; import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters"; import { StatusBadge } from "../components/StatusBadge"; +import { AgentLifecycleStatus, useAgentLifecycleStatus } from "../components/AgentLifecycleStatus"; import { MarkdownBody } from "../components/MarkdownBody"; import { CopyText } from "../components/CopyText"; import { IssueRow } from "../components/IssueRow"; @@ -815,6 +816,7 @@ export function AgentDetail() { queryFn: () => agentsApi.get(routeAgentRef, lookupCompanyId), enabled: canFetchAgent, }); + const lifecycle = useAgentLifecycleStatus(agent); const resolvedCompanyId = agent?.companyId ?? selectedCompanyId; const canonicalAgentRef = agent ? agentRouteRef(agent) : routeAgentRef; const handleLegacyTabChange = useCallback((next: string) => { @@ -984,17 +986,21 @@ export function AgentDetail() { }, [agent?.companyId, selectedCompanyId, setSelectedCompanyId]); // Invoke / pause / resume / terminate / duplicate / reset live in the shared - // AgentActionButtons component. The detail header keeps only "approve" here, - // which is surfaced via the pending-approval banner below. + // AgentActionButtons component. Approval and lifecycle retry stay in the detail header. const agentAction = useMutation({ - mutationFn: async (action: "approve") => { + mutationFn: async (action: "approve" | "retryLifecycle") => { if (!agentLookupRef) return Promise.reject(new Error("No agent reference")); if (action === "approve") { return agentsApi.approve(agentLookupRef, resolvedCompanyId ?? undefined); } + return agentsApi.retryLifecycle(agentLookupRef, resolvedCompanyId ?? undefined); }, - onSuccess: () => { + onSuccess: (_data, action) => { setActionError(null); + if (action === "retryLifecycle") { + queryClient.invalidateQueries({ queryKey: [...queryKeys.agents.detail(agentLookupRef), "lifecycle"] }); + return; + } queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentLookupRef) }); @@ -1160,7 +1166,9 @@ export function AgentDetail() { if (!urlRunId && !urlTab) { return ; } - const isPendingApproval = agent.status === "pending_approval"; + const currentAgent = lifecycle.data && (lifecycle.data.lifecycleVersion ?? 0) >= (agent.lifecycleVersion ?? 0) + ? { ...agent, ...lifecycle.data } : agent; + const isPendingApproval = currentAgent.status === "pending_approval"; const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain"; const pausedEscalationWarning = !hasInvalidOrgChain ? agent.orgChainHealth?.escalationWarning ?? null : null; const showConfigActionBar = ( @@ -1245,7 +1253,7 @@ export function AgentDetail() {
- +

{agent.name}

@@ -1256,6 +1264,7 @@ export function AgentDetail() { {getAdapterDisplay(agent.adapterType === "paperclip_runner" && agent.adapterConfig.provider === "openai_dot" ? "openai_dot" : agent.adapterType).label}· {agent.title || roleLabels[agent.role] || agent.role}
+ agentAction.mutate("retryLifecycle")} retryPending={agentAction.isPending} />
@@ -1273,7 +1282,7 @@ export function AgentDetail() { })} />