From 31e2d0b818a83d284d01dce7990d205010a85e71 Mon Sep 17 00:00:00 2001 From: nickyleach <331803+nickyleach@users.noreply.github.com> Date: Wed, 16 Sep 2026 06:56:31 +0000 Subject: [PATCH] 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 --- .../cloud-control-task-drain.test.ts | 40 ++++++++++++++++++- server/src/routes/instance-settings.ts | 5 ++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/cloud-control-task-drain.test.ts b/server/src/__tests__/cloud-control-task-drain.test.ts index e4fc3cf76d6..d4b0fcf9247 100644 --- a/server/src/__tests__/cloud-control-task-drain.test.ts +++ b/server/src/__tests__/cloud-control-task-drain.test.ts @@ -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(); + } + }); }); diff --git a/server/src/routes/instance-settings.ts b/server/src/routes/instance-settings.ts index db841cf75b3..f7df1b9f2fa 100644 --- a/server/src/routes/instance-settings.ts +++ b/server/src/routes/instance-settings.ts @@ -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, }; }