fix(server): record the true actor source for a Cloud-initiated task-drain termination

The task-drain audit record used getActorInfo alone, which folds an
unlisted actor source into "session". A Cloud-initiated termination
carries the "cloud_control" source, so the arm-time and outcome
activity records misclassified a destructive, instance-wide stop as
an interactive session. Read the verified actor source from the
request directly for that one case, and correct the test assertions
that pinned the wrong value.

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
nickyleachandPaperclip committed 2026-09-16 06:56:31 +00:00
1 parent c2c6ba269d
commit 31e2d0b818
2 files changed
+43 -2

No files matched your search

@@ -473,8 +473,46 @@ describe("the termination option through the production chain", () => {
([, input]: [unknown, { action: string }]) => input.action === "instance.task_drain.started",
);
expect(startCall?.[1].details.initiatingActor).toMatchObject({
actorSource: "session",
actorSource: "cloud_control",
cloudControlRequestId: expect.stringMatching(/^drain-req-live-\d+$/),
});
});
it("the_timer_outcome_record_carries_the_verified_cloud_control_actor", async () => {
// Fake only the timer functions, so the signed assertion still verifies
// against the real clock while the test controls when the deadline fires.
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
const terminateAt = new Date(Date.now() + 30_000);
mockHeartbeatService.computeTaskDrain.mockReturnValue({
startedAt: new Date(),
expiresAt: new Date(Date.now() + 60_000),
terminateActiveTasks: true,
terminateAt,
});
mockHeartbeatService.applyTaskDrain.mockReturnValue(1);
mockHeartbeatService.isTaskDrainGenerationLive.mockReturnValue(true);
const app = createTaskDrainApp();
const res = await request(app)
.post("/api/instance/task-drain")
.set(CLOUD_CONTROL_HEADER, freshAssertion("task-drain:start"))
.send({ ttlMs: 60_000, terminateActiveTasks: true });
expect(res.status).toBe(200);
mockLogActivity.mockClear();
await vi.advanceTimersByTimeAsync(30_000);
const outcomeCall = mockLogActivity.mock.calls.find(
([, input]: [unknown, { action: string }]) =>
input.action === "instance.task_drain.active_tasks_terminated",
);
expect(outcomeCall?.[1].details.initiatingActor).toMatchObject({
actorSource: "cloud_control",
cloudControlRequestId: expect.stringMatching(/^drain-req-live-\d+$/),
});
} finally {
vi.useRealTimers();
}
});
});
+4 -1
View File
@@ -150,7 +150,10 @@ function buildTaskDrainActorSnapshot(req: Request): TaskDrainActorSnapshot {
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
actorSource: actor.actorSource,
// getActorInfo folds every source it does not name into "session", so a
// Cloud-initiated request needs its own check here to keep the true
// source on the durable record.
actorSource: req.actor?.source === "cloud_control" ? "cloud_control" : actor.actorSource,
cloudControlRequestId: req.cloudControlRequestId ?? null,
};
}