|
@@ -5,7 +5,6 @@ import {
|
|
|
} from '@opencode-ai/plugin';
|
|
} from '@opencode-ai/plugin';
|
|
|
import type { BackgroundJobLease } from '../utils/background-job-board';
|
|
import type { BackgroundJobLease } from '../utils/background-job-board';
|
|
|
import type { BackgroundJobStore } from '../utils/background-job-store';
|
|
import type { BackgroundJobStore } from '../utils/background-job-store';
|
|
|
-import { log } from '../utils/logger';
|
|
|
|
|
import { getClient } from '../utils/opencode-client';
|
|
import { getClient } from '../utils/opencode-client';
|
|
|
import { delay } from '../utils/polling';
|
|
import { delay } from '../utils/polling';
|
|
|
import {
|
|
import {
|
|
@@ -13,10 +12,14 @@ import {
|
|
|
SESSION_ID_PATTERN,
|
|
SESSION_ID_PATTERN,
|
|
|
withTimeout,
|
|
withTimeout,
|
|
|
} from '../utils/session';
|
|
} from '../utils/session';
|
|
|
|
|
+import {
|
|
|
|
|
+ getRuntimeSessionStatusSnapshot,
|
|
|
|
|
+ runtimeSessionStatus,
|
|
|
|
|
+} from '../utils/session-runtime-status';
|
|
|
|
|
|
|
|
const z = tool.schema;
|
|
const z = tool.schema;
|
|
|
|
|
|
|
|
-interface CancelTaskToolOptions {
|
|
|
|
|
|
|
+export interface TaskControlToolOptions {
|
|
|
input: PluginInput;
|
|
input: PluginInput;
|
|
|
backgroundJobBoard: BackgroundJobStore;
|
|
backgroundJobBoard: BackgroundJobStore;
|
|
|
shouldManageSession: (sessionID: string) => boolean;
|
|
shouldManageSession: (sessionID: string) => boolean;
|
|
@@ -24,12 +27,14 @@ interface CancelTaskToolOptions {
|
|
|
verifyAbortMs?: number;
|
|
verifyAbortMs?: number;
|
|
|
abortRetryIntervalMs?: number;
|
|
abortRetryIntervalMs?: number;
|
|
|
stableStoppedMs?: number;
|
|
stableStoppedMs?: number;
|
|
|
- deleteTimeoutMs?: number;
|
|
|
|
|
- deleteVerifyMs?: number;
|
|
|
|
|
- deleteStableStoppedMs?: number;
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-class SessionStillRunningError extends Error {}
|
|
|
|
|
|
|
+interface CapturedExecution {
|
|
|
|
|
+ taskID: string;
|
|
|
|
|
+ generation: number;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export class SessionStillRunningError extends Error {}
|
|
|
|
|
|
|
|
class LeaseOwnershipLostError extends Error {}
|
|
class LeaseOwnershipLostError extends Error {}
|
|
|
|
|
|
|
@@ -44,12 +49,12 @@ class LeaseOperationTimeoutError extends Error {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export function createCancelTaskTool(
|
|
export function createCancelTaskTool(
|
|
|
- options: CancelTaskToolOptions,
|
|
|
|
|
-): Record<string, ToolDefinition> {
|
|
|
|
|
- const cancel_task = tool({
|
|
|
|
|
- description: `Cancel a tracked background specialist task.
|
|
|
|
|
|
|
+ options: TaskControlToolOptions,
|
|
|
|
|
+): Record<'task_cancel', ToolDefinition> {
|
|
|
|
|
+ const task_cancel = tool({
|
|
|
|
|
+ description: `Cancel a tracked background specialist task without deleting its session.
|
|
|
|
|
|
|
|
-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.`,
|
|
|
|
|
|
|
+Use only for obsolete, wrong, conflicting, or user-requested cancellation. The retained session can be revived after the lifecycle lane acknowledges its terminal state.`,
|
|
|
args: {
|
|
args: {
|
|
|
task_id: z
|
|
task_id: z
|
|
|
.string()
|
|
.string()
|
|
@@ -57,155 +62,45 @@ Use only for obsolete, wrong, conflicting, or user-requested cancellation. Accep
|
|
|
reason: z.string().optional().describe('Short cancellation reason'),
|
|
reason: z.string().optional().describe('Short cancellation reason'),
|
|
|
},
|
|
},
|
|
|
async execute(args, toolContext) {
|
|
async execute(args, toolContext) {
|
|
|
- const parentSessionID = toolContext?.sessionID;
|
|
|
|
|
- if (!parentSessionID) throw new Error('cancel_task requires sessionID');
|
|
|
|
|
- if (toolContext.agent && toolContext.agent !== 'orchestrator') {
|
|
|
|
|
- throw new Error('cancel_task can only be used by orchestrator');
|
|
|
|
|
- }
|
|
|
|
|
- if (!options.shouldManageSession(parentSessionID)) {
|
|
|
|
|
- throw new Error(
|
|
|
|
|
- 'cancel_task can only be used in orchestrator sessions',
|
|
|
|
|
- );
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
|
|
+ const parentSessionID = assertOrchestrator(
|
|
|
|
|
+ options,
|
|
|
|
|
+ toolContext,
|
|
|
|
|
+ 'task_cancel',
|
|
|
|
|
+ );
|
|
|
const requested = args.task_id.trim();
|
|
const requested = args.task_id.trim();
|
|
|
- if (!requested) throw new Error('cancel_task requires task_id');
|
|
|
|
|
|
|
+ if (!requested) throw new Error('task_cancel requires task_id');
|
|
|
|
|
|
|
|
const job = options.backgroundJobBoard.resolve(
|
|
const job = options.backgroundJobBoard.resolve(
|
|
|
parentSessionID,
|
|
parentSessionID,
|
|
|
requested,
|
|
requested,
|
|
|
);
|
|
);
|
|
|
- log('[cancel-task] request received', {
|
|
|
|
|
- parentSessionID,
|
|
|
|
|
- requested,
|
|
|
|
|
- resolvedTaskID: job?.taskID,
|
|
|
|
|
- alias: job
|
|
|
|
|
- ? options.backgroundJobBoard.field(job.taskID, 'alias')
|
|
|
|
|
- : undefined,
|
|
|
|
|
- state: job
|
|
|
|
|
- ? options.backgroundJobBoard.field(job.taskID, 'state')
|
|
|
|
|
- : undefined,
|
|
|
|
|
- terminalState: job
|
|
|
|
|
- ? options.backgroundJobBoard.field(job.taskID, 'terminalState')
|
|
|
|
|
- : undefined,
|
|
|
|
|
- cancellationRequested: job?.cancellationRequested,
|
|
|
|
|
- });
|
|
|
|
|
if (!job) {
|
|
if (!job) {
|
|
|
- if (SESSION_ID_PATTERN.test(requested)) {
|
|
|
|
|
- if (requested === parentSessionID) {
|
|
|
|
|
- log('[cancel-task] rejected parent session cancellation', {
|
|
|
|
|
- parentSessionID,
|
|
|
|
|
- taskID: requested,
|
|
|
|
|
- });
|
|
|
|
|
- return unknownTaskOutput(requested, 'cannot cancel parent session');
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const knownJob = options.backgroundJobBoard.get(requested);
|
|
|
|
|
- const ownerParentSessionID =
|
|
|
|
|
- options.backgroundJobBoard.getParentSessionID(requested);
|
|
|
|
|
- if (knownJob && ownerParentSessionID !== parentSessionID) {
|
|
|
|
|
- log('[cancel-task] rejected unowned tracked raw session', {
|
|
|
|
|
- parentSessionID,
|
|
|
|
|
- taskID: requested,
|
|
|
|
|
- ownerParentSessionID,
|
|
|
|
|
- });
|
|
|
|
|
- return unknownTaskOutput(
|
|
|
|
|
- requested,
|
|
|
|
|
- 'unknown or unowned background task',
|
|
|
|
|
- );
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const parentID = await getSessionParentID(options.input, requested);
|
|
|
|
|
- if (parentID !== parentSessionID) {
|
|
|
|
|
- log('[cancel-task] rejected raw session without parent ownership', {
|
|
|
|
|
- parentSessionID,
|
|
|
|
|
- taskID: requested,
|
|
|
|
|
- actualParentID: parentID,
|
|
|
|
|
- });
|
|
|
|
|
- return unknownTaskOutput(
|
|
|
|
|
- requested,
|
|
|
|
|
- 'unknown or unowned background task',
|
|
|
|
|
- );
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- log(
|
|
|
|
|
- '[cancel-task] refusing destructive action for untracked raw session',
|
|
|
|
|
- {
|
|
|
|
|
- parentSessionID,
|
|
|
|
|
- taskID: requested,
|
|
|
|
|
- },
|
|
|
|
|
- );
|
|
|
|
|
- return unknownTaskOutput(
|
|
|
|
|
- requested,
|
|
|
|
|
- 'best-effort/uncertain cancellation: session ownership was observed, but no tracked generation exists; no remote abort or delete was attempted',
|
|
|
|
|
- );
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
return unknownTaskOutput(
|
|
return unknownTaskOutput(
|
|
|
requested,
|
|
requested,
|
|
|
- 'unknown or unowned background task',
|
|
|
|
|
|
|
+ await untrackedTaskReason(options, parentSessionID, requested),
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const capturedExecution = {
|
|
|
|
|
|
|
+ const execution = {
|
|
|
taskID: job.taskID,
|
|
taskID: job.taskID,
|
|
|
generation: job.generation,
|
|
generation: job.generation,
|
|
|
};
|
|
};
|
|
|
- const cancellationLease =
|
|
|
|
|
- options.backgroundJobBoard.acquireCancellationLease(
|
|
|
|
|
- capturedExecution.taskID,
|
|
|
|
|
- capturedExecution.generation,
|
|
|
|
|
- );
|
|
|
|
|
- if (!cancellationLease) {
|
|
|
|
|
|
|
+ if (job.state !== 'running') {
|
|
|
return staleCancellationOutput(
|
|
return staleCancellationOutput(
|
|
|
options,
|
|
options,
|
|
|
- capturedExecution,
|
|
|
|
|
- 'cancellation lease unavailable; no remote operation was attempted',
|
|
|
|
|
|
|
+ execution,
|
|
|
|
|
+ `task is ${job.state}, not running`,
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
try {
|
|
try {
|
|
|
- await abortAndVerifySession(
|
|
|
|
|
- options,
|
|
|
|
|
- capturedExecution,
|
|
|
|
|
- cancellationLease,
|
|
|
|
|
- );
|
|
|
|
|
- if (!options.backgroundJobBoard.validateLease(cancellationLease)) {
|
|
|
|
|
- return staleCancellationOutput(options, capturedExecution);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ await cancelTrackedExecution(options, execution, args.reason);
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
- const stillRunning = error instanceof SessionStillRunningError;
|
|
|
|
|
- const boardRunning = options.backgroundJobBoard.isRunning(
|
|
|
|
|
- capturedExecution.taskID,
|
|
|
|
|
- );
|
|
|
|
|
- log('[cancel-task] abort failed', {
|
|
|
|
|
- taskID: capturedExecution.taskID,
|
|
|
|
|
- stillRunning,
|
|
|
|
|
- boardRunning,
|
|
|
|
|
- error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
- });
|
|
|
|
|
- if (!options.backgroundJobBoard.validateLease(cancellationLease)) {
|
|
|
|
|
- return staleCancellationOutput(options, capturedExecution);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ const current = options.backgroundJobBoard.get(execution.taskID);
|
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
|
- const updated = options.backgroundJobBoard.markStatusUncertain(
|
|
|
|
|
- capturedExecution.taskID,
|
|
|
|
|
- message,
|
|
|
|
|
- capturedExecution.generation,
|
|
|
|
|
- );
|
|
|
|
|
- const quarantined =
|
|
|
|
|
- error instanceof LeaseOperationTimeoutError && error.pending;
|
|
|
|
|
- if (!isCapturedExecution(updated, capturedExecution)) {
|
|
|
|
|
- if (!quarantined) {
|
|
|
|
|
- options.backgroundJobBoard.releaseLease(cancellationLease);
|
|
|
|
|
- }
|
|
|
|
|
- return staleCancellationOutput(options, capturedExecution);
|
|
|
|
|
- }
|
|
|
|
|
- if (!quarantined) {
|
|
|
|
|
- options.backgroundJobBoard.releaseLease(cancellationLease);
|
|
|
|
|
- }
|
|
|
|
|
return [
|
|
return [
|
|
|
- `task_id: ${capturedExecution.taskID}`,
|
|
|
|
|
- `state: ${updated?.state ?? 'unknown'}`,
|
|
|
|
|
|
|
+ `task_id: ${execution.taskID}`,
|
|
|
|
|
+ `state: ${current?.state ?? 'unknown'}`,
|
|
|
'',
|
|
'',
|
|
|
'<task_error>',
|
|
'<task_error>',
|
|
|
message,
|
|
message,
|
|
@@ -213,328 +108,181 @@ Use only for obsolete, wrong, conflicting, or user-requested cancellation. Accep
|
|
|
].join('\n');
|
|
].join('\n');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const cancellationOptions = {
|
|
|
|
|
- force: true,
|
|
|
|
|
- expectedGeneration: capturedExecution.generation,
|
|
|
|
|
- cancellationLease,
|
|
|
|
|
- };
|
|
|
|
|
- const marked = options.backgroundJobBoard.markCancelled(
|
|
|
|
|
- capturedExecution.taskID,
|
|
|
|
|
- args.reason,
|
|
|
|
|
- Date.now(),
|
|
|
|
|
- cancellationOptions,
|
|
|
|
|
- );
|
|
|
|
|
- if (!isCapturedExecution(marked, capturedExecution)) {
|
|
|
|
|
- options.backgroundJobBoard.releaseLease(cancellationLease);
|
|
|
|
|
- return staleCancellationOutput(options, capturedExecution);
|
|
|
|
|
- }
|
|
|
|
|
- if (!options.backgroundJobBoard.validateLease(cancellationLease)) {
|
|
|
|
|
- return staleCancellationOutput(options, capturedExecution);
|
|
|
|
|
- }
|
|
|
|
|
- const state = options.backgroundJobBoard.getState(
|
|
|
|
|
- capturedExecution.taskID,
|
|
|
|
|
- );
|
|
|
|
|
- log('[cancel-task] marked job cancelled after verified abort', {
|
|
|
|
|
- taskID: capturedExecution.taskID,
|
|
|
|
|
- alias: options.backgroundJobBoard.field(
|
|
|
|
|
- capturedExecution.taskID,
|
|
|
|
|
- 'alias',
|
|
|
|
|
- ),
|
|
|
|
|
- state,
|
|
|
|
|
- cancellationRequested: options.backgroundJobBoard.field(
|
|
|
|
|
- capturedExecution.taskID,
|
|
|
|
|
- 'cancellationRequested',
|
|
|
|
|
- ),
|
|
|
|
|
- });
|
|
|
|
|
- options.backgroundJobBoard.releaseLease(cancellationLease);
|
|
|
|
|
-
|
|
|
|
|
|
|
+ const state = options.backgroundJobBoard.getState(execution.taskID);
|
|
|
return [
|
|
return [
|
|
|
- `task_id: ${capturedExecution.taskID}`,
|
|
|
|
|
|
|
+ `task_id: ${execution.taskID}`,
|
|
|
`state: ${state ?? 'cancelled'}`,
|
|
`state: ${state ?? 'cancelled'}`,
|
|
|
'',
|
|
'',
|
|
|
'<task_error>',
|
|
'<task_error>',
|
|
|
- options.backgroundJobBoard.getResultSummary(capturedExecution.taskID) ??
|
|
|
|
|
|
|
+ options.backgroundJobBoard.getResultSummary(execution.taskID) ??
|
|
|
'cancelled',
|
|
'cancelled',
|
|
|
'</task_error>',
|
|
'</task_error>',
|
|
|
].join('\n');
|
|
].join('\n');
|
|
|
},
|
|
},
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- return { cancel_task };
|
|
|
|
|
|
|
+ return { task_cancel };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-async function abortAndVerifySession(
|
|
|
|
|
- options: CancelTaskToolOptions,
|
|
|
|
|
- execution: { taskID: string; generation: number },
|
|
|
|
|
- lease: BackgroundJobLease,
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * Abort one captured generation and prove that its retained host session is
|
|
|
|
|
+ * quiescent. This is shared by task_cancel and task_revive; neither operation
|
|
|
|
|
+ * ever deletes the session.
|
|
|
|
|
+ */
|
|
|
|
|
+export async function cancelTrackedExecution(
|
|
|
|
|
+ options: TaskControlToolOptions,
|
|
|
|
|
+ execution: CapturedExecution,
|
|
|
|
|
+ reason?: string,
|
|
|
): Promise<void> {
|
|
): Promise<void> {
|
|
|
- const taskID = execution.taskID;
|
|
|
|
|
- let abortConfirmed = false;
|
|
|
|
|
- log('[cancel-task] abort attempt starting', { taskID });
|
|
|
|
|
- assertLease(options.backgroundJobBoard, lease, execution);
|
|
|
|
|
- try {
|
|
|
|
|
- const response = await awaitLeaseOperation(
|
|
|
|
|
- options.backgroundJobBoard,
|
|
|
|
|
- lease,
|
|
|
|
|
- () => getClient(options.input).session.abort({ path: { id: taskID } }),
|
|
|
|
|
- options.abortTimeoutMs ?? 10_000,
|
|
|
|
|
- `Session abort timed out after ${options.abortTimeoutMs ?? 10_000}ms`,
|
|
|
|
|
|
|
+ const lease = options.backgroundJobBoard.acquireCancellationLease(
|
|
|
|
|
+ execution.taskID,
|
|
|
|
|
+ execution.generation,
|
|
|
|
|
+ );
|
|
|
|
|
+ if (!lease) {
|
|
|
|
|
+ throw new Error(
|
|
|
|
|
+ `stale/uncertain cancellation: cancellation lease unavailable for ${execution.taskID}`,
|
|
|
);
|
|
);
|
|
|
- assertLease(options.backgroundJobBoard, lease, execution);
|
|
|
|
|
- const responseError = operationError(response);
|
|
|
|
|
- if (responseError !== undefined) throw responseError;
|
|
|
|
|
- const responseData = operationBoolean(response);
|
|
|
|
|
- if (responseData === false) {
|
|
|
|
|
- throw new Error(`Session abort was not confirmed: ${taskID}`);
|
|
|
|
|
- }
|
|
|
|
|
- abortConfirmed = responseData === true;
|
|
|
|
|
- log('[cancel-task] abort call returned', { taskID });
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- if (error instanceof LeaseOperationTimeoutError) throw error;
|
|
|
|
|
- assertLease(options.backgroundJobBoard, lease, execution);
|
|
|
|
|
- abortConfirmed = isExplicitSessionAbsence(error);
|
|
|
|
|
- log('[cancel-task] abort call failed', {
|
|
|
|
|
- taskID,
|
|
|
|
|
- error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
- });
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // ponytail: v1 had a polling loop here that verified abort succeeded before
|
|
|
|
|
- // proceeding to delete. v2 abort is server-side and synchronous — the delete
|
|
|
|
|
- // verification loop below catches any remaining running state.
|
|
|
|
|
- assertLease(options.backgroundJobBoard, lease, execution);
|
|
|
|
|
|
|
+ let keepLeaseUntilSettled = false;
|
|
|
try {
|
|
try {
|
|
|
- await deleteAndVerifySession(
|
|
|
|
|
- options,
|
|
|
|
|
- execution,
|
|
|
|
|
- lease,
|
|
|
|
|
- 'cancel-task-after-abort',
|
|
|
|
|
|
|
+ await abortAndVerifySession(options, execution, lease);
|
|
|
|
|
+ assertCapturedExecution(options.backgroundJobBoard, execution);
|
|
|
|
|
+ const marked = options.backgroundJobBoard.markCancelled(
|
|
|
|
|
+ execution.taskID,
|
|
|
|
|
+ reason,
|
|
|
|
|
+ Date.now(),
|
|
|
|
|
+ {
|
|
|
|
|
+ force: true,
|
|
|
|
|
+ expectedGeneration: execution.generation,
|
|
|
|
|
+ cancellationLease: lease,
|
|
|
|
|
+ },
|
|
|
);
|
|
);
|
|
|
|
|
+ if (!isCapturedExecution(marked, execution)) {
|
|
|
|
|
+ throw new Error(
|
|
|
|
|
+ `stale/uncertain cancellation: ${execution.taskID} generation changed`,
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
- if (error instanceof LeaseOperationTimeoutError) throw error;
|
|
|
|
|
- // A confirmed native abort or an explicit not-found response is already
|
|
|
|
|
- // terminal evidence. A transport/unknown abort failure is not evidence;
|
|
|
|
|
- // in that case a failed delete must remain uncertain as well.
|
|
|
|
|
- if (abortConfirmed) return;
|
|
|
|
|
|
|
+ keepLeaseUntilSettled =
|
|
|
|
|
+ error instanceof LeaseOperationTimeoutError && error.pending;
|
|
|
|
|
+ const message = error instanceof Error ? error.message : String(error);
|
|
|
|
|
+ options.backgroundJobBoard.markStatusUncertain(
|
|
|
|
|
+ execution.taskID,
|
|
|
|
|
+ message,
|
|
|
|
|
+ execution.generation,
|
|
|
|
|
+ );
|
|
|
throw error;
|
|
throw error;
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (!keepLeaseUntilSettled) {
|
|
|
|
|
+ options.backgroundJobBoard.releaseLease(lease);
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-async function deleteAndVerifySession(
|
|
|
|
|
- options: CancelTaskToolOptions,
|
|
|
|
|
- execution: { taskID: string; generation: number },
|
|
|
|
|
|
|
+async function abortAndVerifySession(
|
|
|
|
|
+ options: TaskControlToolOptions,
|
|
|
|
|
+ execution: CapturedExecution,
|
|
|
lease: BackgroundJobLease,
|
|
lease: BackgroundJobLease,
|
|
|
- reason: string,
|
|
|
|
|
): Promise<void> {
|
|
): Promise<void> {
|
|
|
- const taskID = execution.taskID;
|
|
|
|
|
- const client = getClient(options.input);
|
|
|
|
|
-
|
|
|
|
|
assertLease(options.backgroundJobBoard, lease, execution);
|
|
assertLease(options.backgroundJobBoard, lease, execution);
|
|
|
- log('[cancel-task] deleting session after abort attempt', {
|
|
|
|
|
- taskID,
|
|
|
|
|
- reason,
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ const taskID = execution.taskID;
|
|
|
|
|
+ let response: unknown;
|
|
|
try {
|
|
try {
|
|
|
- const response = await awaitLeaseOperation(
|
|
|
|
|
|
|
+ response = await awaitLeaseOperation(
|
|
|
options.backgroundJobBoard,
|
|
options.backgroundJobBoard,
|
|
|
lease,
|
|
lease,
|
|
|
- () =>
|
|
|
|
|
- client.session.delete({
|
|
|
|
|
- path: { id: taskID },
|
|
|
|
|
- query: { directory: options.input.directory },
|
|
|
|
|
- }),
|
|
|
|
|
- options.deleteTimeoutMs ?? 10_000,
|
|
|
|
|
- `Session delete timed out after ${options.deleteTimeoutMs ?? 10_000}ms`,
|
|
|
|
|
|
|
+ () => getClient(options.input).session.abort({ path: { id: taskID } }),
|
|
|
|
|
+ options.abortTimeoutMs ?? 10_000,
|
|
|
|
|
+ `Session abort timed out after ${options.abortTimeoutMs ?? 10_000}ms`,
|
|
|
);
|
|
);
|
|
|
- assertLease(options.backgroundJobBoard, lease, execution);
|
|
|
|
|
- const responseError = operationError(response);
|
|
|
|
|
- if (responseError !== undefined) throw responseError;
|
|
|
|
|
- const responseData = operationBoolean(response);
|
|
|
|
|
- if (responseData === false) {
|
|
|
|
|
- throw new Error(`Session delete was not confirmed: ${taskID}`);
|
|
|
|
|
- }
|
|
|
|
|
- log('[cancel-task] session delete returned', { taskID, reason });
|
|
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
- if (error instanceof LeaseOperationTimeoutError) throw error;
|
|
|
|
|
assertLease(options.backgroundJobBoard, lease, execution);
|
|
assertLease(options.backgroundJobBoard, lease, execution);
|
|
|
- if (isExplicitSessionAbsence(error)) {
|
|
|
|
|
- log('[cancel-task] session delete confirmed missing/deleted', {
|
|
|
|
|
- taskID,
|
|
|
|
|
- reason,
|
|
|
|
|
- error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
- });
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- log('[cancel-task] session delete failed; verifying live state', {
|
|
|
|
|
- taskID,
|
|
|
|
|
- reason,
|
|
|
|
|
- error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
- });
|
|
|
|
|
- const status = await getSessionStatus(
|
|
|
|
|
- options.input,
|
|
|
|
|
- taskID,
|
|
|
|
|
- options.deleteVerifyMs ?? 1_500,
|
|
|
|
|
- lease,
|
|
|
|
|
- options.backgroundJobBoard,
|
|
|
|
|
- );
|
|
|
|
|
- assertLease(options.backgroundJobBoard, lease, execution);
|
|
|
|
|
- log('[cancel-task] delete failure verification status', {
|
|
|
|
|
- taskID,
|
|
|
|
|
- reason,
|
|
|
|
|
- status: status.status,
|
|
|
|
|
- statusSource: status.source,
|
|
|
|
|
- statusKeys: status.keys,
|
|
|
|
|
- });
|
|
|
|
|
- if (status.status === 'busy' || status.status === 'retry') {
|
|
|
|
|
- throw new SessionStillRunningError(
|
|
|
|
|
- `Session delete failed and task is still busy: ${taskID}`,
|
|
|
|
|
- );
|
|
|
|
|
- }
|
|
|
|
|
- // An idle or missing status entry is only a liveness observation. It does
|
|
|
|
|
- // not prove that a failed delete or abort reached the server, so preserve
|
|
|
|
|
- // the operation error and let the caller expose an uncertain/error state.
|
|
|
|
|
throw error;
|
|
throw error;
|
|
|
}
|
|
}
|
|
|
|
|
+ assertLease(options.backgroundJobBoard, lease, execution);
|
|
|
|
|
+ const responseError = operationError(response);
|
|
|
|
|
+ if (responseError !== undefined) throw new Error(errorText(responseError));
|
|
|
|
|
+ if (operationBoolean(response) === false) {
|
|
|
|
|
+ throw new Error(`Session abort was not confirmed: ${taskID}`);
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- const deadline = Date.now() + (options.deleteVerifyMs ?? 1_500);
|
|
|
|
|
- const stableStoppedMs = options.deleteStableStoppedMs ?? 300;
|
|
|
|
|
|
|
+ await verifyQuiescentSession(options, execution, lease);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function verifyQuiescentSession(
|
|
|
|
|
+ options: TaskControlToolOptions,
|
|
|
|
|
+ execution: CapturedExecution,
|
|
|
|
|
+ lease: BackgroundJobLease,
|
|
|
|
|
+): Promise<void> {
|
|
|
|
|
+ const deadline = Date.now() + (options.verifyAbortMs ?? 1_500);
|
|
|
|
|
+ const stableStoppedMs = options.stableStoppedMs ?? 300;
|
|
|
const retryIntervalMs = options.abortRetryIntervalMs ?? 150;
|
|
const retryIntervalMs = options.abortRetryIntervalMs ?? 150;
|
|
|
let stableStoppedSince: number | undefined;
|
|
let stableStoppedSince: number | undefined;
|
|
|
- let attempts = 0;
|
|
|
|
|
let lastStatus: string | undefined;
|
|
let lastStatus: string | undefined;
|
|
|
|
|
+
|
|
|
while (Date.now() <= deadline) {
|
|
while (Date.now() <= deadline) {
|
|
|
- attempts += 1;
|
|
|
|
|
assertLease(options.backgroundJobBoard, lease, execution);
|
|
assertLease(options.backgroundJobBoard, lease, execution);
|
|
|
const status = await getSessionStatus(
|
|
const status = await getSessionStatus(
|
|
|
options.input,
|
|
options.input,
|
|
|
- taskID,
|
|
|
|
|
|
|
+ execution.taskID,
|
|
|
Math.max(1, deadline - Date.now()),
|
|
Math.max(1, deadline - Date.now()),
|
|
|
lease,
|
|
lease,
|
|
|
options.backgroundJobBoard,
|
|
options.backgroundJobBoard,
|
|
|
);
|
|
);
|
|
|
assertLease(options.backgroundJobBoard, lease, execution);
|
|
assertLease(options.backgroundJobBoard, lease, execution);
|
|
|
lastStatus = status.status;
|
|
lastStatus = status.status;
|
|
|
- log('[cancel-task] delete verification status', {
|
|
|
|
|
- taskID,
|
|
|
|
|
- reason,
|
|
|
|
|
- attempts,
|
|
|
|
|
- status: status.status,
|
|
|
|
|
- statusSource: status.source,
|
|
|
|
|
- statusKeys: status.keys,
|
|
|
|
|
- stableStoppedSince,
|
|
|
|
|
- });
|
|
|
|
|
- const quiescent =
|
|
|
|
|
- status.status === 'idle' || status.source === 'missing-from-map';
|
|
|
|
|
|
|
+ const quiescent = status.status === 'idle';
|
|
|
if (!quiescent) {
|
|
if (!quiescent) {
|
|
|
stableStoppedSince = undefined;
|
|
stableStoppedSince = undefined;
|
|
|
await delay(retryIntervalMs);
|
|
await delay(retryIntervalMs);
|
|
|
- assertLease(options.backgroundJobBoard, lease, execution);
|
|
|
|
|
continue;
|
|
continue;
|
|
|
}
|
|
}
|
|
|
stableStoppedSince ??= Date.now();
|
|
stableStoppedSince ??= Date.now();
|
|
|
if (Date.now() - stableStoppedSince >= stableStoppedMs) return;
|
|
if (Date.now() - stableStoppedSince >= stableStoppedMs) return;
|
|
|
await delay(retryIntervalMs);
|
|
await delay(retryIntervalMs);
|
|
|
- assertLease(options.backgroundJobBoard, lease, execution);
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
throw new SessionStillRunningError(
|
|
throw new SessionStillRunningError(
|
|
|
- `Session delete returned but task did not stay stopped: ${taskID} (${lastStatus ?? 'unknown'})`,
|
|
|
|
|
|
|
+ `Session abort returned but task did not stay stopped: ${execution.taskID} (${lastStatus ?? 'unknown'})`,
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function getSessionStatus(
|
|
async function getSessionStatus(
|
|
|
input: PluginInput,
|
|
input: PluginInput,
|
|
|
taskID: string,
|
|
taskID: string,
|
|
|
- timeoutMs?: number,
|
|
|
|
|
- lease?: BackgroundJobLease,
|
|
|
|
|
- backgroundJobBoard?: BackgroundJobStore,
|
|
|
|
|
-): Promise<{
|
|
|
|
|
- status: string | undefined;
|
|
|
|
|
- source: string;
|
|
|
|
|
- keys: string[];
|
|
|
|
|
-}> {
|
|
|
|
|
- if (!lease || !backgroundJobBoard) {
|
|
|
|
|
- throw new LeaseOwnershipLostError(
|
|
|
|
|
- `Session status lookup requires a live cancellation lease: ${taskID}`,
|
|
|
|
|
- );
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ timeoutMs: number,
|
|
|
|
|
+ lease: BackgroundJobLease,
|
|
|
|
|
+ backgroundJobBoard: BackgroundJobStore,
|
|
|
|
|
+): Promise<{ status: 'busy' | 'retry' | 'idle' | undefined; source: string }> {
|
|
|
assertLease(backgroundJobBoard, lease, {
|
|
assertLease(backgroundJobBoard, lease, {
|
|
|
taskID: lease.taskID,
|
|
taskID: lease.taskID,
|
|
|
generation: lease.generation,
|
|
generation: lease.generation,
|
|
|
});
|
|
});
|
|
|
-
|
|
|
|
|
- let response: unknown;
|
|
|
|
|
try {
|
|
try {
|
|
|
- response = await awaitLeaseOperation(
|
|
|
|
|
|
|
+ const snapshot = await awaitLeaseOperation(
|
|
|
backgroundJobBoard,
|
|
backgroundJobBoard,
|
|
|
lease,
|
|
lease,
|
|
|
() =>
|
|
() =>
|
|
|
- getClient(input).session.status({
|
|
|
|
|
- query: { directory: input.directory },
|
|
|
|
|
|
|
+ getRuntimeSessionStatusSnapshot(input, {
|
|
|
|
|
+ timeoutMs: Math.max(1, timeoutMs),
|
|
|
}),
|
|
}),
|
|
|
- Math.max(1, timeoutMs ?? 5_000),
|
|
|
|
|
- `Session status lookup timed out after ${Math.max(1, timeoutMs ?? 5_000)}ms`,
|
|
|
|
|
|
|
+ Math.max(1, timeoutMs),
|
|
|
|
|
+ `Session status lookup timed out after ${Math.max(1, timeoutMs)}ms`,
|
|
|
);
|
|
);
|
|
|
- } catch (error) {
|
|
|
|
|
- if (error instanceof LeaseOperationTimeoutError) throw error;
|
|
|
|
|
- if (error instanceof LeaseOwnershipLostError) throw error;
|
|
|
|
|
|
|
+ const status = runtimeSessionStatus(snapshot, taskID);
|
|
|
|
|
+ if (status !== undefined) return { status, source: 'task-map-entry' };
|
|
|
return {
|
|
return {
|
|
|
status: undefined,
|
|
status: undefined,
|
|
|
- source: 'lookup-error',
|
|
|
|
|
- keys: [],
|
|
|
|
|
|
|
+ source: snapshot.error
|
|
|
|
|
+ ? 'lookup-error'
|
|
|
|
|
+ : snapshot.malformedSessionIDs.has(taskID)
|
|
|
|
|
+ ? 'malformed-task-map-entry'
|
|
|
|
|
+ : 'missing-from-map',
|
|
|
};
|
|
};
|
|
|
- }
|
|
|
|
|
- assertLease(backgroundJobBoard, lease, {
|
|
|
|
|
- taskID: lease.taskID,
|
|
|
|
|
- generation: lease.generation,
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- const data = isRecord(response) ? response.data : undefined;
|
|
|
|
|
- if (
|
|
|
|
|
- !isRecord(data) ||
|
|
|
|
|
- Object.hasOwn(data, 'type') ||
|
|
|
|
|
- Object.hasOwn(data, 'status')
|
|
|
|
|
- ) {
|
|
|
|
|
- return { status: undefined, source: 'lookup-error', keys: [] };
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const statuses = new Map<string, 'busy' | 'retry' | 'idle'>();
|
|
|
|
|
- const malformedSessionIDs = new Set<string>();
|
|
|
|
|
- for (const [sessionID, value] of Object.entries(data)) {
|
|
|
|
|
- if (
|
|
|
|
|
- isRecord(value) &&
|
|
|
|
|
- (value.type === 'busy' || value.type === 'retry' || value.type === 'idle')
|
|
|
|
|
- ) {
|
|
|
|
|
- statuses.set(sessionID, value.type);
|
|
|
|
|
- } else {
|
|
|
|
|
- malformedSessionIDs.add(sessionID);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- return {
|
|
|
|
|
- status: malformedSessionIDs.has(taskID) ? undefined : statuses.get(taskID),
|
|
|
|
|
- source: malformedSessionIDs.has(taskID)
|
|
|
|
|
- ? 'malformed-entry'
|
|
|
|
|
- : statuses.has(taskID)
|
|
|
|
|
- ? 'task-map-entry'
|
|
|
|
|
- : 'missing-from-map',
|
|
|
|
|
- keys: [...statuses.keys()].slice(0, 20),
|
|
|
|
|
- };
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function assertLease(
|
|
|
|
|
- backgroundJobBoard: BackgroundJobStore,
|
|
|
|
|
- lease: BackgroundJobLease,
|
|
|
|
|
- execution: { taskID: string; generation: number },
|
|
|
|
|
-): void {
|
|
|
|
|
- if (
|
|
|
|
|
- lease.taskID !== execution.taskID ||
|
|
|
|
|
- lease.generation !== execution.generation ||
|
|
|
|
|
- lease.kind !== 'cancellation' ||
|
|
|
|
|
- !backgroundJobBoard.validateLease(lease)
|
|
|
|
|
- ) {
|
|
|
|
|
- throw new LeaseOwnershipLostError(
|
|
|
|
|
- `Cancellation lease is no longer valid for ${execution.taskID} generation ${execution.generation}`,
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ if (error instanceof LeaseOperationTimeoutError) throw error;
|
|
|
|
|
+ return { status: undefined, source: 'lookup-error' };
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -572,10 +320,79 @@ async function awaitLeaseOperation<T>(
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function assertLease(
|
|
|
|
|
+ backgroundJobBoard: BackgroundJobStore,
|
|
|
|
|
+ lease: BackgroundJobLease,
|
|
|
|
|
+ execution: CapturedExecution,
|
|
|
|
|
+): void {
|
|
|
|
|
+ if (
|
|
|
|
|
+ lease.taskID !== execution.taskID ||
|
|
|
|
|
+ lease.generation !== execution.generation ||
|
|
|
|
|
+ lease.kind !== 'cancellation' ||
|
|
|
|
|
+ !backgroundJobBoard.validateLease(lease)
|
|
|
|
|
+ ) {
|
|
|
|
|
+ throw new LeaseOwnershipLostError(
|
|
|
|
|
+ `Cancellation lease is no longer valid for ${execution.taskID} generation ${execution.generation}`,
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function assertOrchestrator(
|
|
|
|
|
+ options: TaskControlToolOptions,
|
|
|
|
|
+ toolContext: { sessionID?: string; agent?: string } | undefined,
|
|
|
|
|
+ toolName: string,
|
|
|
|
|
+): string {
|
|
|
|
|
+ const parentSessionID = toolContext?.sessionID;
|
|
|
|
|
+ if (!parentSessionID) throw new Error(`${toolName} requires sessionID`);
|
|
|
|
|
+ if (toolContext.agent && toolContext.agent !== 'orchestrator') {
|
|
|
|
|
+ throw new Error(`${toolName} can only be used by orchestrator`);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!options.shouldManageSession(parentSessionID)) {
|
|
|
|
|
+ throw new Error(`${toolName} can only be used in orchestrator sessions`);
|
|
|
|
|
+ }
|
|
|
|
|
+ return parentSessionID;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function untrackedTaskReason(
|
|
|
|
|
+ options: TaskControlToolOptions,
|
|
|
|
|
+ parentSessionID: string,
|
|
|
|
|
+ requested: string,
|
|
|
|
|
+): Promise<string> {
|
|
|
|
|
+ if (!SESSION_ID_PATTERN.test(requested))
|
|
|
|
|
+ return 'unknown or unowned background task';
|
|
|
|
|
+ if (requested === parentSessionID) return 'cannot cancel parent session';
|
|
|
|
|
+ const knownJob = options.backgroundJobBoard.get(requested);
|
|
|
|
|
+ if (
|
|
|
|
|
+ knownJob &&
|
|
|
|
|
+ options.backgroundJobBoard.getParentSessionID(requested) !== parentSessionID
|
|
|
|
|
+ ) {
|
|
|
|
|
+ return 'unknown or unowned background task';
|
|
|
|
|
+ }
|
|
|
|
|
+ const owner = await getSessionParentID(options.input, requested);
|
|
|
|
|
+ if (owner !== parentSessionID) return 'unknown or unowned background task';
|
|
|
|
|
+ return 'best-effort/uncertain cancellation: session ownership was observed, but no tracked generation exists; no remote abort was attempted';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function getSessionParentID(
|
|
|
|
|
+ input: PluginInput,
|
|
|
|
|
+ taskID: string,
|
|
|
|
|
+): Promise<string | undefined> {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const response = await getClient(input).session.get({
|
|
|
|
|
+ path: { id: taskID },
|
|
|
|
|
+ query: { directory: input.directory },
|
|
|
|
|
+ });
|
|
|
|
|
+ return response.data?.parentID;
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return undefined;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function operationError(response: unknown): unknown {
|
|
function operationError(response: unknown): unknown {
|
|
|
if (!isRecord(response)) return undefined;
|
|
if (!isRecord(response)) return undefined;
|
|
|
- const error = response.error;
|
|
|
|
|
- return error === undefined || error === null ? undefined : error;
|
|
|
|
|
|
|
+ return response.error === undefined || response.error === null
|
|
|
|
|
+ ? undefined
|
|
|
|
|
+ : response.error;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function operationBoolean(response: unknown): boolean | undefined {
|
|
function operationBoolean(response: unknown): boolean | undefined {
|
|
@@ -584,31 +401,6 @@ function operationBoolean(response: unknown): boolean | undefined {
|
|
|
return typeof response.data === 'boolean' ? response.data : undefined;
|
|
return typeof response.data === 'boolean' ? response.data : undefined;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function isExplicitSessionAbsence(error: unknown): boolean {
|
|
|
|
|
- const statusCode = findStatusCode(error);
|
|
|
|
|
- if (statusCode === 404) return true;
|
|
|
|
|
-
|
|
|
|
|
- const text = errorText(error);
|
|
|
|
|
- return /\b(?:not[\s_-]?found(?:error)?|no such (?:session|resource)|does not exist|already[\s_-]?deleted|session[\s_-]?deleted)\b/i.test(
|
|
|
|
|
- text,
|
|
|
|
|
- );
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function findStatusCode(value: unknown, depth = 0): number | undefined {
|
|
|
|
|
- if (depth > 3 || !isRecord(value)) return undefined;
|
|
|
|
|
- for (const key of ['statusCode', 'status']) {
|
|
|
|
|
- const candidate = value[key];
|
|
|
|
|
- if (typeof candidate === 'number') return candidate;
|
|
|
|
|
- if (typeof candidate === 'string' && /^\d+$/.test(candidate)) {
|
|
|
|
|
- return Number(candidate);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- return (
|
|
|
|
|
- findStatusCode(value.data, depth + 1) ??
|
|
|
|
|
- findStatusCode(value.cause, depth + 1)
|
|
|
|
|
- );
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
function errorText(error: unknown): string {
|
|
function errorText(error: unknown): string {
|
|
|
if (error instanceof Error) return error.message;
|
|
if (error instanceof Error) return error.message;
|
|
|
if (typeof error === 'string') return error;
|
|
if (typeof error === 'string') return error;
|
|
@@ -623,27 +415,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
return typeof value === 'object' && value !== null;
|
|
return typeof value === 'object' && value !== null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-async function getSessionParentID(
|
|
|
|
|
- input: PluginInput,
|
|
|
|
|
- taskID: string,
|
|
|
|
|
-): Promise<string | undefined> {
|
|
|
|
|
- try {
|
|
|
|
|
- const response = await getClient(input).session.get({
|
|
|
|
|
- path: { id: taskID },
|
|
|
|
|
- query: { directory: input.directory },
|
|
|
|
|
- });
|
|
|
|
|
- const session = response.data;
|
|
|
|
|
- if (!session) return undefined;
|
|
|
|
|
- return session.parentID;
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- log('[cancel-task] session metadata lookup failed', {
|
|
|
|
|
- taskID,
|
|
|
|
|
- error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
- });
|
|
|
|
|
- return undefined;
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
function unknownTaskOutput(taskID: string, message: string): string {
|
|
function unknownTaskOutput(taskID: string, message: string): string {
|
|
|
return [
|
|
return [
|
|
|
`task_id: ${taskID}`,
|
|
`task_id: ${taskID}`,
|
|
@@ -657,7 +428,7 @@ function unknownTaskOutput(taskID: string, message: string): string {
|
|
|
|
|
|
|
|
function isCapturedExecution(
|
|
function isCapturedExecution(
|
|
|
record: ReturnType<BackgroundJobStore['get']>,
|
|
record: ReturnType<BackgroundJobStore['get']>,
|
|
|
- capturedExecution: { taskID: string; generation: number },
|
|
|
|
|
|
|
+ capturedExecution: CapturedExecution,
|
|
|
): boolean {
|
|
): boolean {
|
|
|
return (
|
|
return (
|
|
|
record?.taskID === capturedExecution.taskID &&
|
|
record?.taskID === capturedExecution.taskID &&
|
|
@@ -665,29 +436,31 @@ function isCapturedExecution(
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function assertCapturedExecution(
|
|
|
|
|
+ backgroundJobBoard: BackgroundJobStore,
|
|
|
|
|
+ execution: CapturedExecution,
|
|
|
|
|
+): void {
|
|
|
|
|
+ if (
|
|
|
|
|
+ !isCapturedExecution(backgroundJobBoard.get(execution.taskID), execution)
|
|
|
|
|
+ ) {
|
|
|
|
|
+ throw new Error(
|
|
|
|
|
+ `stale/uncertain cancellation: ${execution.taskID} generation changed`,
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function staleCancellationOutput(
|
|
function staleCancellationOutput(
|
|
|
- options: CancelTaskToolOptions,
|
|
|
|
|
- capturedExecution: { taskID: string; generation: number },
|
|
|
|
|
- detail?: string,
|
|
|
|
|
|
|
+ options: TaskControlToolOptions,
|
|
|
|
|
+ execution: CapturedExecution,
|
|
|
|
|
+ detail: string,
|
|
|
): string {
|
|
): string {
|
|
|
- const current = options.backgroundJobBoard.get(capturedExecution.taskID);
|
|
|
|
|
- const message = detail
|
|
|
|
|
- ? `stale/uncertain cancellation: ${detail}`
|
|
|
|
|
- : current
|
|
|
|
|
- ? `stale/uncertain cancellation: ${capturedExecution.taskID} changed from generation ${capturedExecution.generation} to generation ${current.generation}; the newer execution was not cancelled`
|
|
|
|
|
- : `stale/uncertain cancellation: ${capturedExecution.taskID} is no longer tracked; generation ${capturedExecution.generation} was not cancelled`;
|
|
|
|
|
- log('[cancel-task] refusing stale cancellation terminal transition', {
|
|
|
|
|
- taskID: capturedExecution.taskID,
|
|
|
|
|
- capturedGeneration: capturedExecution.generation,
|
|
|
|
|
- currentGeneration: current?.generation,
|
|
|
|
|
- currentState: current?.state,
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ const current = options.backgroundJobBoard.get(execution.taskID);
|
|
|
return [
|
|
return [
|
|
|
- `task_id: ${capturedExecution.taskID}`,
|
|
|
|
|
|
|
+ `task_id: ${execution.taskID}`,
|
|
|
`state: ${current?.state ?? 'unknown'}`,
|
|
`state: ${current?.state ?? 'unknown'}`,
|
|
|
'',
|
|
'',
|
|
|
'<task_error>',
|
|
'<task_error>',
|
|
|
- message,
|
|
|
|
|
|
|
+ `stale/uncertain cancellation: ${detail}`,
|
|
|
'</task_error>',
|
|
'</task_error>',
|
|
|
].join('\n');
|
|
].join('\n');
|
|
|
}
|
|
}
|