cancel-task.test.ts 16 KB

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