cancel-task.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. import { describe, expect, mock, test } from 'bun:test';
  2. import { parseTaskStatusOutput } from '../utils';
  3. import { BackgroundJobBoard } from '../utils/background-job-board';
  4. import { createCancelTaskTool } from './cancel-task';
  5. function createTool(overrides?: {
  6. abort?: () => Promise<unknown>;
  7. delete?: () => Promise<unknown>;
  8. get?: () => Promise<unknown>;
  9. status?: () => Promise<unknown>;
  10. includeDelete?: boolean;
  11. shouldManageSession?: (sessionID: string) => boolean;
  12. abortTimeoutMs?: number;
  13. verifyAbortMs?: number;
  14. abortRetryIntervalMs?: number;
  15. stableStoppedMs?: number;
  16. deleteVerifyMs?: number;
  17. deleteStableStoppedMs?: number;
  18. }) {
  19. const board = new BackgroundJobBoard();
  20. const abort = mock(overrides?.abort ?? (async () => ({})));
  21. const deleteSession = mock(overrides?.delete ?? (async () => ({})));
  22. const get = mock(
  23. overrides?.get ?? (async () => ({ data: { parentID: 'parent-1' } })),
  24. );
  25. const status = mock(overrides?.status ?? (async () => ({ data: {} })));
  26. const session: Record<string, unknown> = { abort, get, status };
  27. if (overrides?.includeDelete !== false) session.delete = deleteSession;
  28. const tools = createCancelTaskTool({
  29. client: { session } as any,
  30. backgroundJobBoard: board,
  31. shouldManageSession: overrides?.shouldManageSession ?? (() => true),
  32. abortTimeoutMs: overrides?.abortTimeoutMs,
  33. verifyAbortMs: overrides?.verifyAbortMs ?? 1,
  34. abortRetryIntervalMs: overrides?.abortRetryIntervalMs ?? 0,
  35. stableStoppedMs: overrides?.stableStoppedMs ?? 0,
  36. deleteVerifyMs: overrides?.deleteVerifyMs ?? 1,
  37. deleteStableStoppedMs: overrides?.deleteStableStoppedMs ?? 0,
  38. });
  39. return {
  40. board,
  41. abort,
  42. deleteSession,
  43. get,
  44. status,
  45. cancelTask: tools.cancel_task,
  46. };
  47. }
  48. const context = { sessionID: 'parent-1', agent: 'orchestrator' } as any;
  49. describe('cancel_task tool', () => {
  50. test('cancels a tracked running task by task ID', async () => {
  51. const { board, abort, cancelTask } = createTool();
  52. board.registerLaunch({
  53. taskID: 'ses_1',
  54. parentSessionID: 'parent-1',
  55. agent: 'explorer',
  56. });
  57. const output = await cancelTask.execute(
  58. { task_id: 'ses_1', reason: 'obsolete' },
  59. context,
  60. );
  61. expect(abort).toHaveBeenCalledWith({ path: { id: 'ses_1' } });
  62. expect(String(output)).toContain('state: cancelled');
  63. expect(String(output)).toContain('cancelled: obsolete');
  64. expect(parseTaskStatusOutput(String(output))).toMatchObject({
  65. taskID: 'ses_1',
  66. state: 'cancelled',
  67. result: 'cancelled: obsolete',
  68. });
  69. expect(board.get('ses_1')).toMatchObject({ state: 'cancelled' });
  70. });
  71. test('cancels a tracked running task by parent-scoped alias', async () => {
  72. const { board, abort, cancelTask } = createTool();
  73. board.registerLaunch({
  74. taskID: 'ses_1',
  75. parentSessionID: 'parent-1',
  76. agent: 'oracle',
  77. });
  78. await cancelTask.execute({ task_id: 'ora-1' }, context);
  79. expect(abort).toHaveBeenCalledWith({ path: { id: 'ses_1' } });
  80. });
  81. test('does not abort raw session IDs tracked by a different parent', async () => {
  82. const { board, abort, cancelTask } = createTool();
  83. board.registerLaunch({
  84. taskID: 'ses_2',
  85. parentSessionID: 'parent-2',
  86. agent: 'fixer',
  87. });
  88. const output = await cancelTask.execute({ task_id: 'ses_2' }, context);
  89. expect(abort).not.toHaveBeenCalled();
  90. expect(String(output)).toContain('state: unknown');
  91. });
  92. test('does not abort unknown aliases', async () => {
  93. const { abort, cancelTask } = createTool();
  94. const output = await cancelTask.execute({ task_id: 'fix-99' }, context);
  95. expect(abort).not.toHaveBeenCalled();
  96. expect(String(output)).toContain('state: unknown');
  97. });
  98. test('aborts tracked jobs regardless of current board state', async () => {
  99. const { board, abort, cancelTask } = createTool();
  100. board.registerLaunch({
  101. taskID: 'ses_1',
  102. parentSessionID: 'parent-1',
  103. agent: 'fixer',
  104. });
  105. board.updateStatus({ taskID: 'ses_1', state: 'completed' });
  106. const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
  107. expect(abort).toHaveBeenCalledWith({ path: { id: 'ses_1' } });
  108. expect(String(output)).toContain('state: cancelled');
  109. expect(board.get('ses_1')).toMatchObject({ state: 'cancelled' });
  110. });
  111. test('aborts owned raw session IDs when job board lost the task', async () => {
  112. const { abort, cancelTask } = createTool();
  113. const output = await cancelTask.execute(
  114. { task_id: 'ses_lost', reason: 'stop ghost worker' },
  115. context,
  116. );
  117. expect(abort).toHaveBeenCalledWith({ path: { id: 'ses_lost' } });
  118. expect(String(output)).toContain('state: cancelled');
  119. expect(String(output)).toContain('cancelled: stop ghost worker');
  120. });
  121. test('does not abort raw session ID without metadata ownership', async () => {
  122. const { abort, cancelTask } = createTool({
  123. get: async () => ({ data: { parentID: 'other-parent' } }),
  124. });
  125. const output = await cancelTask.execute({ task_id: 'ses_lost' }, context);
  126. expect(abort).not.toHaveBeenCalled();
  127. expect(String(output)).toContain('state: unknown');
  128. });
  129. test('does not abort the parent session ID', async () => {
  130. const { abort, cancelTask } = createTool();
  131. const output = await cancelTask.execute(
  132. { task_id: 'ses_parent' },
  133. { ...context, sessionID: 'ses_parent' },
  134. );
  135. expect(abort).not.toHaveBeenCalled();
  136. expect(String(output)).toContain('state: unknown');
  137. });
  138. test('still aborts stale cancelled jobs', async () => {
  139. const { board, abort, cancelTask } = createTool();
  140. board.registerLaunch({
  141. taskID: 'ses_1',
  142. parentSessionID: 'parent-1',
  143. agent: 'explorer',
  144. });
  145. board.updateStatus({ taskID: 'ses_1', state: 'cancelled' });
  146. const output = await cancelTask.execute(
  147. { task_id: 'ses_1', reason: 'stop ghost worker' },
  148. context,
  149. );
  150. expect(abort).toHaveBeenCalledWith({ path: { id: 'ses_1' } });
  151. expect(String(output)).toContain('state: cancelled');
  152. });
  153. test('still aborts reconciled stale cancellations', async () => {
  154. const { board, abort, cancelTask } = createTool();
  155. board.registerLaunch({
  156. taskID: 'ses_1',
  157. parentSessionID: 'parent-1',
  158. agent: 'explorer',
  159. });
  160. board.updateStatus({ taskID: 'ses_1', state: 'cancelled' });
  161. board.markReconciled('ses_1');
  162. const output = await cancelTask.execute(
  163. { task_id: 'ses_1', reason: 'stop ghost worker' },
  164. context,
  165. );
  166. expect(abort).toHaveBeenCalledWith({ path: { id: 'ses_1' } });
  167. expect(String(output)).toContain('state: cancelled');
  168. });
  169. test('does not terminalize board when abort fails without delete', async () => {
  170. const { board, abort, cancelTask } = createTool({
  171. includeDelete: false,
  172. abort: async () => {
  173. throw new Error('abort failed');
  174. },
  175. });
  176. board.registerLaunch({
  177. taskID: 'ses_1',
  178. parentSessionID: 'parent-1',
  179. agent: 'fixer',
  180. });
  181. const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
  182. expect(abort).toHaveBeenCalled();
  183. expect(String(output)).toContain('state: running');
  184. expect(board.get('ses_1')).toMatchObject({
  185. state: 'running',
  186. terminalUnreconciled: false,
  187. statusUncertain: true,
  188. });
  189. });
  190. test('deletes session when abort fails but delete succeeds', async () => {
  191. const { board, abort, deleteSession, cancelTask } = createTool({
  192. abort: async () => {
  193. throw new Error('abort failed');
  194. },
  195. });
  196. board.registerLaunch({
  197. taskID: 'ses_1',
  198. parentSessionID: 'parent-1',
  199. agent: 'fixer',
  200. });
  201. const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
  202. expect(abort).toHaveBeenCalled();
  203. expect(deleteSession).toHaveBeenCalledWith({ path: { id: 'ses_1' } });
  204. expect(String(output)).toContain('state: cancelled');
  205. expect(board.get('ses_1')).toMatchObject({ state: 'cancelled' });
  206. });
  207. test('treats delete not-found as success when status is missing', async () => {
  208. const { board, deleteSession, cancelTask } = createTool({
  209. delete: async () => {
  210. throw new Error('not found');
  211. },
  212. status: async () => ({ data: {} }),
  213. });
  214. board.registerLaunch({
  215. taskID: 'ses_1',
  216. parentSessionID: 'parent-1',
  217. agent: 'fixer',
  218. });
  219. const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
  220. expect(deleteSession).toHaveBeenCalledWith({ path: { id: 'ses_1' } });
  221. expect(String(output)).toContain('state: cancelled');
  222. expect(board.get('ses_1')).toMatchObject({ state: 'cancelled' });
  223. });
  224. test('keeps running/status uncertain when delete fails and status stays busy', async () => {
  225. const { board, deleteSession, cancelTask } = createTool({
  226. delete: async () => {
  227. throw new Error('delete failed');
  228. },
  229. status: async () => ({ data: { ses_1: { type: 'busy' } } }),
  230. });
  231. board.registerLaunch({
  232. taskID: 'ses_1',
  233. parentSessionID: 'parent-1',
  234. agent: 'fixer',
  235. });
  236. const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
  237. expect(deleteSession).toHaveBeenCalledWith({ path: { id: 'ses_1' } });
  238. expect(String(output)).toContain('state: running');
  239. expect(board.get('ses_1')).toMatchObject({
  240. state: 'running',
  241. statusUncertain: true,
  242. terminalUnreconciled: false,
  243. });
  244. });
  245. test('keeps running/status uncertain when abort times out without delete', async () => {
  246. const { board, cancelTask } = createTool({
  247. includeDelete: false,
  248. abort: () => new Promise(() => {}),
  249. abortTimeoutMs: 1,
  250. });
  251. board.registerLaunch({
  252. taskID: 'ses_1',
  253. parentSessionID: 'parent-1',
  254. agent: 'fixer',
  255. });
  256. const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
  257. expect(String(output)).toContain('state: running');
  258. expect(parseTaskStatusOutput(String(output))).toMatchObject({
  259. taskID: 'ses_1',
  260. state: 'running',
  261. });
  262. expect(board.get('ses_1')).toMatchObject({
  263. state: 'running',
  264. terminalUnreconciled: false,
  265. statusUncertain: true,
  266. });
  267. });
  268. test('deletes session when abort returns but session stays busy', async () => {
  269. let deleted = false;
  270. const { board, abort, deleteSession, cancelTask } = createTool({
  271. delete: async () => {
  272. deleted = true;
  273. return {};
  274. },
  275. status: async () =>
  276. deleted ? { data: {} } : { data: { ses_1: { type: 'busy' } } },
  277. verifyAbortMs: 1,
  278. });
  279. board.registerLaunch({
  280. taskID: 'ses_1',
  281. parentSessionID: 'parent-1',
  282. agent: 'oracle',
  283. });
  284. const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
  285. expect(abort).toHaveBeenCalled();
  286. expect(deleteSession).toHaveBeenCalledWith({ path: { id: 'ses_1' } });
  287. expect(String(output)).toContain('state: cancelled');
  288. expect(board.get('ses_1')).toMatchObject({
  289. state: 'cancelled',
  290. terminalUnreconciled: true,
  291. cancellationRequested: true,
  292. });
  293. });
  294. test('deletes and marks cancelled when session idles then becomes busy', async () => {
  295. let deleted = false;
  296. const statuses = [{ data: {} }, { data: { ses_1: { type: 'busy' } } }];
  297. const { board, abort, deleteSession, cancelTask } = createTool({
  298. delete: async () => {
  299. deleted = true;
  300. return {};
  301. },
  302. status: async () =>
  303. deleted ? { data: {} } : (statuses.shift() ?? { data: {} }),
  304. verifyAbortMs: 10,
  305. abortRetryIntervalMs: 0,
  306. stableStoppedMs: 2,
  307. });
  308. board.registerLaunch({
  309. taskID: 'ses_1',
  310. parentSessionID: 'parent-1',
  311. agent: 'oracle',
  312. });
  313. const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
  314. expect(abort).toHaveBeenCalled();
  315. expect(deleteSession).toHaveBeenCalledWith({ path: { id: 'ses_1' } });
  316. expect(String(output)).toContain('state: cancelled');
  317. expect(board.get('ses_1')).toMatchObject({
  318. state: 'cancelled',
  319. cancellationRequested: true,
  320. terminalUnreconciled: true,
  321. });
  322. });
  323. test('deletes session when board observes busy after abort despite idle status map', async () => {
  324. let deleted = false;
  325. const { board, deleteSession, cancelTask } = createTool({
  326. delete: async () => {
  327. deleted = true;
  328. return {};
  329. },
  330. status: async () => (deleted ? { data: {} } : { data: {} }),
  331. verifyAbortMs: 20,
  332. abortRetryIntervalMs: 1,
  333. stableStoppedMs: 10,
  334. });
  335. board.registerLaunch({
  336. taskID: 'ses_1',
  337. parentSessionID: 'parent-1',
  338. agent: 'oracle',
  339. now: Date.now() - 1000,
  340. });
  341. queueMicrotask(() => board.markRunningFromLiveSession('ses_1'));
  342. const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
  343. expect(deleteSession).toHaveBeenCalledWith({ path: { id: 'ses_1' } });
  344. expect(String(output)).toContain('state: cancelled');
  345. expect(board.get('ses_1')).toMatchObject({
  346. state: 'cancelled',
  347. cancellationRequested: true,
  348. });
  349. });
  350. test('marks cancelled when session disappears from status map', async () => {
  351. const { board, abort, cancelTask } = createTool({
  352. status: async () => ({ data: {} }),
  353. });
  354. board.registerLaunch({
  355. taskID: 'ses_1',
  356. parentSessionID: 'parent-1',
  357. agent: 'oracle',
  358. });
  359. const output = await cancelTask.execute({ task_id: 'ses_1' }, context);
  360. expect(abort).toHaveBeenCalled();
  361. expect(String(output)).toContain('state: cancelled');
  362. expect(board.get('ses_1')).toMatchObject({
  363. state: 'cancelled',
  364. cancellationRequested: true,
  365. });
  366. });
  367. test('cancelSessionByID returns state: error when abort throws non-SessionStillRunningError, even if board shows running', async () => {
  368. const { board, abort, cancelTask } = createTool({
  369. includeDelete: false,
  370. abort: async () => {
  371. throw new Error('network timeout');
  372. },
  373. });
  374. // Register a running job so that isRunning(taskID) would be true
  375. // if the function incorrectly checks it.
  376. board.registerLaunch({
  377. taskID: 'ses_running',
  378. parentSessionID: 'parent-1',
  379. agent: 'fixer',
  380. });
  381. // Override resolve to return undefined, forcing the cancelSessionByID
  382. // raw session path instead of the tracked task path.
  383. board.resolve = mock(() => undefined);
  384. const output = await cancelTask.execute(
  385. { task_id: 'ses_running', reason: 'regression guard' },
  386. context,
  387. );
  388. expect(abort).toHaveBeenCalledWith({ path: { id: 'ses_running' } });
  389. // cancelSessionByID must return state: error for non-SessionStillRunningError,
  390. // NOT state: running (which would happen if || isRunning() were present).
  391. expect(String(output)).toContain('state: error');
  392. expect(String(output)).not.toContain('state: running');
  393. });
  394. test('denies non-orchestrator agents', async () => {
  395. const { cancelTask } = createTool();
  396. await expect(
  397. cancelTask.execute({ task_id: 'ses_1' }, {
  398. sessionID: 'parent-1',
  399. agent: 'fixer',
  400. } as any),
  401. ).rejects.toThrow('orchestrator');
  402. });
  403. test('denies unmanaged sessions', async () => {
  404. const { cancelTask } = createTool({ shouldManageSession: () => false });
  405. await expect(
  406. cancelTask.execute({ task_id: 'ses_1' }, context),
  407. ).rejects.toThrow('orchestrator sessions');
  408. });
  409. });