mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-12 08:19:54 +02:00
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:
1 parent
556daf9a99
commit
5badea4798
15 files changed
+388
-19
No files matched your search
@@ -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,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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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", {});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user