From e700a177ef48dd78907143133590b070b3386811 Mon Sep 17 00:00:00 2001 From: Rin Date: Sun, 8 Feb 2026 02:47:07 +0800 Subject: [PATCH] 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 --- .../src/cognitive/conscious/brain.test.ts | 50 ++++++++++++ .../src/cognitive/conscious/brain.ts | 79 +++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/services/minecraft/src/cognitive/conscious/brain.test.ts b/services/minecraft/src/cognitive/conscious/brain.test.ts index 9ff22f3f9..16adb3f53 100644 --- a/services/minecraft/src/cognitive/conscious/brain.test.ts +++ b/services/minecraft/src/cognitive/conscious/brain.test.ts @@ -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() + }) }) diff --git a/services/minecraft/src/cognitive/conscious/brain.ts b/services/minecraft/src/cognitive/conscious/brain.ts index 8f1fd09bd..f12a68305 100644 --- a/services/minecraft/src/cognitive/conscious/brain.ts +++ b/services/minecraft/src/cognitive/conscious/brain.ts @@ -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() 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 { 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, } }