cancel-task.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  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 =
  118. error instanceof SessionStillRunningError ||
  119. options.backgroundJobBoard.isRunning(job.taskID); // ponytail: intent-revealing query
  120. log('[cancel-task] abort failed', {
  121. taskID: job.taskID,
  122. stillRunning,
  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: job.cancellationRequested,
  153. });
  154. return [
  155. `task_id: ${job.taskID}`,
  156. `state: ${state ?? 'cancelled'}`,
  157. '',
  158. '<task_error>',
  159. options.backgroundJobBoard.getResultSummary(job.taskID) ?? 'cancelled',
  160. '</task_error>',
  161. ].join('\n');
  162. },
  163. });
  164. return { cancel_task };
  165. }
  166. async function cancelSessionByID(
  167. options: CancelTaskToolOptions,
  168. taskID: string,
  169. reason?: string,
  170. ): Promise<string> {
  171. try {
  172. await abortAndVerifySession(options, taskID);
  173. } catch (error) {
  174. const stillRunning = error instanceof SessionStillRunningError;
  175. log('[cancel-task] raw session abort failed', {
  176. taskID,
  177. stillRunning,
  178. error: error instanceof Error ? error.message : String(error),
  179. });
  180. return [
  181. `task_id: ${taskID}`,
  182. `state: ${stillRunning ? 'running' : 'error'}`,
  183. '',
  184. '<task_error>',
  185. error instanceof Error ? error.message : String(error),
  186. '</task_error>',
  187. ].join('\n');
  188. }
  189. return [
  190. `task_id: ${taskID}`,
  191. 'state: cancelled',
  192. '',
  193. '<task_error>',
  194. normalizeCancelReason(reason),
  195. '</task_error>',
  196. ].join('\n');
  197. }
  198. async function abortAndVerifySession(
  199. options: CancelTaskToolOptions,
  200. taskID: string,
  201. ): Promise<void> {
  202. log('[cancel-task] abort attempt starting', { taskID });
  203. const abortStartedAt = Date.now();
  204. try {
  205. await abortSessionWithTimeout(
  206. options.client,
  207. taskID,
  208. options.abortTimeoutMs ?? 10_000,
  209. );
  210. log('[cancel-task] abort call returned', { taskID });
  211. } catch (error) {
  212. log('[cancel-task] abort call failed', {
  213. taskID,
  214. error: error instanceof Error ? error.message : String(error),
  215. canDelete: canDeleteSession(options.client),
  216. });
  217. if (!canDeleteSession(options.client)) throw error;
  218. }
  219. if (canDeleteSession(options.client)) {
  220. await deleteAndVerifySession(options, taskID, 'cancel-task-after-abort');
  221. return;
  222. }
  223. const verifyAbortMs = options.verifyAbortMs ?? 8_000;
  224. const stableStoppedMs = options.stableStoppedMs ?? 3_000;
  225. const retryIntervalMs = options.abortRetryIntervalMs ?? 150;
  226. const deadline = Date.now() + verifyAbortMs;
  227. log('[cancel-task] abort verification starting', {
  228. taskID,
  229. verifyAbortMs,
  230. stableStoppedMs,
  231. retryIntervalMs,
  232. });
  233. let attempts = 0;
  234. let stableStoppedSince: number | undefined;
  235. let lastStatus: string | undefined;
  236. while (Date.now() <= deadline) {
  237. attempts += 1;
  238. const statusSnapshot = await getSessionStatus(options.client, taskID);
  239. lastStatus = statusSnapshot.status;
  240. log('[cancel-task] abort verification status', {
  241. taskID,
  242. attempts,
  243. status: statusSnapshot.status,
  244. statusSource: statusSnapshot.source,
  245. statusKeys: statusSnapshot.keys,
  246. stableStoppedSince,
  247. stableStoppedForMs: stableStoppedSince
  248. ? Date.now() - stableStoppedSince
  249. : 0,
  250. boardState: options.backgroundJobBoard.getState(taskID),
  251. boardLastLiveBusyAt: options.backgroundJobBoard.getLastLiveBusyAt(taskID),
  252. });
  253. const boardLastLiveBusyAt =
  254. options.backgroundJobBoard.getLastLiveBusyAt(taskID);
  255. if (boardLastLiveBusyAt && boardLastLiveBusyAt >= abortStartedAt) {
  256. log('[cancel-task] abort verification saw board busy after abort', {
  257. taskID,
  258. attempts,
  259. abortStartedAt,
  260. boardLastLiveBusyAt,
  261. status: statusSnapshot.status,
  262. statusSource: statusSnapshot.source,
  263. });
  264. await deleteAndVerifySession(options, taskID, 'board-busy-after-abort');
  265. return;
  266. }
  267. if (statusSnapshot.status === 'busy' || statusSnapshot.status === 'retry') {
  268. if (stableStoppedSince !== undefined) {
  269. log('[cancel-task] abort verification saw busy after idle', {
  270. taskID,
  271. attempts,
  272. stableStoppedForMs: Date.now() - stableStoppedSince,
  273. });
  274. await deleteAndVerifySession(options, taskID, 'busy-after-idle');
  275. return;
  276. }
  277. stableStoppedSince = undefined;
  278. await abortSessionWithTimeout(
  279. options.client,
  280. taskID,
  281. options.abortTimeoutMs ?? 10_000,
  282. );
  283. log('[cancel-task] abort retry returned', {
  284. taskID,
  285. attempts,
  286. status: statusSnapshot.status,
  287. });
  288. await delay(retryIntervalMs);
  289. continue;
  290. }
  291. stableStoppedSince ??= Date.now();
  292. if (Date.now() - stableStoppedSince >= stableStoppedMs) {
  293. log('[cancel-task] abort verified stopped', {
  294. taskID,
  295. attempts,
  296. status: statusSnapshot.status,
  297. stableStoppedMs,
  298. });
  299. return;
  300. }
  301. await delay(retryIntervalMs);
  302. }
  303. log('[cancel-task] abort verification timed out', {
  304. taskID,
  305. attempts,
  306. lastStatus,
  307. stableStoppedSince,
  308. });
  309. if (lastStatus === 'busy' || lastStatus === 'retry') {
  310. await deleteAndVerifySession(options, taskID, 'still-busy-after-abort');
  311. return;
  312. }
  313. throw new SessionStillRunningError(
  314. `Session abort returned but task did not stay stopped: ${taskID}`,
  315. );
  316. }
  317. async function deleteAndVerifySession(
  318. options: CancelTaskToolOptions,
  319. taskID: string,
  320. reason: string,
  321. ): Promise<void> {
  322. const session = options.client.session as unknown as {
  323. delete?: (args: { path: { id: string } }) => Promise<unknown>;
  324. };
  325. if (!session.delete) {
  326. log('[cancel-task] session delete unavailable', { taskID, reason });
  327. throw new SessionStillRunningError(
  328. `Session resumed after abort and delete is unavailable: ${taskID}`,
  329. );
  330. }
  331. log('[cancel-task] deleting session after unstable abort', {
  332. taskID,
  333. reason,
  334. });
  335. try {
  336. await withTimeout(
  337. session.delete({ path: { id: taskID } }),
  338. options.deleteTimeoutMs ?? 10_000,
  339. `Session delete timed out after ${options.deleteTimeoutMs ?? 10_000}ms`,
  340. );
  341. log('[cancel-task] session delete returned', { taskID, reason });
  342. } catch (error) {
  343. log('[cancel-task] session delete failed; verifying live state', {
  344. taskID,
  345. reason,
  346. error: error instanceof Error ? error.message : String(error),
  347. });
  348. const status = await getSessionStatus(options.client, taskID);
  349. log('[cancel-task] delete failure verification status', {
  350. taskID,
  351. reason,
  352. status: status.status,
  353. statusSource: status.source,
  354. statusKeys: status.keys,
  355. });
  356. if (status.status === 'busy' || status.status === 'retry') {
  357. throw new SessionStillRunningError(
  358. `Session delete failed and task is still busy: ${taskID}`,
  359. );
  360. }
  361. if (status.status !== 'idle') throw error;
  362. }
  363. const deadline = Date.now() + (options.deleteVerifyMs ?? 1_500);
  364. const stableStoppedMs = options.deleteStableStoppedMs ?? 300;
  365. const retryIntervalMs = options.abortRetryIntervalMs ?? 150;
  366. let stableStoppedSince: number | undefined;
  367. let attempts = 0;
  368. let lastStatus: string | undefined;
  369. while (Date.now() <= deadline) {
  370. attempts += 1;
  371. const status = await getSessionStatus(options.client, taskID);
  372. lastStatus = status.status;
  373. log('[cancel-task] delete verification status', {
  374. taskID,
  375. reason,
  376. attempts,
  377. status: status.status,
  378. statusSource: status.source,
  379. statusKeys: status.keys,
  380. stableStoppedSince,
  381. });
  382. if (status.status === 'busy' || status.status === 'retry') {
  383. stableStoppedSince = undefined;
  384. await delay(retryIntervalMs);
  385. continue;
  386. }
  387. stableStoppedSince ??= Date.now();
  388. if (Date.now() - stableStoppedSince >= stableStoppedMs) return;
  389. await delay(retryIntervalMs);
  390. }
  391. throw new SessionStillRunningError(
  392. `Session delete returned but task did not stay stopped: ${taskID} (${lastStatus ?? 'unknown'})`,
  393. );
  394. }
  395. function canDeleteSession(client: PluginInput['client']): boolean {
  396. const session = client.session as unknown as { delete?: unknown };
  397. return typeof session.delete === 'function';
  398. }
  399. async function getSessionStatus(
  400. client: PluginInput['client'],
  401. taskID: string,
  402. ): Promise<{
  403. status: string | undefined;
  404. source: string;
  405. keys: string[];
  406. }> {
  407. try {
  408. const result = await (
  409. client.session.status as unknown as () => Promise<unknown>
  410. )();
  411. const data = (result as { data?: unknown }).data;
  412. if (!isObjectRecord(data)) {
  413. return { status: undefined, source: 'invalid-data', keys: [] };
  414. }
  415. const keys = Object.keys(data).slice(0, 20);
  416. const item = data[taskID];
  417. if (item === undefined) {
  418. return { status: 'idle', source: 'missing-from-map', keys };
  419. }
  420. if (isObjectRecord(item) && typeof item.type === 'string') {
  421. return { status: item.type, source: 'task-map-entry', keys };
  422. }
  423. if (typeof data.type === 'string') {
  424. return { status: data.type, source: 'legacy-data-type', keys };
  425. }
  426. const nested = data.status;
  427. if (isObjectRecord(nested) && typeof nested.type === 'string') {
  428. return { status: nested.type, source: 'legacy-data-status', keys };
  429. }
  430. return { status: undefined, source: 'unknown-shape', keys };
  431. } catch (error) {
  432. log('[cancel-task] session status lookup failed', {
  433. taskID,
  434. error: error instanceof Error ? error.message : String(error),
  435. });
  436. return { status: undefined, source: 'lookup-error', keys: [] };
  437. }
  438. }
  439. function delay(ms: number): Promise<void> {
  440. return new Promise((resolve) => setTimeout(resolve, ms));
  441. }
  442. function isSessionID(value: string): boolean {
  443. return /^ses_[\w-]+$/.test(value);
  444. }
  445. function normalizeCancelReason(reason?: string): string {
  446. const normalized = reason?.replace(/\s+/g, ' ').trim();
  447. return normalized ? `cancelled: ${normalized}` : 'cancelled';
  448. }
  449. async function getSessionParentID(
  450. client: PluginInput['client'],
  451. taskID: string,
  452. ): Promise<string | undefined> {
  453. const session = client.session as unknown as {
  454. get?: (args: { path: { id: string } }) => Promise<unknown>;
  455. };
  456. if (!session.get) return undefined;
  457. try {
  458. const response = await session.get({ path: { id: taskID } });
  459. const data = (response as { data?: unknown }).data;
  460. if (!isObjectRecord(data)) return undefined;
  461. const parentID = data.parentID;
  462. return typeof parentID === 'string' ? parentID : undefined;
  463. } catch (error) {
  464. log('[cancel-task] session metadata lookup failed', {
  465. taskID,
  466. error: error instanceof Error ? error.message : String(error),
  467. });
  468. return undefined;
  469. }
  470. }
  471. function unknownTaskOutput(taskID: string, message: string): string {
  472. return [
  473. `task_id: ${taskID}`,
  474. 'state: unknown',
  475. '',
  476. '<task_error>',
  477. message,
  478. '</task_error>',
  479. ].join('\n');
  480. }