Files
PaperClipAI/server/src/services/adapter-execution-control.test.ts
T
Devin FoleyandPaperclip 0b12ca9532 fix(server): retain context for unconfirmed adapter stops (#14639)
## Thinking Path

> - Paperclip must keep ownership of work until termination is verified.
> - A Stop request waits for the adapter and its cleanup to settle.
> - After 60 seconds, an unconfirmed Stop raises an error.
> - The error currently lacks run, adapter, and runtime context.
> - This makes it difficult to investigate which stop path is stuck.
> - This change adds bounded diagnostics while preserving termination
checks.

## Linked Issues or Issue Description

**What happened?**

An adapter can remain unsettled after its Stop request. The resulting
error says termination is unverified but does not identify the adapter
or run in error monitoring. The optional Sentry setup does not capture
request context, so the endpoint alone cannot fill the gap.

**Expected behavior**

Keep the Stop unconfirmed and preserve its live execution owner. When
optional Sentry is enabled, attach enough bounded context to investigate
the affected run.

**Steps to reproduce**

1. Register an adapter execution control and abort its controller.
2. Leave its settlement promise pending.
3. Wait for the configured Stop timeout.
4. Observe that Stop still fails, but the event now includes the run
UUID, built-in adapter, native/legacy runtime, timeout duration, and
abort-requested flag.

**Paperclip version or commit**

Master commit `17780751551b3bc1c2521f7694026c34534c46c9`; reproduced
with fake timers and mocked optional error monitoring.

**Deployment mode**

Server execution control, including local and hosted runs. Reporting
remains opt-in.

Searched related Stop PRs. #14523 and #14244 address Hermes cancellation
contracts; this change only adds diagnostics to the shared
unconfirmed-stop timeout.

## What Changed

- Use a typed timeout error with the existing message, name, and timer
stack.
- Pass run/adapter/runtime identity from the cancellation owner.
- Add an event-local, allowlisted Sentry context without changing the
default fingerprint.
- Rebuild the reported exception so arbitrary provider fields cannot be
serialized.
- Test timeout ownership, delayed settlement, privacy boundaries, and
absence of context on unrelated events.
- Document the additional opt-in fields.

## Verification

- `pnpm exec vitest run
server/src/services/adapter-execution-control.test.ts
server/src/__tests__/sentry.test.ts`: 38 passed, five real-SDK checks
skipped because the optional package is not installed.
- `pnpm -r typecheck` passed; server typecheck passed again after the
SDK test addition.
- With audited optional `@sentry/node@10.71.0` installed only in local
test dependencies, `PAPERCLIP_REQUIRE_SENTRY_TEST_SDK=1 pnpm exec vitest
run server/src/__tests__/run-failure-sentry-real-sdk.test.ts
server/src/services/adapter-execution-control.test.ts
server/src/__tests__/sentry.test.ts`: all 45 tests passed. The real SDK
uses an in-memory transport; no Sentry requests are sent.
- The broad local `pnpm test:run` command did not complete in the
available verification window and was stopped; no full local-suite pass
is claimed. `pnpm build` passed. All sharded GitHub CI checks passed on
the final PR head.
- Tests use fake timers and a mocked Sentry package; no provider or
monitoring requests.

## Risks

This is diagnostic coverage, not a claim that the underlying stop delay
is fixed. Unknown adapter/runtime values become `unknown`; malformed run
identifiers become `null`. No stop reason, prompt, output, provider
response, credentials, or arbitrary error properties are sent. Timeout,
cancellation acknowledgement, live-owner retention, and retry behavior
remain unchanged. No schema changes.

## Model Used

OpenAI Codex (GPT-6), with reasoning, repository inspection, and command
execution. The session does not expose a more specific model revision or
context-window size.

## 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 described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run the targeted tests locally and they pass; full checks
are in progress
- [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-09-29 16:38:02 -07:00

154 lines
5.4 KiB
TypeScript

import { afterEach, expect, it, vi } from "vitest";
import { AdapterStopTimeoutError } from "./adapter-stop-timeout.js";
import {
adapterExecutionControls,
captureAdapterStopOwnership,
createAdapterExecutionControl,
registerAdapterExecutionControl,
waitForAdapterStop,
} from "./adapter-execution-control.js";
afterEach(() => vi.useRealTimers());
it("holds readiness for every exact-run no-owner Stop and releases owners idempotently", async () => {
const runId = "registration-after-two-stops";
const first = captureAdapterStopOwnership(runId);
const second = captureAdapterStopOwnership(runId);
const control = createAdapterExecutionControl();
const registration = registerAdapterExecutionControl(runId, control);
try {
expect(first.control).toBeUndefined();
expect(second.control).toBeUndefined();
expect(adapterExecutionControls.has(runId)).toBe(false);
first.release();
first.release();
await Promise.resolve();
expect(adapterExecutionControls.has(runId)).toBe(false);
second.release();
await registration;
expect(adapterExecutionControls.get(runId)).toBe(control);
} finally {
first.release();
second.release();
await registration;
adapterExecutionControls.delete(runId);
}
});
it("waits for a later no-owner Stop added while readiness is already waiting", async () => {
const runId = "registration-overlapping-stops";
const first = captureAdapterStopOwnership(runId);
const control = createAdapterExecutionControl();
const registration = registerAdapterExecutionControl(runId, control);
const later = captureAdapterStopOwnership(runId);
try {
first.release();
await Promise.resolve();
await Promise.resolve();
expect(adapterExecutionControls.has(runId)).toBe(false);
later.release();
await registration;
expect(adapterExecutionControls.get(runId)).toBe(control);
} finally {
first.release();
later.release();
await registration;
adapterExecutionControls.delete(runId);
}
});
it("captures a registered owner without blocking its own readiness or another run", async () => {
const runId = "registration-before-stop";
const unrelated = captureAdapterStopOwnership("unrelated-stop");
const control = createAdapterExecutionControl();
try {
await registerAdapterExecutionControl(runId, control);
const stop = captureAdapterStopOwnership(runId);
expect(stop.control).toBe(control);
stop.release();
expect(adapterExecutionControls.get(runId)).toBe(control);
} finally {
unrelated.release();
adapterExecutionControls.delete(runId);
}
});
it("releases a failed no-owner Stop without inventing cancellation or stranding readiness", async () => {
const runId = "registration-after-failed-stop";
const stop = captureAdapterStopOwnership(runId);
const control = createAdapterExecutionControl();
const registration = registerAdapterExecutionControl(runId, control);
const failure = new Error("cancellation write failed");
try {
await expect(
(async () => {
try {
throw failure;
} finally {
stop.release();
}
})(),
).rejects.toBe(failure);
await registration;
expect(control.controller.signal.aborted).toBe(false);
expect(adapterExecutionControls.get(runId)).toBe(control);
} finally {
stop.release();
await registration;
adapterExecutionControls.delete(runId);
}
});
it("does not acknowledge abort until execution and cleanup settle", async () => {
const control = createAdapterExecutionControl();
const finished = vi.fn();
const waiting = waitForAdapterStop(control.settled).then(finished);
control.controller.abort();
await Promise.resolve();
expect(finished).not.toHaveBeenCalled();
control.finish();
await waiting;
expect(finished).toHaveBeenCalledOnce();
});
it("bounds Stop when an adapter does not settle", async () => {
vi.useFakeTimers();
const control = createAdapterExecutionControl();
const assertion = expect(
waitForAdapterStop(control.settled, 1000),
).rejects.toThrow("termination has not been verified");
await vi.advanceTimersByTimeAsync(1000);
await assertion;
expect(vi.getTimerCount()).toBe(0);
});
it("records the exact unconfirmed run without finishing or removing its control", async () => {
vi.useFakeTimers();
const runId = "11111111-1111-4111-8111-111111111111";
const control = createAdapterExecutionControl();
await registerAdapterExecutionControl(runId, control);
control.controller.abort();
let finished = false;
void control.settled.then(() => { finished = true; });
const result = waitForAdapterStop(control.settled, 1000, {
runId, adapterType: "claude_local", runtimeMode: "legacy", abortRequested: control.controller.signal.aborted,
}).catch((error: unknown) => error);
try {
await vi.advanceTimersByTimeAsync(1000);
const error = await result;
expect(error).toBeInstanceOf(AdapterStopTimeoutError);
expect((error as AdapterStopTimeoutError).diagnostics).toEqual({
runId, adapterType: "claude_local", runtimeMode: "legacy", abortRequested: true, timeoutMs: 1000,
});
expect(adapterExecutionControls.get(runId)).toBe(control);
expect(finished).toBe(false);
expect(vi.getTimerCount()).toBe(0);
control.finish();
await control.settled;
expect(finished).toBe(true);
} finally {
control.finish();
adapterExecutionControls.delete(runId);
}
});