From 43be3c93ddb1423cdbda504082a4081ca0a2112d Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Mon, 26 Oct 2026 10:23:27 -0700 Subject: [PATCH] Preserve creator retry access while draft computers stop Treat stopping setup drafts as creator-configurable so users with agent creation permission can retry cleanup and update their selection. Keep hire completion blocked until stopping finishes, and preserve other users configuration denials. Verified all 66 lifecycle and policy tests, server TypeScript, and map checks. The new route case covers owner success, other-creator denial, and premature completion refusal. Co-Authored-By: Paperclip --- maps/agent-access-map/agents.md | 2 +- .../__tests__/lifecycle.test.ts | 24 +++++++++++++++++++ .../agent-lifecycle/adapters/records.ts | 2 +- .../modules/agent-lifecycle/domain/policy.ts | 2 +- 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/maps/agent-access-map/agents.md b/maps/agent-access-map/agents.md index 52516eeb486..f091a126d4e 100644 --- a/maps/agent-access-map/agents.md +++ b/maps/agent-access-map/agents.md @@ -59,7 +59,7 @@ Normal agent-authored hiring keeps its existing approval policy. An agent-owned plugin may prepare the draft and leave its lifecycle in `verifying` while that setup hold remains. Only bounded Core-issued setup probes can run there. The creator's company creation permission also covers configuring -and retrying their draft during preparation; other actors still require the +and retrying their draft during preparation or stopping; other actors still require the ordinary configuration permission. This is a board wizard path, not new agent execution or probe authority. Lifecycle and component regressions cover these transitions; no permitted-agent or live-provider journey is claimed here. diff --git a/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts b/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts index 49946277268..aa21b2e887a 100644 --- a/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts +++ b/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts @@ -233,6 +233,30 @@ const support = await getEmbeddedPostgresTestSupport(); expect((await current(setup.id)).lifecycleHolds).toEqual(["setup"]); }); + it("lets only the setup creator configure and retry a stopping draft with creation permission", async () => { + await db.insert(companyMemberships).values(["setup-owner", "setup-other"].map(principalId => ({ companyId, + principalType: "user", principalId, membershipRole: "member" }))); + await db.insert(principalPermissionGrants).values(["setup-owner", "setup-other"].map(principalId => ({ companyId, + principalType: "user", principalId, permissionKey: "agents:create" }))); + const lifecycle = createAgentLifecycle(db); + const draft = await lifecycle.requestHire(companyId, { name: "Stopping setup", adapterType: "process" }, + { setupOnly: true, createdByUserId: "setup-owner" }); + await db.update(agents).set({ lifecycleState: "pausing" }).where(eq(agents.id, draft.id)); + function app(userId: string) { + const value = express(); value.use(express.json()); + value.use((req, _res, next) => { req.actor = { type: "board", source: "session", userId, + companyIds: [companyId], memberships: [{ companyId, membershipRole: "member", status: "active" }] }; next(); }); + value.use("/api", agentRoutes(db)); value.use(errorHandler); return value; + } + const updated = await request(app("setup-owner")).patch(`/api/agents/${draft.id}`).send({ runtimeConfig: { heartbeat: { enabled: false } } }); + expect(updated.status, JSON.stringify(updated.body)).toBe(200); + await request(app("setup-owner")).post(`/api/agents/${draft.id}/lifecycle/retry`).send({}).expect(200); + await request(app("setup-other")).patch(`/api/agents/${draft.id}`).send({ runtimeConfig: { heartbeat: { enabled: true } } }).expect(403); + await request(app("setup-other")).post(`/api/agents/${draft.id}/lifecycle/retry`).send({}).expect(403); + await expect(lifecycle.requestHire(companyId, { name: draft.name }, { setupAgentId: draft.id })).rejects.toThrow("no longer available"); + expect((await current(draft.id)).lifecycleState).toBe("pausing"); + }); + it("creates a board-owned setup before requesting approval for its final configuration", async () => { await db.update(companies).set({ requireBoardApprovalForNewAgents: true }).where(eq(companies.id, companyId)); await db.insert(companyMemberships).values(["board", "other-creator"].map(principalId => ({ companyId, principalType: "user", principalId, membershipRole: "member" }))); diff --git a/server/src/modules/agent-lifecycle/adapters/records.ts b/server/src/modules/agent-lifecycle/adapters/records.ts index 176894eeb7b..a6ccd8a0dbe 100644 --- a/server/src/modules/agent-lifecycle/adapters/records.ts +++ b/server/src/modules/agent-lifecycle/adapters/records.ts @@ -26,7 +26,7 @@ export function agentRecords(db: Db, effects: LifecycleEffects) { if (!existing) throw notFound("Agent setup not found"); return existing; } - if (setup && (!isAgentSetupDraft(setup) || !setup.lifecycleHolds.includes("setup"))) { + if (setup && (!isAgentSetupDraft(setup) || setup.lifecycleState === "pausing" || !setup.lifecycleHolds.includes("setup"))) { throw conflict("Agent setup is no longer available"); } if (options?.setupAgentId && !setup) throw notFound("Agent setup not found"); diff --git a/server/src/modules/agent-lifecycle/domain/policy.ts b/server/src/modules/agent-lifecycle/domain/policy.ts index 458c1d729b5..043206479bf 100644 --- a/server/src/modules/agent-lifecycle/domain/policy.ts +++ b/server/src/modules/agent-lifecycle/domain/policy.ts @@ -6,7 +6,7 @@ export class AgentLifecycleConflict extends Error { } export function isAgentSetupDraft(agent: { lifecycleState?: string; pauseReason?: string | null }) { - return agent.pauseReason === "setup" && ["paused", "preparing", "verifying"].includes(agent.lifecycleState ?? ""); + return agent.pauseReason === "setup" && ["paused", "preparing", "verifying", "pausing"].includes(agent.lifecycleState ?? ""); } export function canConfigureAgentConnection(agent: { lifecycleState: string; status: string }): boolean {