## Thinking Path > - Paperclip coordinates agents and controls when they can run. > - Lifecycle plugins prepare and remove resources for each agent. > - Those resources can require private connection credentials. > - The current SDK can resolve configured secrets but cannot save these credentials securely. > - Ordinary company secrets can be bound to agents and do not provide the required server-only boundary. > - This change saves agent-owned instance secrets through the existing local lifecycle result and native secret service. > - The tenant can retain runtime credentials through restarts without a separate encryption system or remote API. ## Linked Issues or Issue Description **Subsystem affected** Agent lifecycle, plugin contract, and native secret storage. **Problem or motivation** Lifecycle plugins need to save private runtime connection details for the owning agent. Worker memory loses these details on restart. Plugin state and company secrets do not provide a private runtime-only boundary. **Proposed solution** Add optional instance-secret changes to lifecycle results. Save them through the existing secret service and local encrypted provider before accepting the result. Require company, agent, plugin, phase, operation, revision, and current unexpired attempt ownership. Supply saved values through the existing local preparation/resume request. **Alternatives considered** A separate encrypted store duplicates the native secret system. Generic plugin company-secret writes proposed by #5007 do not supply agent ownership or a server-only secret scope. This change does not add that general write API. **Roadmap alignment** Extends the existing lifecycle contract from #15631. It does not add a resource provider or select an execution backend. ## What Changed - Add an instance scope to native secrets with mandatory agent/plugin ownership, a company-qualified agent foreign key, and key uniqueness per owner. Existing company and user scopes retain their constraints. - Reuse native creation, rotation, deletion, secret versions, access audit, and the local encrypted provider. Keep values out of company catalogs, normal bindings, agent access, and public agent records. - Accept local secret patches only after locking and checking the current lifecycle operation and required plugin selection. Storage failure or a stale reply cannot complete setup. Expired or replaced attempt owners cannot overwrite a successor's credentials. - Keep vault-import conflicts company-scoped. Protect instance-owned account-home paths during failed device-login cleanup with a private, fail-closed scan. - Skip decryption on stop and cleanup so a lost key cannot block removal. Cascade secret records when their agent or plugin is deleted. - Log fixed provider diagnostics without raw errors that can contain credentials. Document the local contract, runtime resolver, and access-map exclusions. Retain the five latest migration snapshots through the normal generator pruning. ## Verification - 239 focused tests passed across agent-instance-secrets, lifecycle, secrets-service, secrets-routes, plugin-secrets-handler, and agent-device-login-routes. Used disposable PostgreSQL 18 clusters through the existing test constructor seam because the bundled binary cannot start on this macOS host. The temporary harness is outside git. - Regression tests reproduced the vault-import conflict and missed cleanup claimant before the fixes; both pass after the fixes. - New coverage includes encrypted native versions, rotation, restart recovery, company/agent/plugin isolation, ordinary-read and binding denial, stale replies, storage failure, safe logging, cleanup without the master key, and same-name secret isolation, native account-home mutation guards, lease takeover/expiry, and live agent/plugin deletion cascades. - Direct server TypeScript check, DB/shared typechecks, migration numbering/safety checks, module boundaries, and git diff check passed. - Full workspace typecheck and build were attempted; both stop at the missing local cargo toolchain. The full test command was started and stopped after failures in unchanged native-runtime/workspace suites plus bundled-PostgreSQL skips; it did not complete. CI must verify the full toolchain paths. - Current head `c0f8bfd12477ad509252ecd40e168a6c84dbeb36`: all 55 CI checks passed, including the previously canceled e2e shard. Greptile Apex reviewed this exact head at 5/5 with no unresolved threads. GitHub reports the branch mergeable and clean. - No live provider probe, production data access, or deployment. Reviewed the diff for credentials, private references, and run artifacts. ## Risks Apply migration 0339_luxuriant_iron_monger.sql before deploying tenant code. It adds nullable ownership columns, a partial unique index, foreign keys, and a stricter scope constraint. Existing rows remain valid. Keep the schema on application rollback. Preserve the existing secrets master key through deployments and backup restoration. The lifecycle request/result now has a sensitive, server-only field. Plugins must never log it or return it to an agent or browser. Values stay in the tenant database through the existing encryption provider. Trusted runtime consumers must apply normal execution admission before resolving them. Older hosts omit instanceSecrets; plugins that require durable storage must reject those hosts. ## Model Used OpenAI GPT-6 via Codex, with reasoning, repository inspection, code editing, and local test execution. The exact serving model identifier and context window are not exposed in this session. ## 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 (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run 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 any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
17 KiB
Agent lifecycle
The agent lifecycle module controls hiring, pause, resume, and termination.
Its public entry point is server/src/modules/agent-lifecycle/index.ts.
Use its commands to create an agent or change its lifecycle state.
Do not pass a database transaction to a command.
Each hire or transition command owns its transaction.
Application use
Application code imports the configured factory from services/agent-lifecycle.ts.
This service supplies the module dependencies. Use the root database connection.
Apply the existing caller authorization checks before you call a command.
import { createAgentLifecycle } from "../services/agent-lifecycle.js";
const lifecycle = createAgentLifecycle(db);
await lifecycle.updateConfiguration(agentId, { title: "Engineer" });
await lifecycle.pauseAgent(agentId);
const state = await lifecycle.get(agentId);
The pause command records the request. The worker completes the external steps.
Read lifecycleState to check progress.
Configuration updates accept only the fields in AGENT_CONFIGURATION_FIELDS.
They reject identity, company, accounting totals, timestamp, and lifecycle fields.
The public update schema also rejects spend totals. Budget limit changes remain
permitted through budgetMonthlyCents.
Do not import module adapters or construct module dependencies in a caller. The company deletion service uses the separate deletion entry point. The listed configuration workflows use the restricted transaction service. These are the only transaction integrations. They are described below.
States
| State | Meaning | Next state |
|---|---|---|
pending_approval |
The hire needs board approval. | preparing or rejected |
preparing |
Required plugins prepare the agent. | verifying |
verifying |
The host tests the saved harness configuration. | ready |
ready |
The lifecycle permits work. | pausing or terminating |
pausing |
The host stops work. Required plugins stop their resources. | paused |
paused |
A pause hold prevents work. | resuming or terminating |
resuming |
Required plugins start their resources. | The state saved before pause |
terminating |
The host revokes keys and stops work. | cleaning_up |
cleaning_up |
Required plugins remove their resources. | terminated |
terminated |
Termination is complete. | None |
rejected |
The board rejected the hire. | None |
Termination can start before preparation or verification completes. An old result cannot complete a newer operation. Deletion requires completed termination or a rejected hire. The existing restrictions for built-in agents and accounting still apply.
Messages accepted during setup remain queued until verification completes.
After the worker commits ready, its driver immediately asks the scheduler to
dispatch that agent's saved work. The scheduler rechecks company scope,
invokability, budgets, concurrency, and task ownership through normal admission.
If dispatch fails, readiness remains committed and the periodic queue sweep
retries; setup is not repeated because of a scheduler failure.
Native OpenCode verification checks the selected model with the real hello probe. It validates the model identifier but does not first enumerate the full model catalog. Authentication failures, unavailable models, and probe timeouts still prevent readiness. Without an explicit local working directory, the native probe uses a temporary empty directory instead of the server's checkout.
Commands and transactions
requestHire writes the agent record before preparation starts.
An approved hire starts preparation after the transaction commits.
A proposed hire waits for approveHire.
approveHire and rejectHire accept an agent ID or { approvalId }.
Both forms use the same hire decision path. An agent ID also resolves its open
hire approval. The approval and the agent change commit together.
The existing company approval rules still apply.
pauseAgent, resumeAgent, and terminateAgent record the requested change.
They do not wait for external resource operations.
retry makes a failed step available for another attempt.
reconcilePolicyHolds updates budget and company holds for the specified company.
The background policy check processes at most 100 eligible agents each minute.
It continues from the last agent ID and returns to the start after the last page.
Pending hires and agents in termination or a final state are excluded.
An explicit company or budget change still updates all affected agents immediately.
updateAndTransition applies a configuration change and a lifecycle command
in one transaction. A failed command does not save the configuration change.
The onboarding and invitation services own their feature workflows.
They commit the hire through requestHire before they save the other records.
A separate database transaction holds only the workflow lock.
It prevents concurrent requests for the same workflow.
The hire command commits through its own connection and transaction.
A failure in the lock transaction cannot undo a committed hire.
If a later step fails, a retry uses the existing agent.
The onboarding revision is saved only after its content and audit record commit.
The approval service owns comments, revisions, and general approval records. The lifecycle module resolves a hire approval and changes the agent in one transaction. It retains the original credential owner when it approves a hire.
The company deletion service owns the company data cascade.
It requests agent termination before it starts that cascade.
It locks the company in the deletion transaction and removes dependent records.
It then calls agentLifecycleCompanyDeletion.deleteCompanyData(tx, companyId)
through the module's separate company-deletion.ts entry point.
This operation implements the shared CompanyDeletionParticipant interface.
It locks the agent rows, checks their states, and deletes them.
All agents must be terminated or rejected. Otherwise, it throws an error.
The error rolls back all database changes in the deletion transaction.
A concurrent hire makes deletion fail or waits until deletion ends.
The module's delete statement also restricts removal to terminated or rejected agents.
purgeAgent deletes a single agent. It owns the transaction and checks the agent
state before it calls the service to remove dependent records.
The service checks accounting holds. The module then deletes the agent record.
An error rolls back both operations. Neither path permits incomplete termination.
The ordinary agent service owns reads, permissions, and keys.
The configuration service owns validation and configuration revisions.
The lifecycle module owns the agent configuration write.
Credential checks remain in the agent credential service.
Secret proposal approval saves the secret before it applies the agent binding.
If the binding fails, the secret remains approved and the binding remains pending.
A retry uses the approved secret. It does not create another secret.
The binding transaction checks authorization, expiry, and the current reporting
structure again before it changes the agent.
The hire service owns names, appearance, permissions, credential binding,
connection installs, identity setup, and primary-agent selection.
The module calls the hire service before and after the agent record write.
These operations use the lifecycle transaction. A failed operation rolls back
the hire and its dependent records.
The module owns the initial state, approval transition, and lifecycle events.
The service in services/agent-lifecycle.ts supplies these integrations to the
module. The module does not import services or routes. Shared record queries
and validation functions are in lib/. They do not call lifecycle commands.
The application supplies the worker driver at startup.
updateConfiguration owns the transaction for an ordinary configuration update.
The module locks the agent. The configuration service validates and prepares the
patch. The module saves it and invalidates an old verification result when
execution configuration changes during setup. The service then updates dependent
records and saves the configuration revision in the same transaction.
There is no separate invalidation operation for callers.
Configuration writes reject caller-supplied lifecycle fields.
For updateAndTransition, lifecycle owns the transaction and calls the
same configuration operation. Both changes commit together.
Some workflows must save an agent reference and their own records together.
The module exposes configuration.ts for these transactions. Only
services/agent-configuration-transaction.ts can import it.
This service supplies the module integrations. It cannot save a configuration
without the module's invalidation check.
Its permitted callers are secret binding approval, Connection adoption, runner
pairing, avatar updates, instruction revisions, and skill reassignment.
The boundary check lists these callers. Ordinary callers use the root command.
Budget changes always use a root command so enforcement follows the commit.
The transaction integration cannot approve, pause, resume, or terminate an agent.
It does not start a worker before the caller commits. The periodic scan recovers
pending setup work after the commit.
Manual, budget, and company pause holds are independent. A manual resume does not remove a budget or company hold. Restoring a company does not remove a manual hold. Budget and company policy still prevent admission while the pause work runs.
The legacy status field remains available to existing clients.
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.
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
A plugin can declare agentLifecycle: true in its manifest.
It must also request agents.lifecycle.manage.
The plugin implements onAgentLifecycle in the SDK.
This method is separate from event delivery and event acknowledgment.
The host selects the required plugins before the first lifecycle step.
setRequiredPlugins stores their IDs on the agent.
The host is not a plugin ID. Each operation records host completion and
completed plugin IDs separately. The host step must finish before plugin calls.
A disabled or removed plugin cannot silently release an existing requirement.
Restore that plugin to complete the operation.
A later plugin installation does not change an existing selection.
Each request contains these fields:
companyIdandagentIdidentify the agent.phaseidentifies the lifecycle step.operationIdidentifies the operation.versionidentifies the state revision.
Return the same operationId and version with status: "complete" or
status: "pending".
Return complete only after the requested effect is complete.
Return pending while external work continues.
Throw an error when the step fails.
The host stores a fixed error message. It does not store the provider error text.
Server logs identify the failed step, phase, operation, and plugin when available.
They include only known error codes. Unknown codes become unclassified.
They do not include provider messages, stacks, or configuration values.
A failure before the agent is read can have only a step and agent ID.
A scan failure has no agent ID.
Calls can repeat after a timeout or a server restart. A plugin must make repeated calls safe. It must retain the highest version for each company and agent. It must reject an older request after it accepts a newer version. It must also prevent an older operation from creating resources after cleanup. Serialize external effects or remove resources from a late completion. Do not treat a host timeout as cancellation of the external effect.
Local instance secrets
Lifecycle requests include instanceSecrets, a map of this plugin's private
credentials for this company and agent. A plugin can return an instanceSecrets
map with its result to save values. A null value removes the key. Omitted keys
remain unchanged. Writes are allowed during preparation and resume; removals can
also occur during stop and cleanup. Use at most 16 keys per result, with names
matching [a-z][a-z0-9_]{0,63} and values no larger than 64 KiB each.
The tenant persists the changes before accepting the result. It locks the agent and checks its company, phase, operation, revision, required plugin selection, and the captured attempt's current lease owner and unexpired deadline. A stale reply or a failed secret write cannot complete preparation. Calls after a restart receive the saved values. Stop and cleanup requests receive an empty map, so a damaged credential cannot block removal. Return removals even while cleanup is pending. An older host omits the field; plugins that require durable credentials must reject that host before resource effects.
This uses the existing secret service, company_secrets, secret versions, and
local_encrypted provider. There is no new encryption system or remote API.
The instance scope requires an owning agent and plugin. Agent/plugin deletion
cascades the native secret records. These secrets are excluded from company
catalogs, normal secret references, agent access, plugin state, and public agent
records. The general SDK ctx.secrets.resolve() remains read-only. Company vault imports
ignore instance-owned names and keys. Device-login cleanup checks instance-owned
account-home claims privately and keeps the directory if a value cannot be read.
Trusted server runtime code can resolve values with
agentInstanceSecretService(db).read({ companyId, agentId, pluginId }) after its
normal execution admission checks. Use the installed plugin's database ID.
Never place returned values in an agent environment, task payload, log, or UI
response. Preserve the existing secret master key through restarts and backups.
Migration 0339_luxuriant_iron_monger.sql adds native secret ownership and its
scope constraint; keep these columns when reverting application code.
Verification and recovery
The harness verification service uses the existing environment-test code. It owns test configuration, credential selection, and test result checks. It tests the saved agent configuration and the selected environment. A failed login or an incomplete probe cannot complete verification. The lifecycle module receives completion, pending work, or failure through its driver. It owns the state transition and has no harness test rules. The current UI can also run a test before the hire. Some adapters can charge for these tests. It uses the saved responsible user for managed credentials. The host does not grant new credential access for this test. A connection pool selects an account for a separate lifecycle test operation. An external controller must complete its existing readiness test.
Task requests can enter the durable run queue during preparation, verification, and resume. Execution waits for readiness. Queue recovery does not require periodic agent heartbeats to be enabled.
A cancelled run record does not prove that execution stopped. The host also checks process ownership, controller leases, and environment cleanup. Required plugin cleanup cannot start while these checks are incomplete.
The worker starts after a committed command. A periodic scan recovers work after a process stops. A database lease permits one active attempt for each agent. The worker renews that lease during external calls. Each result must match the current revision and lease owner. Shutdown waits for active work.
The agent API returns lifecycleState, lifecycleVersion, and lifecycleError.
A board user can retry through POST /api/agents/:id/lifecycle/retry.
The route applies the existing company access checks.
Existing installations
Migration 0320_daily_onslaught.sql maps the current status of each agent.
Active execution states become ready.
Paused, pending, and terminated agents retain their current meaning.
The migration retains each existing pause reason as a hold.
It does not provision external resources or record a successful harness test.
Resource backfill is a separate task.
Run the normal database migration process before starting the new server. Stop older server processes during the change. An older process can write the legacy status without the new lifecycle checks.
pnpm check:module-boundaries checks the module imports and agent writes.
Tests and migration fixtures can write records directly.
Production creation and lifecycle state writes must use the lifecycle module.
Production record deletion must also use the lifecycle module.
Execution configuration writes must use the module. The offline worktree seed
command can disable timers in a copied database before the server starts.
Extension rules
Add a lifecycle command to the module entry point. Put state transition rules in
domain/policy.ts. Put database operations in the module adapters.
Keep harness and provider rules in services. Supply external work through the
worker driver. Report completion only after that work is complete.
Add a test for a failed or repeated operation. Run pnpm check:module-boundaries
to check the import and write boundaries.