fix(minecraft): stop action cancels active control action without emitting failure feedback

Add test confirming executeStopAction cancels active control action (goToPlayer), marks it as cancelled in queue snapshot, calls bot.interrupt, and does not enqueue failure feedback event for the cancelled action
This commit is contained in:
Rin
2026-02-18 11:14:42 +08:00
committed by Neko Ayaka
parent cbcd23597f
commit e700a177ef
2 changed files with 129 additions and 0 deletions
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import { ActionError } from '../../utils/errors'
import { Brain } from './brain'
function createReflexSnapshot() {
@@ -367,4 +368,53 @@ describe('brain control action queue', () => {
params: {},
})
})
it('cancels active control action on stop without emitting failure feedback', async () => {
const deps: any = createDeps('await skip()')
deps.taskExecutor.getAvailableActions = vi.fn(() => [createAsyncControlAction('goToPlayer')])
deps.taskExecutor.executeActionWithResult = vi.fn((action: any, cancellationToken?: any) => {
if (action.tool === 'goToPlayer') {
return new Promise((_resolve, reject) => {
cancellationToken?.onCancelled(() => {
reject(new ActionError('INTERRUPTED', 'cancelled by stop'))
})
})
}
if (action.tool === 'stop')
return Promise.resolve('all actions stopped')
return Promise.resolve('ok')
})
const brain: any = new Brain(deps)
const enqueueSpy = vi.fn(async () => undefined)
brain.enqueueEvent = enqueueSpy
const bot = {
interrupt: vi.fn(),
}
await brain.enqueueControlAction(bot, {
tool: 'goToPlayer',
params: { player_name: 'Alex', closeness: 2 },
}, 1)
await new Promise(resolve => setTimeout(resolve, 20))
await brain.executeStopAction(bot, 2)
await new Promise(resolve => setTimeout(resolve, 20))
const snapshot = brain.getDebugSnapshot()
const cancelledEntry = snapshot.actionQueue.recent.find((entry: any) => entry.tool === 'goToPlayer')
expect(cancelledEntry?.state).toBe('cancelled')
expect(snapshot.actionQueue.counts.total).toBe(0)
expect(bot.interrupt).toHaveBeenCalled()
const goToPlayerFailure = enqueueSpy.mock.calls.find((call: any[]) => {
const event = call[1]
return event?.type === 'feedback'
&& event?.payload?.status === 'failure'
&& event?.payload?.action?.tool === 'goToPlayer'
})
expect(goToPlayerFailure).toBeUndefined()
})
})
@@ -15,6 +15,7 @@ import type { CancellationToken } from './task-state'
import { config } from '../../composables/config'
import { DebugService } from '../../debug'
import { ActionError } from '../../utils/errors'
import { buildConsciousContextView } from './context-view'
import { JavaScriptPlanner } from './js-planner'
import { createLlmLogRuntime } from './llm-log'
@@ -240,6 +241,7 @@ export class Brain {
private pendingControlActions: ControlActionQueueEntry[] = []
private activeControlAction: ControlActionQueueEntry | null = null
private recentControlActions: ControlActionQueueEntry[] = []
private readonly stopCancelledControlActionIds = new Set<number>()
private actionQueueUpdatedAt = Date.now()
private isActionWorkerRunning = false
private completedControlActionsSinceLastFeedback = 0
@@ -756,6 +758,33 @@ export class Brain {
try {
const result = await this.deps.taskExecutor.executeActionWithResult(entry.action, cancellationToken)
const cancelledByStop = cancellationToken.isCancelled || this.stopCancelledControlActionIds.has(entry.id)
if (cancelledByStop) {
entry.state = 'cancelled'
entry.error = 'Cancelled by stop action'
entry.finishedAt = Date.now()
this.pushRecentControlAction(entry)
this.appendLlmLog({
turnId: entry.sourceTurnId,
kind: 'scheduler',
eventType: 'feedback',
sourceType: 'system',
sourceId: 'brain:action_queue',
tags: ['scheduler', 'action_queue', 'cancelled', entry.action.tool],
text: `Control action #${entry.id} cancelled: ${entry.action.tool}`,
metadata: {
actionId: entry.id,
reason: 'stop',
},
})
this.stopCancelledControlActionIds.delete(entry.id)
this.activeControlAction = null
this.touchActionQueue()
continue
}
entry.state = 'succeeded'
entry.result = result
entry.finishedAt = Date.now()
@@ -795,6 +824,37 @@ export class Brain {
}
}
catch (err) {
const interrupted = err instanceof ActionError && err.code === 'INTERRUPTED'
const cancelledByStop = cancellationToken.isCancelled
|| interrupted
|| this.stopCancelledControlActionIds.has(entry.id)
if (cancelledByStop) {
entry.state = 'cancelled'
entry.error = 'Cancelled by stop action'
entry.finishedAt = Date.now()
this.pushRecentControlAction(entry)
this.appendLlmLog({
turnId: entry.sourceTurnId,
kind: 'scheduler',
eventType: 'feedback',
sourceType: 'system',
sourceId: 'brain:action_queue',
tags: ['scheduler', 'action_queue', 'cancelled', entry.action.tool],
text: `Control action #${entry.id} cancelled: ${entry.action.tool}`,
metadata: {
actionId: entry.id,
reason: interrupted ? 'interrupted' : 'stop',
},
})
this.stopCancelledControlActionIds.delete(entry.id)
this.activeControlAction = null
this.touchActionQueue()
continue
}
const errorMessage = toErrorMessage(err)
entry.state = 'failed'
entry.error = errorMessage
@@ -854,7 +914,21 @@ export class Brain {
private async executeStopAction(bot: MineflayerWithAgents, sourceTurnId: number): Promise<unknown> {
const clearedCount = this.clearPendingControlActions('cancelled')
const cancelledActiveActionId = this.activeControlAction?.id
if (cancelledActiveActionId)
this.stopCancelledControlActionIds.add(cancelledActiveActionId)
this.currentCancellationToken?.cancel()
this.deps.reflexManager.clearFollowTarget()
try {
bot.interrupt('stop requested by brain')
}
catch (err) {
this.deps.logger.withError(err as Error).warn('Brain: Failed to interrupt mineflayer during stop')
}
this.completedControlActionsSinceLastFeedback = 0
this.appendLlmLog({
turnId: sourceTurnId,
@@ -864,6 +938,9 @@ export class Brain {
sourceId: 'brain:action_queue',
tags: ['scheduler', 'action_queue', 'stop'],
text: `Stop requested. Cleared pending control actions: ${clearedCount}`,
metadata: {
cancelledActiveActionId,
},
})
const result = await this.deps.taskExecutor.executeActionWithResult({ tool: 'stop', params: {} })
@@ -875,6 +952,7 @@ export class Brain {
result,
summary: {
clearedPendingCount: clearedCount,
cancelledActiveActionId,
},
},
source: { type: 'system', id: 'executor' },
@@ -885,6 +963,7 @@ export class Brain {
ok: true,
stopped: true,
clearedPendingCount: clearedCount,
cancelledActiveActionId,
}
}