mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user