Use the agent lifecycle contract for plugin setup and status (#15391)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Some plugins must prepare resources before an agent can work.
> - Those resources must stop or be removed when the agent pauses or
terminates.
> - The agent lifecycle module now owns these steps and their durable
completion.
> - Plugins use its required callback contract instead of a separate
readiness query.
> - This PR documents that contract and shows its progress and retry
action on the agent page.

## Linked Issues or Issue Description

Refs: #15631

**Subsystem affected**

Required plugin lifecycle work and the agent detail page.

**Problem or motivation**

The lifecycle module exposes `onAgentLifecycle` and
`agents.lifecycle.manage`, but the plugin authoring guide does not
explain their use. The agent page displays legacy status. Setup
therefore looks paused and offers Resume. Termination leaves the page
before resource cleanup completes.

**Proposed solution**

Use the callback, admission guards, persisted state, and retry route
supplied by the lifecycle module. Show pending phases and fixed host
errors. Keep the detail page open during incomplete termination. Use
lifecycle state for pause and resume controls.

**Alternatives considered**

A separate plugin readiness query would duplicate the module's state and
admission rules. The final diff contains no readiness API, database
commit hooks, or second admission check.

## What Changed

- Document the manifest declaration, callback results, phase ordering,
capability scope, retry behavior, and provider revision requirements.
- Correct SDK comments for lifecycle, routing, and external object
callbacks.
- Show preparation, verification, pause, resume, termination, and
cleanup progress from the existing agent fields.
- Refresh pending steps every two seconds and other states every thirty
seconds. Keep these reads separate from the agent record used by
editable forms. Use the existing authorized retry route for failed
steps.
- Offer Pause during setup. Disable pause and resume while stop or
cleanup is incomplete. Keep cleanup failures reachable on the detail
page. Disable new work after termination starts.
- Test required resource work across setup, pause, resume, and confirmed
deletion. Test every worker callback phase, missing handlers, retry
authorization, and UI behavior.

## Verification

- Focused UI tests: 139 passed across lifecycle status, action buttons,
API tests, and the configuration form. A regression preserves unsaved
edits during an in-flight lifecycle refresh and a failed refresh.
- Focused host and shared tests: 113 passed across lifecycle
integration, retry authorization, plugin RPC, and manifest validation.
The database suite used isolated local PostgreSQL 18 through a temporary
loader outside the repository.
- Module boundary tests: 11 passed. Module import/write checks and UI
token gates passed.
- Plugin SDK build, server direct TypeScript check, and UI TypeScript
check passed.
- `git diff --check` and an added-line secrets/private-reference scan
passed. Fixtures use synthetic identities.
- Full `pnpm -r typecheck` and `pnpm build` passed using the existing
local Rust toolchain.
- Visually checked all six progress phases with the application
components and styles. Captured preparation and cleanup failure
screenshots from the production UI build with synthetic API data;
artifacts are kept outside git.
- Full local `pnpm test:run` was attempted and stopped after a
native-runner failure reproduced in isolation:
`native-session-resume.test.ts` / `archives a damaged prior epoch before
completing guarded same-task replacement with real runnerd` returns a
failed Codex fixture result instead of success. The native-runtime and
runner trees are unchanged from the base. No full local test pass is
claimed.
- All latest-head CI checks passed, including test shards, verification,
E2E, and the canary packaging check. Greptile reviewed commit
`96473e03a` at 5/5 with no unresolved comments.

## Risks

- Selected required plugins remain a dependency for lifecycle
completion. The lifecycle module retains their requirements when a
plugin is disabled or removed.
- Plugins must make repeated provider operations safe and fence older
revisions. Host revision checks do not cancel external requests.
- Polling adds one agent-detail read every two seconds during pending
work and every thirty seconds otherwise.
- No schema change, private VM provider implementation, resource
backfill, or deployment is included. No live provider test was run.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository inspection, code
editing, and command execution. The exact model revision and context
window were not reported by the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either linked existing issues or described the issue in-PR
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal
ticket id
- [x] I have run focused tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented risks
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no unresolved comments
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip authored and GitHub committed 2026-10-09 11:15:24 -07:00
1 parent 556daf9a99
commit 5badea4798
15 files changed
+388 -19

No files matched your search

+6 -1
View File
@@ -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
+57
View File
@@ -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
+11 -5
View File
@@ -352,17 +352,23 @@ export interface PluginDefinition {
params: DetectExternalObjectsParams,
): Promise<DetectExternalObjectsResult>;
/**
* 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<AgentLifecycleResult>;
/** Propose a member from host-authorized candidates. Requires ai.connections.route. */
onRouteAiConnection?(params: AiConnectionRouterRequest): Promise<AiConnectionRouterResult>;
/**
* 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<AgentLifecycleResult>;
onRouteAiConnection?(params: AiConnectionRouterRequest): Promise<AiConnectionRouterResult>;
onResolveExternalObject?(
params: ResolveExternalObjectParams,
): Promise<PluginExternalObjectResolveResult>;
+1 -1
View File
@@ -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: {
@@ -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<typeof import("../services/agent-lifecycle.js")>();
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();
});
});
+10 -2
View File
@@ -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 () => {
@@ -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);
+7
View File
@@ -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", {});
});
+1
View File
@@ -202,6 +202,7 @@ export const agentsApi = {
api.delete<AgentInstructionsBundle>(
agentPath(id, companyId, `/instructions-bundle/file?path=${encodeURIComponent(relativePath)}${baseHash ? `&baseHash=${baseHash}` : ""}`),
),
retryLifecycle: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/lifecycle/retry"), {}),
pause: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/pause"), {}),
resume: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/resume"), {}),
clearError: (id: string, companyId?: string) =>
@@ -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<HTMLButtonElement>('[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 });
+6 -3
View File
@@ -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<string, unknown> | null;
const runtimeDebug =
@@ -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 <>
<AgentLifecycleStatus agent={lifecycle.data ?? saved} refreshError={lifecycle.isError} onRetry={() => {}} retryPending={false} />
<AgentConfigForm mode="edit" agent={saved} onSave={onSave} hidePromptTemplate showAdapterTypeField={false} />
</>;
}
await act(async () => root.render(<QueryClientProvider client={queryClient}><ToastProvider><TooltipProvider><Harness /></TooltipProvider></ToastProvider></QueryClientProvider>));
await flushReact();
const nameInput = container.querySelector<HTMLInputElement>('input[placeholder="Agent name"]')!;
setInputValue(nameInput, "Unsaved name");
await flushReact();
let finishPoll!: (agent: Agent) => void;
mockAgentsApi.get.mockReturnValueOnce(new Promise<Agent>(resolve => { finishPoll = resolve; }));
let poll!: Promise<void>;
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
@@ -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(<AgentLifecycleStatus agent={{ lifecycleState, lifecycleError: null }} onRetry={() => {}} 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(<AgentLifecycleStatus agent={{ lifecycleState, lifecycleError: null }} onRetry={() => {}} 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(<AgentLifecycleStatus agent={agent} onRetry={retry} retryPending={false} />));
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(<AgentLifecycleStatus agent={agent} onRetry={retry} retryPending />));
expect(container.querySelector("button")!.disabled).toBe(true);
await act(async () => container.querySelector("button")!.click());
expect(retry).toHaveBeenCalledOnce();
await act(async () => root.render(<AgentLifecycleStatus agent={{ lifecycleState: "terminated", lifecycleError: null }} onRetry={retry} retryPending={false} />));
expect(container.textContent).toBe("");
} finally { await act(async () => root.unmount()); }
});
@@ -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<Record<AgentLifecycleState, [string, string]>>;
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<Agent, "lifecycleState" | "lifecycleError">;
onRetry: () => void;
retryPending: boolean;
refreshError?: boolean;
}) {
const phase = pendingPhase(agent.lifecycleState);
if (!phase && !refreshError) return null;
return <div role="status" aria-live="polite" className="space-y-2 text-sm">
{refreshError && <p className="text-destructive">Could not refresh agent lifecycle status.</p>}
{phase && <div className="flex flex-wrap items-center gap-2">
<Badge variant={agent.lifecycleError ? "destructive" : "secondary"}>
{!agent.lifecycleError && <Loader2 aria-hidden="true" className="mr-1 size-3 animate-spin motion-reduce:animate-none" />}
{phase[0]}
</Badge>
<span className="text-muted-foreground">{phase[1]}</span>
</div>}
{phase && agent.lifecycleError && <div className="flex flex-wrap items-center gap-2 text-destructive">
<span>{agent.lifecycleError}</span>
<Button variant="outline" size="sm" disabled={retryPending} onClick={onRetry}>
{retryPending ? "Retrying…" : "Retry"}
</Button>
</div>}
</div>;
}
+16 -7
View File
@@ -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 <Navigate to={agentDetailHref(canonicalAgentRef)} replace />;
}
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() {
<header className="flex flex-wrap items-center justify-between gap-5 border-b border-border pb-6">
<div className="flex min-w-0 items-center gap-4">
<div role="img" aria-label={`${agent.name} avatar`} className="shrink-0">
<AgentCharacter agent={agent} state={characterStateForAgent(agent.status)} size={96} trackingScope="page" />
<AgentCharacter agent={agent} state={characterStateForAgent(currentAgent.status)} size={96} trackingScope="page" />
</div>
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2"><h1 className="truncate text-2xl font-semibold tracking-tight">{agent.name}</h1><PrimaryAgentIndicator agentId={agent.id} companyId={agent.companyId} /></div>
@@ -1256,6 +1264,7 @@ export function AgentDetail() {
<span>{getAdapterDisplay(agent.adapterType === "paperclip_runner" && agent.adapterConfig.provider === "openai_dot" ? "openai_dot" : agent.adapterType).label}</span><span>·</span>
<span>{agent.title || roleLabels[agent.role] || agent.role}</span>
</div>
<AgentLifecycleStatus agent={currentAgent} refreshError={lifecycle.isError} onRetry={() => agentAction.mutate("retryLifecycle")} retryPending={agentAction.isPending} />
<SetPrimaryAgentButton agent={agent} />
</div>
</div>
@@ -1273,7 +1282,7 @@ export function AgentDetail() {
})}
/>
<AgentActionButtons
agent={agent}
agent={currentAgent}
companyId={resolvedCompanyId}
assignLabel="Assign Task"
showStatus={false}