mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 13:38:01 +02:00
diagnostics: record native completion cancellation source
This commit is contained in:
1 parent
af10f6df09
commit
9b44e0db22
2 files changed
+71
-1
No files matched your search
@@ -10,7 +10,7 @@ import { createHeartbeatLifecycle, type HeartbeatLifecycleDependencies } from ".
|
||||
import { createHeartbeatRunPreparation, ConfigurationIncompleteFailure } from "./run-preparation.js";
|
||||
import { createHeartbeatRunState, getAdapterSessionCodec } from "./run-state.js";
|
||||
import { buildEffectiveRunSessionConfigMetadata } from "./workspaces.js";
|
||||
import { createHeartbeatRunCompletion, type CompleteHeartbeatRunInput, type FailHeartbeatRunInput, type HeartbeatRunCompletionDependencies } from "./run-completion.js";
|
||||
import { createHeartbeatRunCompletion, nativeCompletionCancellationDiagnostic, type CompleteHeartbeatRunInput, type FailHeartbeatRunInput, type HeartbeatRunCompletionDependencies } from "./run-completion.js";
|
||||
|
||||
vi.mock("../live-events.js", () => ({ publishLiveEvent: vi.fn() }));
|
||||
|
||||
@@ -72,6 +72,45 @@ it("constructs completion handlers without querying or starting work", () => {
|
||||
for (const callback of Object.values(deps)) if (vi.isMockFunction(callback)) expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("native completion cancellation diagnostics", () => {
|
||||
it.each([
|
||||
["Legacy controller lease lost", "legacy_controller_lease_lost"],
|
||||
["Run stopped before provider startup", "stopped_before_provider_startup"],
|
||||
["private-token-do-not-log", "other"],
|
||||
])("classifies an aborted controller without exposing its message: %s", (message, abortReason) => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error(message));
|
||||
const diagnostic = nativeCompletionCancellationDiagnostic({ outcome: "cancelled", nativeTerminal: "succeeded",
|
||||
persistedRunStatus: "running", signal: controller.signal });
|
||||
expect(diagnostic).toEqual({ nativeTerminal: "succeeded", outcomeSource: "execution_signal",
|
||||
persistedRunStatus: "running", signalAborted: true, abortReason });
|
||||
expect(JSON.stringify(diagnostic)).not.toContain(message);
|
||||
});
|
||||
|
||||
it("reports a persisted cancellation ahead of an aborted controller", () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error("Legacy controller lease lost"));
|
||||
expect(nativeCompletionCancellationDiagnostic({ outcome: "cancelled", nativeTerminal: "succeeded",
|
||||
persistedRunStatus: "cancelled", signal: controller.signal })).toMatchObject({
|
||||
outcomeSource: "persisted_terminal", persistedRunStatus: "cancelled", abortReason: "legacy_controller_lease_lost",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports persisted cancellation without inferring a signal cause", () => {
|
||||
expect(nativeCompletionCancellationDiagnostic({ outcome: "cancelled", nativeTerminal: "succeeded",
|
||||
persistedRunStatus: "cancelled", signal: new AbortController().signal })).toMatchObject({
|
||||
outcomeSource: "persisted_terminal", signalAborted: false, abortReason: "not_aborted",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits diagnostics for successful completion and provider cancellation", () => {
|
||||
const signal = new AbortController().signal;
|
||||
expect(nativeCompletionCancellationDiagnostic({ outcome: "succeeded", nativeTerminal: "succeeded", persistedRunStatus: "running", signal })).toBeNull();
|
||||
expect(nativeCompletionCancellationDiagnostic({ outcome: "cancelled", nativeTerminal: "cancelled", persistedRunStatus: "running", signal })).toBeNull();
|
||||
expect(nativeCompletionCancellationDiagnostic({ outcome: "cancelled", nativeTerminal: undefined, persistedRunStatus: "cancelled", signal })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
describe.skipIf(!support.supported)("heartbeat run completion boundary", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
|
||||
@@ -50,6 +50,30 @@ type RunState = ReturnType<typeof createHeartbeatRunState>;
|
||||
type IssueContext = Awaited<ReturnType<ReturnType<typeof createHeartbeatRunPreparation>["getIssueExecutionContext"]>> | null;
|
||||
type TaskSession = Awaited<ReturnType<RunState["getTaskSession"]>> | null;
|
||||
|
||||
/** Closed diagnostics for a host cancellation overriding a completed native turn. */
|
||||
export function nativeCompletionCancellationDiagnostic(input: {
|
||||
outcome: RunSessionOutcome;
|
||||
nativeTerminal: string | undefined;
|
||||
persistedRunStatus: string | null;
|
||||
signal: AbortSignal;
|
||||
}) {
|
||||
if (input.outcome !== "cancelled" || input.nativeTerminal !== "succeeded") return null;
|
||||
const persistedCancellation = input.persistedRunStatus === "cancelled";
|
||||
if (!persistedCancellation && !input.signal.aborted) return null;
|
||||
const abortMessage = input.signal.reason instanceof Error ? input.signal.reason.message : null;
|
||||
return {
|
||||
nativeTerminal: "succeeded",
|
||||
outcomeSource: persistedCancellation ? "persisted_terminal" : "execution_signal",
|
||||
persistedRunStatus: input.persistedRunStatus === null ? "missing"
|
||||
: ["queued", "scheduled_retry", "running", "succeeded", "failed", "cancelled", "timed_out", "interrupted"].includes(input.persistedRunStatus)
|
||||
? input.persistedRunStatus : "unknown",
|
||||
signalAborted: input.signal.aborted,
|
||||
abortReason: !input.signal.aborted ? "not_aborted"
|
||||
: abortMessage === "Legacy controller lease lost" ? "legacy_controller_lease_lost"
|
||||
: abortMessage === "Run stopped before provider startup" ? "stopped_before_provider_startup" : "other",
|
||||
};
|
||||
}
|
||||
|
||||
/** Completion persists terminal outcomes; the executor retains dispatch and cleanup ownership. */
|
||||
export interface HeartbeatRunCompletionDependencies extends Pick<RunState,
|
||||
"getRun" | "getAgent" | "resolveNormalizedUsageForSession" | "clearTaskSessions" | "upsertTaskSession">,
|
||||
@@ -393,6 +417,12 @@ export function createHeartbeatRunCompletion(db: Db, dependencies: HeartbeatRunC
|
||||
} else {
|
||||
outcome = "failed";
|
||||
}
|
||||
const nativeCancellationDiagnostic = nativeCompletionCancellationDiagnostic({
|
||||
outcome,
|
||||
nativeTerminal: adapterResult.nativeFinalization?.terminal.runTerminalState,
|
||||
persistedRunStatus: latestRun?.status ?? null,
|
||||
signal,
|
||||
});
|
||||
|
||||
const nextSessionState = resolveNextSessionState({
|
||||
adapterType: agent.adapterType,
|
||||
@@ -666,6 +696,7 @@ export function createHeartbeatRunCompletion(db: Db, dependencies: HeartbeatRunC
|
||||
payload: {
|
||||
status,
|
||||
exitCode: adapterResult.exitCode,
|
||||
...(nativeCancellationDiagnostic ? { nativeOutcomeOverride: nativeCancellationDiagnostic } : {}),
|
||||
...(readRunCancellation(finalizedRun.resultJson) ? { cancellation: readRunCancellation(finalizedRun.resultJson) } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user