Files
PaperClipAI/server/src/routes/instance-settings.ts
T
Devin FoleyandPaperclip 3367b75ccc fix: fence accepted work and cleanup before idle sleep (#15522)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A host can stop an idle instance to reduce unused compute.
> - Zero active runs do not prove that requests, saved work, or cleanup
are complete.
> - A client can disconnect while its request still writes, and failed
cleanup can remain only in memory or on disk.
> - This pull request holds new admission and checks accepted work,
cleanup, and persisted work under an owned drain.
> - The host gets an empty report only while those checks remain valid.

## Linked Issues or Issue Description

Refs #13413. That companion PR uses the same owned-hold vocabulary for
runtime services. This PR covers HTTP requests, scheduler work,
accounting, cleanup, and the instance work inventory. It does not
include the preview gateway changes.

**What existing behavior does this improve?**

The instance task-drain API reports process counters. It does not prove
that a host can safely stop the instance for idle sleep.

**Current behavior**

A quiet run set can coexist with an unfinished request, cleanup after a
completed run, future work, or a failed accounting write.

**Proposed behavior**

Provide a bounded owned idle hold. Block new ingress, track accepted
handler promises, inspect durable and local work, and return `none` only
when the same hold stays quiet through the checks. Keep normal
deployment drains compatible.

**Reason and benefit**

Hosts can identify eligible idle instances without treating a disconnect
or failed cleanup write as completed work.

## What Changed

- Add `purpose: "idle"`, a bounded TTL, unique owners, and owner-checked
release to task drain. Existing holds cannot be replaced by another API
request.
- Gate HTTP ingress before parsers, auth, webhooks, and MCP. Gate new
WebSocket upgrades. Track async handlers in nested Express routers and
error middleware until they settle, even after the response or client
disconnect.
- Count accepted live-event WebSocket authentication through settlement,
even after disconnects. Count detached built-in agent, managed-home and
runtime-service startup reconciliation after readiness.
- Keep health and control mutations tracked. Count control-request
authentication separately from the read-only report, including
concurrent user/company/membership writes.
- Pause new scheduler admissions during idle holds. Count work already
in flight, including database backups, and reject scans whose work
generation changes. Periodic backups block sleep without a host wake
schedule.
- Inspect accounting and orphan-cleanup spool directories without
skipping temporary or malformed entries. Retain orphan tokens through
queue splices, flush failures, and buffer overflow. Keep failed usage
capture counted until its database failure fence is written.
- Check persisted work across companies in a bounded read-only
transaction. Include deferred agent-file cleanup and saved watchdogs
whose watched issues are complete. Enabled plugins and unsupported
retained work remain blockers.
- Require the exact idle owner and completed startup tracking before
returning an empty report. Keep reports free of tenant details.
- Document the hosting protocol, retry behavior, conservative blockers,
and the remaining external provider-stop race.

## Verification

- Current head: `8d9a599b732c2047b8c671d4799065d7c90a3567`, rebased on
master `941a3fa991aeb97eb1ac390c65b7973b5f6de1ad`. Both heartbeat helper
extractions are preserved. GitHub confirms no merge conflicts.
- Focused heartbeat renderer/run-log, drain, control-auth, admission,
route and PostgreSQL inventory checks: 234 tests passed in 10 suites
after the rebase.
- The POST task-drain contract includes the expected `409` conflict
response. The 84 OpenAPI and instance-settings route tests and server
typecheck passed after that final documentation fix.
- Final accepted-upgrade/startup regression run: 104 tests passed in
five suites, including success and failure after readiness or
disconnect. Final server typecheck also passed.
- `pnpm -r typecheck`: passed.
- `pnpm build`: passed.
- `pnpm install --frozen-lockfile` and the PR diff secret scan passed.
No dependency or lockfile changes are added by this PR.
- The prior verification covered HTTP disconnects and early responses,
async error handlers, concurrent authentication writes, signed
bootstrap, saved watchdogs, backup promises, orphan cleanup, spools and
failed accounting fences. Those tests passed.
- The last full local `pnpm test:run` stopped in the general-server
phase with 16,658 passing tests and five skill/connector fixture
failures caused by an ancestor workspace skill directory. That full
local run preceded this rebase and has not been repeated for the import
conflict. Full current-head CI passed: 53 successful checks and two
conditional skips, with no failures.
- All eight review findings are fixed and their threads resolved,
including accepted upgrade authentication, detached startup writes and
the POST conflict contract. Current-head Apex review is 5/5 with no new
findings; all eight review threads remain resolved.
- No live provider stop or production change was performed.

## Risks

- The hosting controller must use the owned protocol and hold external
admission through its final validation and provider stop. A legacy drain
cannot authorize idle sleep. During an idle hold, new requests receive
503 with `Retry-After: 1`; the host must handle queueing or retry before
enabling this path.
- This is a single-process protocol. An unexpected restart after the
last validation can race an external provider stop. The host must
serialize deploy/wake/sleep operations and bind the validation to the
instance it stops. Multiple replicas need shared fencing.
- Some retained state conservatively prevents sleep, including every
enabled plugin. This PR does not promise that every inactive instance
becomes eligible.
- The HTTP adapter uses Express 5 router layers. Real Express tests
cover nested routes, errors and disconnects. New routes must register
before tracking is installed. Detached work must have durable state or
explicit work tracking.
- Unrecoverable in-memory cleanup debt keeps the instance awake until
reconciliation. This change does not make such debt survive an unplanned
process crash. No schema migration or provider configuration change is
included.

## Model Used

OpenAI GPT-6 via Codex, with reasoning, repository inspection, tool use
and code execution. The exact deployment variant/model ID 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 and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run focused tests locally and they pass; the full local
fixture limitation and clean full CI result are documented above
- [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>
2026-10-08 08:35:45 -07:00

507 lines
21 KiB
TypeScript

import { trackIdleWork } from "../services/task-admission.js";
import { Router, type Request } from "express";
import { companies, type Db } from "@paperclipai/db";
import {
patchInstanceSettingsSchema,
patchInstanceExperimentalSettingsSchema,
patchInstanceGeneralSettingsSchema,
startTaskDrainRequestSchema,
} from "@paperclipai/shared";
import { conflict, forbidden } from "../errors.js";
import {
cloudTenantPrimaryCompanyId,
getCloudStackContext,
isCloudManagedInstance,
} from "../services/cloud-instance.js";
import { getHiddenSettings } from "../services/settings-visibility.js";
import { readIdleSleepSafety } from "../services/idle-sleep-safety.js";
import { validate } from "../middleware/validate.js";
import { logger } from "../middleware/logger.js";
import {
companyService,
heartbeatService,
instanceSettingsService,
logActivity,
publishActivity,
type ActivityPublication,
} from "../services/index.js";
import { environmentService } from "../services/environments.js";
import { assertEnvironmentSelectionForCompany } from "./environment-selection.js";
import { assertBoardOrgAccess, getActorInfo } from "./authz.js";
function sameJsonValue(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
if (Array.isArray(a) || Array.isArray(b)) {
return (
Array.isArray(a)
&& Array.isArray(b)
&& a.length === b.length
&& a.every((value, i) => sameJsonValue(value, b[i]))
);
}
const aKeys = Object.keys(a);
const bKeys = new Set(Object.keys(b));
return aKeys.length === bKeys.size && aKeys.every((key) =>
bKeys.has(key) && sameJsonValue((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]),
);
}
/**
* Floor writes to operator-hidden settings. Same-value writes pass so clients
* that echo a full GET response keep working (the executionMode precedent);
* only a write that would actually change a hidden setting is rejected.
*/
async function assertNoHiddenSettingChanges(
body: Record<string, unknown>,
getCurrent: () => Promise<object>,
isHiddenField: (field: string) => boolean,
) {
const hiddenKeys = Object.keys(body).filter(isHiddenField);
if (hiddenKeys.length === 0) return;
const current = (await getCurrent()) as Record<string, unknown>;
for (const key of hiddenKeys) {
if (sameJsonValue(body[key], current[key])) continue;
throw forbidden(`${key} is managed by the hosting operator on this instance`, {
code: "settings_operator_managed",
});
}
}
/**
* Publish activity events for an already-committed mutation. The audit row
* exists in the database no matter what happens here, so a publish failure
* must not turn into a route error: that would report the mutation as
* failed to the caller when it in fact succeeded. Log and swallow instead.
*/
function publishActivitiesBestEffort(publications: ActivityPublication[], action: string) {
for (const publication of publications) {
try {
publishActivity(publication);
} catch (err) {
logger.error({ err, action, companyId: publication.companyId }, "failed to publish activity event");
}
}
}
function assertCanManageInstanceSettings(req: Request) {
if (req.actor.type !== "board") {
throw forbidden("Board access required");
}
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) {
return;
}
throw forbidden("Instance admin access required");
}
// A task-drain start or stop reads the live drain state, writes an audit
// transaction, and only then mutates the process-local drain state. The
// audit write is an async gap: two overlapping requests can commit their
// transactions in one order but reach the in-memory mutation in the other
// order, so a stale transition would win, the audit log would not match the
// live state, and the response for the newer request would not match what
// actually ended up live. Run each request's whole read-audit-apply
// sequence through this queue so overlapping requests execute one at a
// time, in the order they enter it: audit order and apply order then always
// agree, and each response reports exactly the state its own request
// produced.
let taskDrainTransitionQueue: Promise<void> = Promise.resolve();
function withTaskDrainTransition<T>(run: () => Promise<T>): Promise<T> {
const turn = trackIdleWork(taskDrainTransitionQueue.then(run));
// Normalize to a settled void promise for the next caller in line, so a
// rejected transition (a failed audit write, for example) cannot wedge
// every later transition behind it.
taskDrainTransitionQueue = turn.then(
() => undefined,
() => undefined,
);
return turn;
}
export function instanceSettingsRoutes(db: Db) {
const router = Router();
const svc = instanceSettingsService(db);
const environments = environmentService(db);
const heartbeat = heartbeatService(db);
router.get("/instance/settings", async (req, res) => {
assertBoardOrgAccess(req);
res.json(await svc.get());
});
router.patch(
"/instance/settings",
validate(patchInstanceSettingsSchema),
async (req, res) => {
assertCanManageInstanceSettings(req);
if (Object.prototype.hasOwnProperty.call(req.body, "defaultEnvironmentId")) {
await assertEnvironmentSelectionForCompany(
environments,
"instance",
typeof req.body.defaultEnvironmentId === "string" ? req.body.defaultEnvironmentId : null,
);
}
// An explicit tenant write of the instance default reclassifies its
// attribution: whatever the default becomes — including a deliberate
// re-selection of the managed sandbox row — it is tenant-chosen, so
// the reconciliation stamp marker must not survive to let a later
// managed-sandbox-only mode-off pass mistake the tenant's choice for a
// stamp and revert it. The marker clear and the settings write commit
// in ONE transaction, so no partial failure can desync attribution
// from the default (neither a stale stamp on a tenant choice, nor a
// reconciliation default that lost its marker and can never revert).
const writesDefault = Object.prototype.hasOwnProperty.call(req.body, "defaultEnvironmentId");
const managedSandbox = writesDefault
? await environments.findManagedSandboxEnvironment(undefined, { includeArchived: true })
: null;
const updated = await db.transaction(async (tx) => {
if (managedSandbox?.metadata?.managedDefaultStamped === true) {
const { managedDefaultStamped: _cleared, ...remainingMetadata } = managedSandbox.metadata;
await environments.update(managedSandbox.id, { metadata: remainingMetadata }, { db: tx });
}
return svc.update(req.body, { db: tx });
});
const actor = getActorInfo(req);
const companyIds = await svc.listCompanyIds();
await Promise.all(
companyIds.map((companyId) =>
logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "instance.settings.updated",
entityType: "instance_settings",
entityId: updated.id,
details: {
defaultEnvironmentId: updated.defaultEnvironmentId,
changedKeys: Object.keys(req.body).sort(),
},
}),
),
);
res.json(updated);
},
);
router.get("/instance/settings/general", async (req, res) => {
// General settings (e.g. feedbackDataSharingPreference) are readable by any
// authenticated org member or instance admin. Only PATCH requires instance-admin.
assertBoardOrgAccess(req);
res.json(await svc.getGeneral());
});
router.patch(
"/instance/settings/general",
validate(patchInstanceGeneralSettingsSchema),
async (req, res) => {
assertCanManageInstanceSettings(req);
// Floor: on cloud-managed instances the execution mode is pinned by the
// platform (the execution-policy bootstrap writes it at boot). No
// instance admin — including a computed owner-admin — may change it: a
// forced provider switch would strand runs on a provider the platform
// never provisioned. Same-value writes pass so settings forms that echo
// the full general-settings object keep working. Absent and "any" both
// mean unrestricted, so they compare equal.
if (
isCloudManagedInstance() &&
Object.prototype.hasOwnProperty.call(req.body, "executionMode")
) {
const current = await svc.getGeneral();
if ((req.body.executionMode ?? "any") !== (current.executionMode ?? "any")) {
throw forbidden("executionMode is platform-managed on cloud-managed instances", {
code: "execution_mode_platform_managed",
});
}
}
const hidden = getHiddenSettings();
await assertNoHiddenSettingChanges(
req.body,
() => svc.getGeneral(),
(field) => hidden.has(`instance.general.${field}`),
);
const updated = await svc.updateGeneral(req.body);
const actor = getActorInfo(req);
const companyIds = await svc.listCompanyIds();
await Promise.all(
companyIds.map((companyId) =>
logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "instance.settings.general_updated",
entityType: "instance_settings",
entityId: updated.id,
details: {
general: updated.general,
changedKeys: Object.keys(req.body).sort(),
},
}),
),
);
res.json(updated.general);
},
);
router.get("/instance/settings/experimental", async (req, res) => {
// Experimental settings are readable by any authenticated org member
// or instance admin. Updating them remains instance-admin only because
// this payload includes instance-wide operational controls.
assertBoardOrgAccess(req);
res.json(await svc.getExperimental());
});
router.patch(
"/instance/settings/experimental",
validate(patchInstanceExperimentalSettingsSchema),
async (req, res) => {
assertCanManageInstanceSettings(req);
// Hiding the whole Experimental page floors every toggle; otherwise
// only individually hidden keys are floored.
const hidden = getHiddenSettings();
await assertNoHiddenSettingChanges(
req.body,
() => svc.getExperimental(),
(field) =>
hidden.has("instance.experimental") || hidden.has(`instance.experimental.${field}`),
);
const updated = await svc.updateExperimental(req.body);
const actor = getActorInfo(req);
const companyIds = await svc.listCompanyIds();
await Promise.all(
companyIds.map((companyId) =>
logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "instance.settings.experimental_updated",
entityType: "instance_settings",
entityId: updated.id,
details: {
experimental: updated.experimental,
changedKeys: Object.keys(req.body).sort(),
},
}),
),
);
res.json(updated.experimental);
},
);
router.get("/instance/task-drain", async (req, res) => {
assertBoardOrgAccess(req);
if (req.query.idleSleepSafety === "1") {
// The report covers every company in this process. Ordinary company
// members may read process counters, but not instance-wide work state.
assertCanManageInstanceSettings(req);
const idleSleepSafety = await readIdleSleepSafety(db, () => heartbeat.getTaskDrainStatus(), Date.now,
typeof req.query.ownerId === "string" ? req.query.ownerId : undefined);
res.json({ ...heartbeat.getTaskDrainStatus(), idleSleepSafety });
return;
}
res.json(heartbeat.getTaskDrainStatus());
});
router.post(
"/instance/task-drain",
validate(startTaskDrainRequestSchema),
async (req, res) => {
assertCanManageInstanceSettings(req);
const actor = getActorInfo(req);
const companyIds = await svc.listCompanyIds();
const ttlMs = req.body.ttlMs ?? null;
// The whole read-audit-apply sequence runs as one queued transition
// (see withTaskDrainTransition above), so an overlapping start or
// stop cannot commit its audit row, or apply its live state, out of
// order against this one. computeTaskDrain runs inside the turn so
// startedAt reflects the moment this request actually took effect,
// not the moment it arrived and was queued behind another transition.
const drain = await withTaskDrainTransition(async () => {
const prior = heartbeat.getTaskDrainStatus();
if (prior?.ownerId || (req.body.purpose === "idle" && prior?.draining)) {
throw conflict("Another task drain is already active");
}
const computed = heartbeat.computeTaskDrain({ ttlMs,
...(req.body.purpose === "idle" ? { purpose: "idle" as const } : {}) });
// One transaction for every company's audit row, so a write that
// succeeds for one company and fails for another never leaves a
// partial activity history behind — either every company gets the
// record, or none does. The drain mutation below runs only after
// this transaction commits, so a failed write leaves the live
// drain untouched and there is no partial state to roll back.
const postCommitActivityPublications: ActivityPublication[] = [];
await db.transaction((tx) =>
Promise.all(
companyIds.map((companyId) =>
logActivity(tx as unknown as Db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "instance.task_drain.started",
entityType: "instance_settings",
entityId: "default",
details: {
startedAt: computed.startedAt,
expiresAt: computed.expiresAt,
...(computed.ownerId ? { purpose: "idle", ownerId: computed.ownerId } : {}),
},
}, postCommitActivityPublications),
),
),
);
heartbeat.applyTaskDrain(computed);
// The audit record already committed, so a failure to publish it
// here is not a reason to undo the drain: reverting the in-memory
// state at this point would desync it from the committed row.
// Swallow a publish failure so it cannot turn a committed mutation
// into a false 500.
publishActivitiesBestEffort(postCommitActivityPublications, "instance.task_drain.started");
return computed;
});
res.json(drain);
},
);
router.delete("/instance/task-drain", async (req, res) => {
assertCanManageInstanceSettings(req);
const actor = getActorInfo(req);
const companyIds = await svc.listCompanyIds();
// See the POST handler above for why the whole read-audit-apply
// sequence runs inside withTaskDrainTransition: it queues this stop
// behind any transition already in flight, so it cannot read a status
// an overlapping request is about to make stale, and its audit row and
// its live-state mutation always land in the same order as every other
// queued transition.
const wasActive = await withTaskDrainTransition(async () => {
const priorStatus = heartbeat.getTaskDrainStatus();
if ((priorStatus.ownerId || req.query.ownerId !== undefined) &&
(typeof req.query.ownerId !== "string" || req.query.ownerId !== priorStatus.ownerId)) {
throw conflict("Task drain ownership changed");
}
// Read wasActive once, here, and use this same value for the audit
// detail and the response body below. A TTL that expires between two
// separate reads would otherwise make the two values disagree.
const wasActive = priorStatus.draining;
// See the POST handler above for why this is one transaction, and why
// the drain mutation runs only after it commits.
const postCommitActivityPublications: ActivityPublication[] = [];
await db.transaction((tx) =>
Promise.all(
companyIds.map((companyId) =>
logActivity(tx as unknown as Db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "instance.task_drain.stopped",
entityType: "instance_settings",
entityId: "default",
details: {
wasActive,
},
}, postCommitActivityPublications),
),
),
);
heartbeat.stopTaskDrain();
// See the POST handler above for why a publish failure here is
// swallowed instead of failing the route: the audit record already
// committed, so a publish failure here must not undo a drain-stop
// that is already correct in the database, and must not report the
// stop as failed when it succeeded.
publishActivitiesBestEffort(postCommitActivityPublications, "instance.task_drain.stopped");
return wasActive;
});
res.json({ wasActive });
});
// Cloud lifecycle read-back: the harness answers a tenant's archive
// doorbell by asking this instance what the Cloud-pinned primary
// company's status actually is, so the shared-token doorbell can stay a
// hint (see cloud-lifecycle-sync.ts). Reached by the harness with a
// `lifecycle:read` Cloud control assertion. Instance-admin only for
// humans: the response summarizes lifecycle state across EVERY company
// on the instance, which a board member scoped to one company must not
// be able to infer.
router.get("/instance/lifecycle", async (req, res) => {
assertCanManageInstanceSettings(req);
const stackId = getCloudStackContext()?.stackId;
if (!stackId) {
res.status(404).json({ error: "not_cloud_managed" });
return;
}
const primaryCompanyId = cloudTenantPrimaryCompanyId(stackId);
const rows = await db
.select({ id: companies.id, status: companies.status })
.from(companies);
const primary = rows.find((row) => row.id === primaryCompanyId);
res.json({
primaryCompanyId,
primaryCompanyStatus: primary?.status ?? "missing",
otherUnarchivedCompanyCount: rows.filter(
(row) => row.id !== primaryCompanyId && row.status !== "archived",
).length,
});
});
// Cloud restore counterpart: "Resume organization" on a stack that was
// archived because its primary company was archived must bring the
// company back too, or the tenant's next doorbell would re-archive the
// stack the customer just resumed. Idempotent: a primary company that is
// not archived reports changed:false. Reached by the harness with a
// `lifecycle:unarchive-primary` control assertion; instance admins hold
// the same power through PATCH /api/companies/:id already.
router.post("/instance/lifecycle/unarchive-primary", async (req, res) => {
assertCanManageInstanceSettings(req);
const stackId = getCloudStackContext()?.stackId;
if (!stackId) {
res.status(404).json({ error: "not_cloud_managed" });
return;
}
const primaryCompanyId = cloudTenantPrimaryCompanyId(stackId);
const companySvc = companyService(db);
const existing = await companySvc.getById(primaryCompanyId);
if (!existing) {
res.status(404).json({ error: "primary_company_not_found" });
return;
}
if (existing.status !== "archived") {
res.json({ status: existing.status, changed: false });
return;
}
// Attribution: only the harness's synthetic cloud_control actor logs
// as the Cloud system identity; a human instance admin calling this
// endpoint is recorded as themselves, exactly like an in-product
// unarchive.
const actor = req.actor.source === "cloud_control"
? { actorType: "system" as const, actorId: "paperclip-cloud", agentId: null, runId: null }
: (() => {
const info = getActorInfo(req);
return {
actorType: info.actorType,
actorId: info.actorId,
agentId: info.agentId,
runId: info.runId,
};
})();
const updated = await companySvc.update(primaryCompanyId, { status: "active" }, actor);
res.json({ status: updated?.status ?? "active", changed: true });
});
return router;
}