cancel-task.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  1. import {
  2. type PluginInput,
  3. type ToolDefinition,
  4. tool,
  5. } from '@opencode-ai/plugin';
  6. import type { BackgroundJobBoard } from '../utils/background-job-board';
  7. import { isRecord as isObjectRecord } from '../utils/guards';
  8. import { log } from '../utils/logger';
  9. import { abortSessionWithTimeout, withTimeout } from '../utils/session';
  10. const z = tool.schema;
  11. interface CancelTaskToolOptions {
  12. client: PluginInput['client'];
  13. backgroundJobBoard: BackgroundJobBoard;
  14. shouldManageSession: (sessionID: string) => boolean;
  15. abortTimeoutMs?: number;
  16. verifyAbortMs?: number;
  17. abortRetryIntervalMs?: number;
  18. stableStoppedMs?: number;
  19. deleteTimeoutMs?: number;
  20. deleteVerifyMs?: number;
  21. deleteStableStoppedMs?: number;
  22. }
  23. class SessionStillRunningError extends Error {}
  24. export function createCancelTaskTool(
  25. options: CancelTaskToolOptions,
  26. ): Record<string, ToolDefinition> {
  27. const cancel_task = tool({
  28. description: `Cancel a tracked background specialist task.
  29. Use only for obsolete, wrong, conflicting, or user-requested cancellation. Accepts either the native task_id/session ID or the parent-scoped alias shown in the Background Job Board. Cancellation is not rollback: if cancelling a writer, inspect and reconcile partial file changes before replacing the lane.`,
  30. args: {
  31. task_id: z
  32. .string()
  33. .describe('Tracked background task ID or Background Job Board alias'),
  34. reason: z.string().optional().describe('Short cancellation reason'),
  35. },
  36. async execute(args, toolContext) {
  37. const parentSessionID = toolContext?.sessionID;
  38. if (!parentSessionID) throw new Error('cancel_task requires sessionID');
  39. if (toolContext.agent && toolContext.agent !== 'orchestrator') {
  40. throw new Error('cancel_task can only be used by orchestrator');
  41. }
  42. if (!options.shouldManageSession(parentSessionID)) {
  43. throw new Error(
  44. 'cancel_task can only be used in orchestrator sessions',
  45. );
  46. }
  47. const requested = args.task_id.trim();
  48. if (!requested) throw new Error('cancel_task requires task_id');
  49. const job = options.backgroundJobBoard.resolve(
  50. parentSessionID,
  51. requested,
  52. );
  53. log('[cancel-task] request received', {
  54. parentSessionID,
  55. requested,
  56. resolvedTaskID: job?.taskID,
  57. alias: job
  58. ? options.backgroundJobBoard.field(job.taskID, 'alias')
  59. : undefined,
  60. state: job
  61. ? options.backgroundJobBoard.field(job.taskID, 'state')
  62. : undefined,
  63. terminalState: job
  64. ? options.backgroundJobBoard.field(job.taskID, 'terminalState')
  65. : undefined,
  66. cancellationRequested: job?.cancellationRequested,
  67. });
  68. if (!job) {
  69. if (isSessionID(requested)) {
  70. if (requested === parentSessionID) {
  71. log('[cancel-task] rejected parent session cancellation', {
  72. parentSessionID,
  73. taskID: requested,
  74. });
  75. return unknownTaskOutput(requested, 'cannot cancel parent session');
  76. }
  77. const knownJob = options.backgroundJobBoard.get(requested);
  78. const ownerParentSessionID =
  79. options.backgroundJobBoard.getParentSessionID(requested);
  80. if (knownJob && ownerParentSessionID !== parentSessionID) {
  81. log('[cancel-task] rejected unowned tracked raw session', {
  82. parentSessionID,
  83. taskID: requested,
  84. ownerParentSessionID,
  85. });
  86. return unknownTaskOutput(
  87. requested,
  88. 'unknown or unowned background task',
  89. );
  90. }
  91. const parentID = await getSessionParentID(options.client, requested);
  92. if (parentID !== parentSessionID) {
  93. log('[cancel-task] rejected raw session without parent ownership', {
  94. parentSessionID,
  95. taskID: requested,
  96. actualParentID: parentID,
  97. });
  98. return unknownTaskOutput(
  99. requested,
  100. 'unknown or unowned background task',
  101. );
  102. }
  103. log('[cancel-task] falling back to owned raw session abort', {
  104. parentSessionID,
  105. taskID: requested,
  106. });
  107. return cancelSessionByID(options, requested, args.reason);
  108. }
  109. return unknownTaskOutput(
  110. requested,
  111. 'unknown or unowned background task',
  112. );
  113. }
  114. try {
  115. await abortAndVerifySession(options, job.taskID);
  116. } catch (error) {
  117. const stillRunning = error instanceof SessionStillRunningError;
  118. const boardRunning = options.backgroundJobBoard.isRunning(job.taskID);
  119. log('[cancel-task] abort failed', {
  120. taskID: job.taskID,
  121. stillRunning,
  122. boardRunning,
  123. error: error instanceof Error ? error.message : String(error),
  124. });
  125. options.backgroundJobBoard.updateStatus({
  126. taskID: job.taskID,
  127. state: 'running',
  128. statusUncertain: true,
  129. lastStatusError:
  130. error instanceof Error ? error.message : String(error),
  131. });
  132. return [
  133. `task_id: ${job.taskID}`,
  134. 'state: running',
  135. '',
  136. '<task_error>',
  137. error instanceof Error ? error.message : String(error),
  138. '</task_error>',
  139. ].join('\n');
  140. }
  141. options.backgroundJobBoard.markCancelled(
  142. job.taskID,
  143. args.reason,
  144. Date.now(),
  145. { force: true },
  146. );
  147. const state = options.backgroundJobBoard.getState(job.taskID);
  148. log('[cancel-task] marked job cancelled after verified abort', {
  149. taskID: job.taskID,
  150. alias: options.backgroundJobBoard.field(job.taskID, 'alias'),
  151. state,
  152. cancellationRequested: options.backgroundJobBoard.field(
  153. job.taskID,
  154. 'cancellationRequested',
  155. ),
  156. });
  157. return [
  158. `task_id: ${job.taskID}`,
  159. `state: ${state ?? 'cancelled'}`,
  160. '',
  161. '<task_error>',
  162. options.backgroundJobBoard.getResultSummary(job.taskID) ?? 'cancelled',
  163. '</task_error>',
  164. ].join('\n');
  165. },
  166. });
  167. return { cancel_task };
  168. }
  169. async function cancelSessionByID(
  170. options: CancelTaskToolOptions,
  171. taskID: string,
  172. reason?: string,
  173. ): Promise<string> {
  174. try {
  175. await abortAndVerifySession(options, taskID);
  176. } catch (error) {
  177. const stillRunning = error instanceof SessionStillRunningError;
  178. log('[cancel-task] raw session abort failed', {
  179. taskID,
  180. stillRunning,
  181. error: error instanceof Error ? error.message : String(error),
  182. });
  183. return [
  184. `task_id: ${taskID}`,
  185. `state: ${stillRunning ? 'running' : 'error'}`,
  186. '',
  187. '<task_error>',
  188. error instanceof Error ? error.message : String(error),
  189. '</task_error>',
  190. ].join('\n');
  191. }
  192. return [
  193. `task_id: ${taskID}`,
  194. 'state: cancelled',
  195. '',
  196. '<task_error>',
  197. normalizeCancelReason(reason),
  198. '</task_error>',
  199. ].join('\n');
  200. }
  201. async function abortAndVerifySession(
  202. options: CancelTaskToolOptions,
  203. taskID: string,
  204. ): Promise<void> {
  205. log('[cancel-task] abort attempt starting', { taskID });
  206. const abortStartedAt = Date.now();
  207. try {
  208. await abortSessionWithTimeout(
  209. options.client,
  210. taskID,
  211. options.abortTimeoutMs ?? 10_000,
  212. );
  213. log('[cancel-task] abort call returned', { taskID });
  214. } catch (error) {
  215. log('[cancel-task] abort call failed', {
  216. taskID,
  217. error: error instanceof Error ? error.message : String(error),
  218. canDelete: canDeleteSession(options.client),
  219. });
  220. if (!canDeleteSession(options.client)) throw error;
  221. }
  222. if (canDeleteSession(options.client)) {
  223. await deleteAndVerifySession(options, taskID, 'cancel-task-after-abort');
  224. return;
  225. }
  226. const verifyAbortMs = options.verifyAbortMs ?? 8_000;
  227. const stableStoppedMs = options.stableStoppedMs ?? 3_000;
  228. const retryIntervalMs = options.abortRetryIntervalMs ?? 150;
  229. const deadline = Date.now() + verifyAbortMs;
  230. log('[cancel-task] abort verification starting', {
  231. taskID,
  232. verifyAbortMs,
  233. stableStoppedMs,
  234. retryIntervalMs,
  235. });
  236. let attempts = 0;
  237. let stableStoppedSince: number | undefined;
  238. let lastStatus: string | undefined;
  239. while (Date.now() <= deadline) {
  240. attempts += 1;
  241. const statusSnapshot = await getSessionStatus(options.client, taskID);
  242. lastStatus = statusSnapshot.status;
  243. log('[cancel-task] abort verification status', {
  244. taskID,
  245. attempts,
  246. status: statusSnapshot.status,
  247. statusSource: statusSnapshot.source,
  248. statusKeys: statusSnapshot.keys,
  249. stableStoppedSince,
  250. stableStoppedForMs: stableStoppedSince
  251. ? Date.now() - stableStoppedSince
  252. : 0,
  253. boardState: options.backgroundJobBoard.getState(taskID),
  254. boardLastLiveBusyAt: options.backgroundJobBoard.getLastLiveBusyAt(taskID),
  255. });
  256. const boardLastLiveBusyAt =
  257. options.backgroundJobBoard.getLastLiveBusyAt(taskID);
  258. if (boardLastLiveBusyAt && boardLastLiveBusyAt >= abortStartedAt) {
  259. log('[cancel-task] abort verification saw board busy after abort', {
  260. taskID,
  261. attempts,
  262. abortStartedAt,
  263. boardLastLiveBusyAt,
  264. status: statusSnapshot.status,
  265. statusSource: statusSnapshot.source,
  266. });
  267. await deleteAndVerifySession(options, taskID, 'board-busy-after-abort');
  268. return;
  269. }
  270. if (statusSnapshot.status === 'busy' || statusSnapshot.status === 'retry') {
  271. if (stableStoppedSince !== undefined) {
  272. log('[cancel-task] abort verification saw busy after idle', {
  273. taskID,
  274. attempts,
  275. stableStoppedForMs: Date.now() - stableStoppedSince,
  276. });
  277. await deleteAndVerifySession(options, taskID, 'busy-after-idle');
  278. return;
  279. }
  280. stableStoppedSince = undefined;
  281. await abortSessionWithTimeout(
  282. options.client,
  283. taskID,
  284. options.abortTimeoutMs ?? 10_000,
  285. );
  286. log('[cancel-task] abort retry returned', {
  287. taskID,
  288. attempts,
  289. status: statusSnapshot.status,
  290. });
  291. await delay(retryIntervalMs);
  292. continue;
  293. }
  294. stableStoppedSince ??= Date.now();
  295. if (Date.now() - stableStoppedSince >= stableStoppedMs) {
  296. log('[cancel-task] abort verified stopped', {
  297. taskID,
  298. attempts,
  299. status: statusSnapshot.status,
  300. stableStoppedMs,
  301. });
  302. return;
  303. }
  304. await delay(retryIntervalMs);
  305. }
  306. log('[cancel-task] abort verification timed out', {
  307. taskID,
  308. attempts,
  309. lastStatus,
  310. stableStoppedSince,
  311. });
  312. if (lastStatus === 'busy' || lastStatus === 'retry') {
  313. await deleteAndVerifySession(options, taskID, 'still-busy-after-abort');
  314. return;
  315. }
  316. throw new SessionStillRunningError(
  317. `Session abort returned but task did not stay stopped: ${taskID}`,
  318. );
  319. }
  320. async function deleteAndVerifySession(
  321. options: CancelTaskToolOptions,
  322. taskID: string,
  323. reason: string,
  324. ): Promise<void> {
  325. const session = options.client.session as unknown as {
  326. delete?: (args: { path: { id: string } }) => Promise<unknown>;
  327. };
  328. if (!session.delete) {
  329. log('[cancel-task] session delete unavailable', { taskID, reason });
  330. throw new SessionStillRunningError(
  331. `Session resumed after abort and delete is unavailable: ${taskID}`,
  332. );
  333. }
  334. log('[cancel-task] deleting session after unstable abort', {
  335. taskID,
  336. reason,
  337. });
  338. try {
  339. await withTimeout(
  340. session.delete({ path: { id: taskID } }),
  341. options.deleteTimeoutMs ?? 10_000,
  342. `Session delete timed out after ${options.deleteTimeoutMs ?? 10_000}ms`,
  343. );
  344. log('[cancel-task] session delete returned', { taskID, reason });
  345. } catch (error) {
  346. log('[cancel-task] session delete failed; verifying live state', {
  347. taskID,
  348. reason,
  349. error: error instanceof Error ? error.message : String(error),
  350. });
  351. const status = await getSessionStatus(options.client, taskID);
  352. log('[cancel-task] delete failure verification status', {
  353. taskID,
  354. reason,
  355. status: status.status,
  356. statusSource: status.source,
  357. statusKeys: status.keys,
  358. });
  359. if (status.status === 'busy' || status.status === 'retry') {
  360. throw new SessionStillRunningError(
  361. `Session delete failed and task is still busy: ${taskID}`,
  362. );
  363. }
  364. if (status.status !== 'idle') throw error;
  365. }
  366. const deadline = Date.now() + (options.deleteVerifyMs ?? 1_500);
  367. const stableStoppedMs = options.deleteStableStoppedMs ?? 300;
  368. const retryIntervalMs = options.abortRetryIntervalMs ?? 150;
  369. let stableStoppedSince: number | undefined;
  370. let attempts = 0;
  371. let lastStatus: string | undefined;
  372. while (Date.now() <= deadline) {
  373. attempts += 1;
  374. const status = await getSessionStatus(options.client, taskID);
  375. lastStatus = status.status;
  376. log('[cancel-task] delete verification status', {
  377. taskID,
  378. reason,
  379. attempts,
  380. status: status.status,
  381. statusSource: status.source,
  382. statusKeys: status.keys,
  383. stableStoppedSince,
  384. });
  385. if (status.status === 'busy' || status.status === 'retry') {
  386. stableStoppedSince = undefined;
  387. await delay(retryIntervalMs);
  388. continue;
  389. }
  390. stableStoppedSince ??= Date.now();
  391. if (Date.now() - stableStoppedSince >= stableStoppedMs) return;
  392. await delay(retryIntervalMs);
  393. }
  394. throw new SessionStillRunningError(
  395. `Session delete returned but task did not stay stopped: ${taskID} (${lastStatus ?? 'unknown'})`,
  396. );
  397. }
  398. function canDeleteSession(client: PluginInput['client']): boolean {
  399. const session = client.session as unknown as { delete?: unknown };
  400. return typeof session.delete === 'function';
  401. }
  402. async function getSessionStatus(
  403. client: PluginInput['client'],
  404. taskID: string,
  405. ): Promise<{
  406. status: string | undefined;
  407. source: string;
  408. keys: string[];
  409. }> {
  410. try {
  411. const result = await (
  412. client.session.status as unknown as () => Promise<unknown>
  413. )();
  414. const data = (result as { data?: unknown }).data;
  415. if (!isObjectRecord(data)) {
  416. return { status: undefined, source: 'invalid-data', keys: [] };
  417. }
  418. const keys = Object.keys(data).slice(0, 20);
  419. const item = data[taskID];
  420. if (item === undefined) {
  421. return { status: 'idle', source: 'missing-from-map', keys };
  422. }
  423. if (isObjectRecord(item) && typeof item.type === 'string') {
  424. return { status: item.type, source: 'task-map-entry', keys };
  425. }
  426. if (typeof data.type === 'string') {
  427. return { status: data.type, source: 'legacy-data-type', keys };
  428. }
  429. const nested = data.status;
  430. if (isObjectRecord(nested) && typeof nested.type === 'string') {
  431. return { status: nested.type, source: 'legacy-data-status', keys };
  432. }
  433. return { status: undefined, source: 'unknown-shape', keys };
  434. } catch (error) {
  435. log('[cancel-task] session status lookup failed', {
  436. taskID,
  437. error: error instanceof Error ? error.message : String(error),
  438. });
  439. return { status: undefined, source: 'lookup-error', keys: [] };
  440. }
  441. }
  442. function delay(ms: number): Promise<void> {
  443. return new Promise((resolve) => setTimeout(resolve, ms));
  444. }
  445. function isSessionID(value: string): boolean {
  446. return /^ses_[\w-]+$/.test(value);
  447. }
  448. function normalizeCancelReason(reason?: string): string {
  449. const normalized = reason?.replace(/\s+/g, ' ').trim();
  450. return normalized ? `cancelled: ${normalized}` : 'cancelled';
  451. }
  452. async function getSessionParentID(
  453. client: PluginInput['client'],
  454. taskID: string,
  455. ): Promise<string | undefined> {
  456. const session = client.session as unknown as {
  457. get?: (args: { path: { id: string } }) => Promise<unknown>;
  458. };
  459. if (!session.get) return undefined;
  460. try {
  461. const response = await session.get({ path: { id: taskID } });
  462. const data = (response as { data?: unknown }).data;
  463. if (!isObjectRecord(data)) return undefined;
  464. const parentID = data.parentID;
  465. return typeof parentID === 'string' ? parentID : undefined;
  466. } catch (error) {
  467. log('[cancel-task] session metadata lookup failed', {
  468. taskID,
  469. error: error instanceof Error ? error.message : String(error),
  470. });
  471. return undefined;
  472. }
  473. }
  474. function unknownTaskOutput(taskID: string, message: string): string {
  475. return [
  476. `task_id: ${taskID}`,
  477. 'state: unknown',
  478. '',
  479. '<task_error>',
  480. message,
  481. '</task_error>',
  482. ].join('\n');
  483. }