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, } }