Files
PaperClipAI/server/src/__tests__/heartbeat-task-drain.test.ts
Nicky LeachandPaperclip 4310b0c947 refactor(server): remove unreachable task-drain compensation paths (#12511)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The task-drain service controls when task execution can start and
stop.
> - The service had compensation paths for states that its validators or
recovery process already handle.
> - These paths added rollback state and a stuck-claim marker without
improving normal drain behavior.
> - This pull request removes the unreachable TTL clamp, audit rollback,
generation counter, and double-fault marker.
> - The result keeps input validation, audit ordering, atomic release,
and orphan recovery.

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The task-drain service and its routes manage drain state, audit rows,
and execution locks.

**Subsystem affected**
server/ — REST API and orchestration services.

**Current behavior**
The service clamps a validated TTL value. The routes mutate drain state
before audit writes and then restore state after a failed write. Claim
release also tracks a second durable-write failure with an in-memory
marker.

**Proposed behavior**
The validator remains the single TTL policy. The routes write audit rows
before they mutate drain state. Claim release logs a failed write and
lets the orphan reaper release the issue lock.

**Reason and benefit**
The removed paths cannot handle a valid API request that reaches them.
The rollback can lose the original start time. The marker can keep a
drain non-quiescent until process restart. The simpler flow keeps state
consistent and uses the existing recovery path.

**Breaking changes**
None to the public API. A failed claim release keeps the issue lock
until the next orphan-reaper cycle.

## What Changed

- Remove the service-layer TTL clamp because the shared validator
rejects values above the limit.
- Write task-drain audit rows before drain mutation and remove the
rollback helpers.
- Remove the rollback generation counter and its unused state.
- Remove double-fault stuck-claim tracking and keep the atomic release
path.
- State that the quiescent flag describes work in this process.
- Keep the orphan reaper as the recovery path after a failed claim
release.

## Verification

- Run `pnpm --filter @paperclipai/server test
server/src/__tests__/heartbeat-task-drain-admission-release.test.ts`.
- Run `pnpm --filter @paperclipai/server test
server/src/__tests__/heartbeat-task-drain.test.ts`.
- Run `pnpm --filter @paperclipai/server test
server/src/__tests__/instance-settings-routes.test.ts`.
- Run `pnpm --filter @paperclipai/server test
server/src/__tests__/heartbeat-scheduling-suppression.test.ts`.
- Run `pnpm --filter @paperclipai/server test
server/src/__tests__/execution-lock-orphan-cleanup.test.ts`.
- The five affected test files pass with 70 tests.
- Confirm the full pull request checks pass before merge.

## Risks

The issue lock remains held until the orphan reaper runs after a failed
claim release. This uses the existing recovery path for interrupted
runs. The change does not alter the public API or database schema.

## Model Used

OpenAI Codex, GPT-5, 400K context window, tool use and code execution.

## 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>
2026-08-29 11:43:16 -07:00

66 lines
1.9 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import {
getTaskDrainStatus,
resolveHeartbeatSchedulingSuppression,
startTaskDrain,
stopTaskDrain,
} from "../services/heartbeat.ts";
describe("heartbeat task drain", () => {
afterEach(() => {
stopTaskDrain();
vi.useRealTimers();
});
it("start_task_drain_suppresses_admission", () => {
startTaskDrain({});
expect(resolveHeartbeatSchedulingSuppression({})).toEqual({
suppressed: true,
reason: "task_drain",
});
});
it("stop_task_drain_restores_admission", () => {
startTaskDrain({});
expect(stopTaskDrain()).toEqual({ wasActive: true });
expect(resolveHeartbeatSchedulingSuppression({})).toEqual({
suppressed: false,
reason: null,
});
expect(stopTaskDrain()).toEqual({ wasActive: false });
});
it("null_ttl_produces_no_expiry", () => {
const { expiresAt } = startTaskDrain({ ttlMs: null });
expect(expiresAt).toBeNull();
expect(getTaskDrainStatus().expiresAt).toBeNull();
});
it("an_expired_ttl_ends_the_drain_and_restores_admission", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
startTaskDrain({ ttlMs: 1000 });
expect(resolveHeartbeatSchedulingSuppression({})).toEqual({
suppressed: true,
reason: "task_drain",
});
vi.setSystemTime(new Date("2026-01-01T00:00:01.001Z"));
expect(resolveHeartbeatSchedulingSuppression({})).toEqual({
suppressed: false,
reason: null,
});
expect(getTaskDrainStatus().draining).toBe(false);
});
it("status_reports_quiescent_when_both_promise_sets_are_empty", () => {
startTaskDrain({});
const status = getTaskDrainStatus();
expect(status.draining).toBe(true);
expect(status.activeRuns).toBe(0);
expect(status.pendingWakes).toBe(0);
expect(status.quiescent).toBe(true);
});
});