Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
d80cb14
feat: [ENG-2776] expose taskId-scoped cancel on agent and session
bao-byterover May 12, 2026
ea52137
Merge feat/ENG-2776: expose taskId-scoped cancel
bao-byterover May 12, 2026
9647f87
feat: [ENG-2775] agent process subscribes to task:cancel
bao-byterover May 12, 2026
58713bb
Merge feat/ENG-2775: agent process subscribes to task:cancel
bao-byterover May 12, 2026
09f59e2
feat: [ENG-2777] cancel-aware execute, queue advance, queued-task cancel
bao-byterover May 12, 2026
206e94b
Merge feat/ENG-2777: cancel-aware execute + queue advance + queued-ta…
bao-byterover May 12, 2026
b558bad
feat: [ENG-2778] integration test for end-to-end cancel pipeline
bao-byterover May 13, 2026
751e2b1
Merge feat/ENG-2778: end-to-end cancel pipeline integration test
bao-byterover May 13, 2026
af7488d
feat: [ENG-2779] shared CLI helper that emits the cancel request
bao-byterover May 13, 2026
5c277b4
Merge feat/ENG-2779: shared CLI cancel-task helper
bao-byterover May 13, 2026
31306ea
feat: [ENG-2780] brv curate --cancel flag
bao-byterover May 13, 2026
a7973fa
Merge feat/ENG-2780: brv curate --cancel flag
bao-byterover May 13, 2026
5c54119
feat: [ENG-2781] brv query --cancel flag
bao-byterover May 13, 2026
983e79d
Merge feat/ENG-2781: brv query --cancel flag
bao-byterover May 13, 2026
9e423a3
feat: [ENG-2782] brv dream --cancel flag
bao-byterover May 13, 2026
a0ededa
Merge feat/ENG-2782: brv dream --cancel flag
bao-byterover May 13, 2026
903441f
feat: [ENG-2783] foreground Ctrl-C sends cancel before exit
bao-byterover May 13, 2026
0358768
Merge feat/ENG-2783: foreground Ctrl-C sends cancel before exit
bao-byterover May 13, 2026
cda4a37
feat: [ENG-2787] TUI cancel-task API helper
bao-byterover May 13, 2026
0ef7311
Merge feat/ENG-2787: TUI cancel-task API helper
bao-byterover May 13, 2026
85151b6
feat: [ENG-2788] Ctrl+Q cancel keybind for active curate/query tasks
bao-byterover May 13, 2026
5275857
Merge feat/ENG-2788: Ctrl+Q cancel keybind for active curate/query tasks
bao-byterover May 13, 2026
c19bf93
refactor: [ENG-2783] DRY cancel-branch wiring + tighten SIGINT and qu…
bao-byterover May 13, 2026
39720dc
feat: [ENG-2783] foreground commands surface remote --cancel
bao-byterover May 13, 2026
6585258
Merge feat/ENG-2783: post-review nits + remote-cancel UX in CLI commands
bao-byterover May 13, 2026
d57edbd
fix: [ENG-2783] daemon-side idempotency for task:cancel retries
bao-byterover May 13, 2026
c6d77da
chore: [ENG-2783] log swallowed SIGINT errors + align cancel JSON suc…
bao-byterover May 13, 2026
5dd57eb
Merge feat/ENG-2783: remaining post-review cancel nits (Nit-3, Nit-4,…
bao-byterover May 13, 2026
acb2acc
fix: [ENG-2783] durable cancel idempotency via persistent history store
bao-byterover May 13, 2026
b466a0d
Merge feat/ENG-2783: durable cancel idempotency via persistent task h…
bao-byterover May 13, 2026
45a0ff0
fix: [ENG-2783] cancelTaskLocally cache miss + post-review nits
bao-byterover May 14, 2026
e196ac0
Merge branch 'main' into proj/task-cancellation
bao-byterover May 20, 2026
06b9c07
test: [ENG-2783] drop stale timeoutMs from SIGINT test options
bao-byterover May 20, 2026
5a559ff
feat: [ENG-2784] add WebUI cancel button to task list rows and detail…
ncnthien May 22, 2026
98c0eb3
feat: [ENG-2784] unify cancel state across list and detail, add pendi…
ncnthien May 22, 2026
4916a50
Merge pull request #693 from campfirein/feat/ENG-2784
ncnthien May 22, 2026
cc333d8
feat: [ENG-2906] surface ctrl+q in TUI footer, fix selector FIFO, sup…
cuongdo-byterover May 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/agent/core/interfaces/i-chat-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ import type {ILLMService} from './i-llm-service.js'
*/
export interface IChatSession {
/**
* Cancel the current operation.
* Aborts any ongoing LLM request.
* Cancel the current operation or a specific task.
* Aborts the abort controller scoped to the given taskId; when no taskId is
* provided, aborts the legacy fallback controller used by interactive runs.
*
* @param taskId - Optional task ID to target a specific in-flight run
* @returns true when a controller was found and aborted, false otherwise
*/
cancel(): void
cancel(taskId?: string): boolean

/**
* Cleanup session resources but preserve history for later restoration.
Expand Down
10 changes: 10 additions & 0 deletions src/agent/core/interfaces/i-cipher-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@ export interface ICipherAgent {
*/
cancel(): Promise<boolean>

/**
* Cancels a specific in-flight task by fanning the cancel across all live
* sessions. Idempotent: a second call for the same taskId returns false
* because the controller has already been aborted and removed.
*
* @param taskId - Task identifier (matches the taskId passed to run/streamRun)
* @returns true when any session held a controller for the task; false otherwise
*/
cancelTask(taskId: string): Promise<boolean>

/**
* Create a task-scoped child session for parallel execution.
* The session gets its own sandbox, context manager, and LLM service.
Expand Down
26 changes: 26 additions & 0 deletions src/agent/infra/agent/cipher-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,32 @@ export class CipherAgent extends BaseAgent implements ICipherAgent {
return Boolean(streamController)
}

/**
* Cancel a specific in-flight task across all live sessions.
* Fans the cancel out to every session managed by this agent; returns true
* as soon as at least one session reports it held the controller.
* Idempotent — a second call for the same taskId returns false because the
* controller has already been aborted and removed from its session.
*
* @param taskId - Task identifier (matches the taskId passed to run/streamRun)
* @returns true when any session cancelled the task, false when no session held it
*/
public async cancelTask(taskId: string): Promise<boolean> {
this.ensureStarted()

const sessionManager = this.getSessionManagerInternal()
let cancelled = false
for (const sessionId of sessionManager.listSessions()) {
const session = sessionManager.getSession(sessionId)
if (!session) continue
if (session.cancel(taskId)) {
cancelled = true
}
}

return cancelled
}

// === Public Methods (alphabetical order) ===

protected override async cleanupServices(): Promise<void> {
Expand Down
21 changes: 13 additions & 8 deletions src/agent/infra/session/chat-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,18 +95,23 @@ export class ChatSession implements IChatSession {

/**
* Cancel the current operation or a specific task.
* @param taskId - Optional taskId to cancel specific task, otherwise cancels fallback controller
*
* @param taskId - Optional taskId to cancel a specific task, otherwise cancels the fallback controller
* @returns true when a controller was found and aborted, false otherwise (so callers can decide
* whether to emit a terminal event upstream)
*/
public cancel(taskId?: string): void {
public cancel(taskId?: string): boolean {
if (taskId) {
const controller = this.activeControllers.get(taskId)
if (controller) {
controller.abort()
this.activeControllers.delete(taskId)
}
} else if (this.currentController) {
this.currentController.abort()
if (!controller) return false
controller.abort()
this.activeControllers.delete(taskId)
return true
}

if (!this.currentController) return false
this.currentController.abort()
return true
}

/**
Expand Down
56 changes: 54 additions & 2 deletions src/oclif/commands/curate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {ProviderConfigResponse, TransportStateEventNames} from '../../../server/
import {extractCurateOperations} from '../../../server/utils/curate-result-parser.js'
import {TaskEvents} from '../../../shared/transport/events/index.js'
import {printBillingLine} from '../../lib/billing-line.js'
import {runCancelBranchWithRetry} from '../../lib/cancel-task.js'
import {
type DaemonClientOptions,
formatConnectionError,
Expand Down Expand Up @@ -71,6 +72,10 @@ Bad examples:
'<%= config.bin %> curate view --status completed --since 1h',
]
public static flags = {
cancel: Flags.string({
description: 'Cancel a running task by id. Short-circuits the create flow — no new task is created.',
exclusive: ['files', 'folder', 'detach'],
}),
detach: Flags.boolean({
default: false,
description: 'Queue task and exit without waiting for completion',
Expand Down Expand Up @@ -113,6 +118,19 @@ Bad examples:
}
const format: 'json' | 'text' = flags.format ?? 'text'

if (rawFlags.cancel) {
const ok = await runCancelBranchWithRetry({
command: 'curate',
daemonClientOptions: this.getDaemonClientOptions(),
format,
log: (msg) => this.log(msg),
onTransportError: (error) => this.reportError(error, format),
taskId: rawFlags.cancel,
})
if (!ok) this.exit(1)
return
}

warnIfTimeoutFlagUsed({
defaultValue: DEFAULT_TIMEOUT_SECONDS,
log: (message) => this.log(message),
Expand All @@ -129,6 +147,7 @@ Bad examples:
const taskType = flags.folder?.length ? 'curate-folder' : 'curate'

let providerContext: ProviderErrorContext | undefined
let wasCancelled = false

try {
await withDaemonRetry(
Expand All @@ -154,7 +173,16 @@ Bad examples:
await ensureBillingFunds({billing, client})
}

await this.submitTask({client, content: resolvedContent, flags, format, projectRoot, taskType, worktreeRoot})
const result = await this.submitTask({
client,
content: resolvedContent,
flags,
format,
projectRoot,
taskType,
worktreeRoot,
})
if (result.wasCancelled) wasCancelled = true
},
{
...this.getDaemonClientOptions(),
Expand All @@ -167,7 +195,13 @@ Bad examples:
)
} catch (error) {
this.reportError(error, format, providerContext)
return
}

// Throw the SIGINT-conventional exit AFTER the daemon-retry try/catch so
// the ExitError isn't swallowed by reportError. Routine completions and
// errors fall through here naturally.
if (wasCancelled) this.exit(130)
}

/**
Expand Down Expand Up @@ -309,7 +343,7 @@ Bad examples:
projectRoot?: string
taskType: string
worktreeRoot?: string
}): Promise<void> {
}): Promise<{wasCancelled: boolean}> {
const {client, content, flags, format, projectRoot, taskType, worktreeRoot} = props
const hasFolders = Boolean(flags.folder?.length)
const taskId = randomUUID()
Expand Down Expand Up @@ -339,11 +373,26 @@ Bad examples:
this.log(`✓ Context queued for processing.${suffix}`)
}
} else {
let wasCancelled = false
const completionPromise = waitForTaskCompletion(
{
client,
command: 'curate',
format,
onCancelled: ({taskId: tid}) => {
wasCancelled = true
if (format === 'json') {
// success: false because the JSON top-level field tracks the exit
// code (130 on cancel). Cancellation semantics live in data.status.
writeJsonResponse({
command: 'curate',
data: {event: 'cancelled', message: 'Curate cancelled', status: 'cancelled', taskId: tid},
success: false,
})
} else {
this.log(`✗ Curate cancelled (Task: ${tid})`)
}
},
onCompleted: ({logId, pendingReview, taskId: tid, toolCalls}) => {
const changes = this.composeChangesFromToolCalls(toolCalls)
// Per-file detail is best-effort enrichment; server notify is authoritative
Expand Down Expand Up @@ -397,7 +446,10 @@ Bad examples:
)
await client.requestWithAck<TaskAck>(TaskEvents.CREATE, taskPayload)
await completionPromise
return {wasCancelled}
}

return {wasCancelled: false}
}

private validateInput(args: {context?: string}, flags: CurateFlags, format: 'json' | 'text'): boolean {
Expand Down
48 changes: 46 additions & 2 deletions src/oclif/commands/dream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {FileCurateLogStore} from '../../server/infra/storage/file-curate-log-sto
import {FileReviewBackupStore} from '../../server/infra/storage/file-review-backup-store.js'
import {getProjectDataDir} from '../../server/utils/path-utils.js'
import {TaskEvents} from '../../shared/transport/events/index.js'
import {runCancelBranchWithRetry} from '../lib/cancel-task.js'
import {
type DaemonClientOptions,
formatConnectionError,
Expand Down Expand Up @@ -84,6 +85,10 @@ export default class Dream extends Command {
'<%= config.bin %> <%= command.id %> --format json',
]
public static flags = {
cancel: Flags.string({
description: 'Cancel a running dream task by id. Hard stop — does not revert any partial writes (use --undo for that). Short-circuits the dream flow.',
exclusive: ['force', 'undo', 'detach'],
}),
detach: Flags.boolean({
default: false,
description: 'Queue task and exit without waiting for completion',
Expand Down Expand Up @@ -118,6 +123,19 @@ export default class Dream extends Command {
const {flags: rawFlags} = await this.parse(Dream)
const format = rawFlags.format === 'json' ? 'json' : 'text'

if (rawFlags.cancel) {
const ok = await runCancelBranchWithRetry({
command: 'dream',
daemonClientOptions: this.getDaemonClientOptions(),
format,
log: (msg) => this.log(msg),
onTransportError: (error) => this.reportError(error, format),
taskId: rawFlags.cancel,
})
if (!ok) this.exit(1)
return
}

warnIfTimeoutFlagUsed({
defaultValue: DEFAULT_TIMEOUT_SECONDS,
log: (message) => this.log(message),
Expand All @@ -130,6 +148,7 @@ export default class Dream extends Command {
}

let providerContext: ProviderErrorContext | undefined
let wasCancelled = false

try {
await withDaemonRetry(
Expand All @@ -149,14 +168,15 @@ export default class Dream extends Command {
throw new Error(providerMissingMessage(active.activeProvider, active.authMethod))
}

await this.submitTask({
const result = await this.submitTask({
client,
detach: rawFlags.detach,
force: rawFlags.force,
format,
projectRoot,
worktreeRoot,
})
if (result.wasCancelled) wasCancelled = true
},
{
...this.getDaemonClientOptions(),
Expand All @@ -169,7 +189,13 @@ export default class Dream extends Command {
)
} catch (error) {
this.reportError(error, format, providerContext)
return
}

// Throw the SIGINT-conventional exit AFTER the daemon-retry try/catch so
// the ExitError isn't swallowed by reportError. Routine completions and
// errors fall through here naturally.
if (wasCancelled) this.exit(130)
}

private reportError(error: unknown, format: 'json' | 'text', providerContext?: ProviderErrorContext): void {
Expand Down Expand Up @@ -228,7 +254,7 @@ export default class Dream extends Command {
format: 'json' | 'text'
projectRoot?: string
worktreeRoot?: string
}): Promise<void> {
}): Promise<{wasCancelled: boolean}> {
const {client, detach, force, format, projectRoot, worktreeRoot} = props
const taskId = randomUUID()
const taskPayload = {
Expand All @@ -255,11 +281,26 @@ export default class Dream extends Command {
this.log(`✓ Dream queued for processing.${logSuffix}`)
}
} else {
let wasCancelled = false
const completionPromise = waitForTaskCompletion(
{
client,
command: 'dream',
format,
onCancelled: ({taskId: tid}) => {
wasCancelled = true
if (format === 'json') {
// success: false because the JSON top-level field tracks the exit
// code (130 on cancel). Cancellation semantics live in data.status.
writeJsonResponse({
command: 'dream',
data: {event: 'cancelled', message: 'Dream cancelled', status: 'cancelled', taskId: tid},
success: false,
})
} else {
this.log(`✗ Dream cancelled (Task: ${tid})`)
}
},
onCompleted: ({logId, result, taskId: tid}) => {
const skipped = result?.startsWith('Dream skipped:')
if (format === 'json') {
Expand Down Expand Up @@ -291,6 +332,9 @@ export default class Dream extends Command {
)
await client.requestWithAck<TaskAck>(TaskEvents.CREATE, taskPayload)
await completionPromise
return {wasCancelled}
}

return {wasCancelled: false}
}
}
Loading