Skip to content

Commit 254153e

Browse files
committed
refactor: make cancelTask blocking by waiting for task completion and enforcing state verification
1 parent 97acc11 commit 254153e

3 files changed

Lines changed: 64 additions & 228 deletions

File tree

src/server/request_handler/default_request_handler.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -928,13 +928,17 @@ export class DefaultRequestHandler implements A2ARequestHandler {
928928
const eventBus = this.eventBusManager.getByTaskId(taskId);
929929

930930
if (eventBus) {
931-
// Signal the executor and return; §3.1.5's "Updated Task with
932-
// cancellation status" output is satisfied by the snapshot below.
933-
// `_runExecutor`'s background drain keeps the store current.
931+
const eventQueue = new ExecutionEventQueue(eventBus);
934932
await this.agentExecutor.cancelTask(taskId, eventBus);
933+
// Consume all the events until the task reaches a terminal state.
934+
await this._processEvents(
935+
taskId,
936+
new ResultManager(this.taskStore, context),
937+
eventQueue,
938+
context
939+
);
935940
} else {
936-
// No active bus — executor isn't running here, so persist CANCELED
937-
// directly.
941+
// Here we are marking task as cancelled. We are not waiting for the executor to actually cancel processing.
938942
task.status = {
939943
state: TaskState.TASK_STATE_CANCELED,
940944
message: {
@@ -968,8 +972,9 @@ export class DefaultRequestHandler implements A2ARequestHandler {
968972
if (!latestTask) {
969973
throw new GenericError(`Task ${params.id} not found after cancellation.`);
970974
}
971-
// No post-load throw: §3.1.5 lists "the task might have already
972-
// completed or failed" as a valid outcome; return the snapshot.
975+
if (latestTask.status!.state != TaskState.TASK_STATE_CANCELED) {
976+
throw new TaskNotCancelableError(`Task not cancelable: ${params.id}`);
977+
}
973978
return latestTask;
974979
}
975980

test/server/default_request_handler.spec.ts

Lines changed: 35 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3199,40 +3199,34 @@ describe('DefaultRequestHandler as A2ARequestHandler', () => {
31993199
assert.isDefined(createdTaskEvent, 'Task creation event should have been received');
32003200
const taskId = createdTaskEvent.payload.value.id;
32013201

3202-
// Per §3.1.5, cancelTask is non-blocking — it fires the signal and
3203-
// returns the current snapshot rather than awaiting the event drain.
3204-
// The snapshot here will be WORKING because the executor's next
3205-
// loop tick (which publishes CANCELED) hasn't fired yet.
3206-
const cancelResponse = await handler.cancelTask(
3202+
// Now, issue the cancel request
3203+
const cancelPromise = handler.cancelTask(
32073204
{ id: taskId, tenant: '', metadata: {} },
32083205
serverCallContext
32093206
);
32103207

3208+
// Let the executor's loop run to completion to detect the cancellation
3209+
await vi.runAllTimersAsync();
3210+
3211+
const cancelResponse = await cancelPromise;
3212+
32113213
expect(cancellableExecutor.cancelTaskSpy).toHaveBeenCalledExactlyOnceWith(
32123214
taskId,
32133215
expect.anything()
32143216
);
3215-
assert.equal(cancelResponse.status.state, TaskState.TASK_STATE_WORKING);
3216-
3217-
// Let the executor's loop run to completion to detect the cancellation
3218-
// and let the stream consumer drain CANCELED into the store.
3219-
await vi.runAllTimersAsync();
32203217

32213218
const finalTask = await handler.getTask(
32223219
{ id: taskId, tenant: '', historyLength: 0 },
32233220
serverCallContext
32243221
);
32253222
assert.equal(finalTask.status.state, TaskState.TASK_STATE_CANCELED);
3223+
3224+
assert.equal(cancelResponse.status.state, TaskState.TASK_STATE_CANCELED);
32263225
});
32273226

3228-
it('cancelTask: returns snapshot when executor resolves without publishing CANCELED (§3.1.5)', async () => {
3227+
it('cancelTask: should fail when it fails to cancel a task', async () => {
32293228
vi.useFakeTimers();
3230-
// Use a mock whose cancelTask is a no-op — it resolves without
3231-
// publishing CANCELED. §3.1.5 defines the output as "Updated Task
3232-
// with cancellation status" and notes "success is not guaranteed";
3233-
// the handler MUST fire the signal and return the current snapshot
3234-
// rather than throw TaskNotCancelableError. The executor's normal
3235-
// lifecycle plays out afterwards.
3229+
// Use the more advanced mock for this specific test
32363230
const failingCancellableExecutor = new FailingCancellableMockAgentExecutor();
32373231

32383232
handler = new DefaultRequestHandler(
@@ -3261,18 +3255,30 @@ describe('DefaultRequestHandler as A2ARequestHandler', () => {
32613255
assert.isDefined(createdTaskEvent, 'Task creation event should have been received');
32623256
const taskId = createdTaskEvent.payload.value.id;
32633257

3264-
const cancelResponse = await handler.cancelTask(
3265-
{ id: taskId, tenant: '', metadata: {} },
3266-
serverCallContext
3267-
);
3268-
3269-
// Snapshot reflects the persisted state — the executor hasn't
3270-
// honored the signal, so it stays WORKING.
3271-
assert.equal(cancelResponse.status.state, TaskState.TASK_STATE_WORKING);
3272-
expect(failingCancellableExecutor.cancelTaskSpy).toHaveBeenCalledWith(
3273-
taskId,
3274-
expect.anything()
3275-
);
3258+
let cancelResponse: Task | undefined;
3259+
let thrownError: any;
3260+
try {
3261+
const cancelPromise = handler.cancelTask(
3262+
{ id: taskId, tenant: '', metadata: {} },
3263+
serverCallContext
3264+
);
3265+
cancelPromise.catch(() => {});
3266+
await vi.runAllTimersAsync();
3267+
try {
3268+
cancelResponse = await cancelPromise;
3269+
} catch (error: any) {
3270+
thrownError = error;
3271+
}
3272+
} finally {
3273+
assert.isDefined(thrownError);
3274+
assert.isUndefined(cancelResponse);
3275+
assert.instanceOf(thrownError, TaskNotCancelableError);
3276+
expect(thrownError.message).to.contain('Task not cancelable');
3277+
expect(failingCancellableExecutor.cancelTaskSpy).toHaveBeenCalledWith(
3278+
taskId,
3279+
expect.anything()
3280+
);
3281+
}
32763282
});
32773283

32783284
it('cancelTask: should fail for tasks in a terminal state', async () => {
Lines changed: 17 additions & 192 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,29 @@
11
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
22

3-
import {
4-
DefaultRequestHandler,
5-
ExecutionEventBus,
6-
InMemoryTaskStore,
7-
TaskStore,
8-
} from '../../../src/server/index.js';
3+
import { DefaultRequestHandler, InMemoryTaskStore, TaskStore } from '../../../src/server/index.js';
94
import { AgentCard, CancelTaskRequest, Task, TaskState } from '../../../src/types/pb/a2a.js';
105
import { DefaultExecutionEventBusManager } from '../../../src/server/events/execution_event_bus_manager.js';
11-
import { AgentEvent } from '../../../src/server/events/execution_event_bus.js';
126
import { ServerCallContext } from '../../../src/server/context.js';
13-
import { TaskNotCancelableError, TaskNotFoundError } from '../../../src/errors.js';
147
import { MockAgentExecutor } from '../mocks/agent-executor.mock.js';
158

169
/**
17-
* Focused coverage for {@link DefaultRequestHandler.cancelTask} per
18-
* spec §3.1.5 (output is the "Updated Task with cancellation status";
19-
* "success is not guaranteed (e.g., the task might have already
20-
* completed or failed, or cancellation might not be supported at its
21-
* current stage)") and §3.3.1 (cancel MUST be idempotent — "multiple
22-
* cancellation requests have the same effect").
10+
* Focused coverage for the §3.3.1 idempotency carve-out in
11+
* {@link DefaultRequestHandler.cancelTask}: "Cancel Task operations
12+
* are idempotent — multiple cancellation requests have the same effect."
2313
*
24-
* Mirrors the a2a-go idempotent pattern in
25-
* `a2asrv/agentexec.go:268,357-359` and
26-
* `internal/taskexec/distributed_manager.go:164-166`.
27-
*
28-
* The contract verified here:
29-
*
30-
* 1. **Idempotent on CANCELED** — a second cancel of an
31-
* already-canceled task returns the snapshot, NOT
32-
* `TaskNotCancelableError` (§3.3.1).
33-
* 2. **Non-blocking** — the handler MUST NOT await an event drain
34-
* after `agentExecutor.cancelTask(...)`. Returning the current
35-
* snapshot satisfies §3.1.5's "Updated Task with cancellation
36-
* status" output without blocking on the executor publishing a
37-
* CANCELED event.
38-
* 3. **Non-CANCELED final state is not an error** — §3.1.5 explicitly
39-
* lists "the task might have already completed or failed" as a
40-
* reason cancel may not succeed; that outcome is the snapshot, not
41-
* a thrown error.
42-
* 4. **Non-cancelable states still throw** — COMPLETED / FAILED /
43-
* REJECTED tasks raise `TaskNotCancelableError` per the §3.1.5
44-
* errors list.
45-
* 5. **Unknown task still throws `TaskNotFoundError`** — preserved
46-
* per the §3.1.5 errors list.
14+
* The rest of the cancel contract (terminal-state rejection, unknown
15+
* task, drain-then-return) is already covered in
16+
* `default_request_handler.spec.ts`.
4717
*/
48-
describe('DefaultRequestHandler.cancelTask (§3.1.5, §3.3.1)', () => {
18+
describe('DefaultRequestHandler.cancelTask idempotency (§3.3.1)', () => {
4919
let handler: DefaultRequestHandler;
5020
let taskStore: TaskStore;
5121
let mockExecutor: MockAgentExecutor;
5222
let eventBusManager: DefaultExecutionEventBusManager;
5323

5424
const agentCard: AgentCard = {
5525
name: 'Cancel Task Agent',
56-
description: 'Test agent for §3.1.5 / §3.3.1 cancel contract',
26+
description: 'Test agent for §3.3.1 cancel idempotency',
5727
version: '1.0.0',
5828
provider: undefined,
5929
documentationUrl: '',
@@ -91,26 +61,22 @@ describe('DefaultRequestHandler.cancelTask (§3.1.5, §3.3.1)', () => {
9161
vi.restoreAllMocks();
9262
});
9363

94-
const makeTask = (
95-
id: string,
96-
state: TaskState = TaskState.TASK_STATE_WORKING,
97-
contextId = `ctx-${id}`
98-
): Task => ({
64+
const cancelReq = (id: string): CancelTaskRequest => ({
9965
id,
100-
contextId,
101-
status: { state, message: undefined, timestamp: undefined },
102-
artifacts: [],
103-
history: [],
66+
tenant: '',
10467
metadata: {},
10568
});
10669

107-
const cancelReq = (id: string): CancelTaskRequest => ({
70+
const makeTask = (id: string, state: TaskState): Task => ({
10871
id,
109-
tenant: '',
72+
contextId: `ctx-${id}`,
73+
status: { state, message: undefined, timestamp: undefined },
74+
artifacts: [],
75+
history: [],
11076
metadata: {},
11177
});
11278

113-
it('returns the snapshot (no throw) when canceling an already-canceled task — §3.3.1 idempotency', async () => {
79+
it('returns the snapshot (no throw) when canceling an already-canceled task', async () => {
11480
// The user retries cancel after the first one succeeded (or two
11581
// clients raced the same cancel). Per §3.3.1 the second call MUST
11682
// be idempotent — return the snapshot, not TaskNotCancelableError.
@@ -126,145 +92,4 @@ describe('DefaultRequestHandler.cancelTask (§3.1.5, §3.3.1)', () => {
12692
// canceled — idempotency means a no-op, not a re-issue.
12793
expect(mockExecutor.cancelTask).not.toHaveBeenCalled();
12894
});
129-
130-
it('returns the current snapshot without awaiting a CANCELED event — §3.1.5 non-blocking', async () => {
131-
// Executor's cancelTask is a no-op stub that resolves immediately
132-
// without publishing CANCELED. Previously, cancelTask awaited
133-
// `_processEvents`, which only resolves once the bus closes — so
134-
// this call would never return. The handler must now fire the
135-
// signal and return the current snapshot from the store; §3.1.5
136-
// defines the output as "Updated Task with cancellation status",
137-
// i.e. whatever the current state is at response time.
138-
const taskId = 'task-no-cancel-event';
139-
const contextId = `ctx-${taskId}`;
140-
const persisted = makeTask(taskId, TaskState.TASK_STATE_WORKING, contextId);
141-
await taskStore.save(persisted, serverContext);
142-
143-
// Register an active bus so the handler takes the executor-signal
144-
// branch (not the no-bus direct-persist branch).
145-
eventBusManager.createOrGetByTaskId(taskId);
146-
147-
// If the handler awaited the event drain this `await` would never
148-
// resolve — the test would time out instead of asserting. Reaching
149-
// the assertions at all is the proof of non-blocking behavior.
150-
const result = await handler.cancelTask(cancelReq(taskId), serverContext);
151-
152-
expect(mockExecutor.cancelTask).toHaveBeenCalledExactlyOnceWith(taskId, expect.anything());
153-
// Snapshot reflects the persisted state — the executor hasn't
154-
// published anything in response to cancel, so it remains WORKING.
155-
expect(result.status?.state).toBe(TaskState.TASK_STATE_WORKING);
156-
});
157-
158-
it('returns the snapshot when the executor completes during cancel — no post-load throw', async () => {
159-
// §3.1.5 explicitly names "the task might have already completed or
160-
// failed" as a reason cancel may not succeed. Race: the user signals
161-
// cancel, but the executor was already on the last step and
162-
// publishes COMPLETED before processing the cancel. The previous
163-
// implementation threw TaskNotCancelableError after the drain
164-
// because final state was not CANCELED; the new contract returns
165-
// the snapshot — the "Updated Task with cancellation status"
166-
// §3.1.5 specifies as the output.
167-
const taskId = 'task-natural-completion';
168-
const contextId = `ctx-${taskId}`;
169-
const persisted = makeTask(taskId, TaskState.TASK_STATE_WORKING, contextId);
170-
await taskStore.save(persisted, serverContext);
171-
172-
const bus: ExecutionEventBus = eventBusManager.createOrGetByTaskId(taskId);
173-
174-
// Simulate the completion landing while the cancel call is in
175-
// flight: the executor publishes COMPLETED inside its cancelTask
176-
// handler (a reasonable response when work already finished), and
177-
// we persist that state to the store so the post-signal
178-
// `taskStore.load(...)` observes COMPLETED.
179-
mockExecutor.cancelTask.mockImplementation(
180-
async (cancelTaskId: string, eventBus: ExecutionEventBus) => {
181-
eventBus.publish(
182-
AgentEvent.statusUpdate({
183-
taskId: cancelTaskId,
184-
contextId,
185-
status: {
186-
state: TaskState.TASK_STATE_COMPLETED,
187-
message: undefined,
188-
timestamp: undefined,
189-
},
190-
metadata: {},
191-
})
192-
);
193-
// Persist directly — the handler no longer drains the bus, so
194-
// a parallel ResultManager isn't writing this state for us in
195-
// the test's deterministic window.
196-
const current = await taskStore.load(cancelTaskId, serverContext);
197-
if (current) {
198-
current.status = {
199-
state: TaskState.TASK_STATE_COMPLETED,
200-
message: undefined,
201-
timestamp: undefined,
202-
};
203-
await taskStore.save(current, serverContext);
204-
}
205-
}
206-
);
207-
208-
const result = await handler.cancelTask(cancelReq(taskId), serverContext);
209-
210-
expect(result.id).toBe(taskId);
211-
expect(result.status?.state).toBe(TaskState.TASK_STATE_COMPLETED);
212-
// Bus is still around — `_runExecutor.finally` is what closes it,
213-
// not cancelTask. Referenced so the no-unused-vars lint is happy.
214-
expect(bus).toBeDefined();
215-
});
216-
217-
it('throws TaskNotCancelableError for COMPLETED tasks', async () => {
218-
const taskId = 'task-completed';
219-
await taskStore.save(makeTask(taskId, TaskState.TASK_STATE_COMPLETED), serverContext);
220-
await expect(handler.cancelTask(cancelReq(taskId), serverContext)).rejects.toThrow(
221-
TaskNotCancelableError
222-
);
223-
expect(mockExecutor.cancelTask).not.toHaveBeenCalled();
224-
});
225-
226-
it('throws TaskNotCancelableError for FAILED tasks', async () => {
227-
const taskId = 'task-failed';
228-
await taskStore.save(makeTask(taskId, TaskState.TASK_STATE_FAILED), serverContext);
229-
await expect(handler.cancelTask(cancelReq(taskId), serverContext)).rejects.toThrow(
230-
TaskNotCancelableError
231-
);
232-
expect(mockExecutor.cancelTask).not.toHaveBeenCalled();
233-
});
234-
235-
it('throws TaskNotCancelableError for REJECTED tasks', async () => {
236-
const taskId = 'task-rejected';
237-
await taskStore.save(makeTask(taskId, TaskState.TASK_STATE_REJECTED), serverContext);
238-
await expect(handler.cancelTask(cancelReq(taskId), serverContext)).rejects.toThrow(
239-
TaskNotCancelableError
240-
);
241-
expect(mockExecutor.cancelTask).not.toHaveBeenCalled();
242-
});
243-
244-
it('still throws TaskNotFoundError when the task id is unknown', async () => {
245-
await expect(handler.cancelTask(cancelReq('does-not-exist'), serverContext)).rejects.toThrow(
246-
TaskNotFoundError
247-
);
248-
expect(mockExecutor.cancelTask).not.toHaveBeenCalled();
249-
});
250-
251-
it('persists CANCELED state directly when no active bus exists', async () => {
252-
// No-bus branch is unchanged: the executor isn't around to signal,
253-
// so the handler writes CANCELED to the store and returns it. Pinned
254-
// as a regression guard so the new idempotency check doesn't
255-
// accidentally short-circuit the persist path.
256-
const taskId = 'task-no-bus';
257-
const persisted = makeTask(taskId, TaskState.TASK_STATE_WORKING);
258-
await taskStore.save(persisted, serverContext);
259-
expect(eventBusManager.getByTaskId(taskId)).toBeUndefined();
260-
261-
const result = await handler.cancelTask(cancelReq(taskId), serverContext);
262-
263-
expect(result.status?.state).toBe(TaskState.TASK_STATE_CANCELED);
264-
// No executor signal on the no-bus branch — there's nothing
265-
// running to interrupt.
266-
expect(mockExecutor.cancelTask).not.toHaveBeenCalled();
267-
// The cancellation message should be appended to history.
268-
expect(result.history?.length).toBeGreaterThan(0);
269-
});
27095
});

0 commit comments

Comments
 (0)