Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
4a5e603
feat(tower): coordinate missions with deterministic orchestration
Oct 1, 2026
fbadaa0
fix(tower): disable goal mode
Oct 2, 2026
1aa09c0
feat(tower): degrade gracefully on tower state loss
Oct 3, 2026
23dc6fd
feat(tower): veto destructive bash commands in the tower main checkout
Oct 3, 2026
26310ba
fix(tower): allow git clean dot pathspecs inside tower worktrees
Oct 3, 2026
b27a649
fix(tower): in case of remove .tower dir
Oct 3, 2026
24b82c5
fix(tests): ci
Oct 5, 2026
0f986f0
test(tower): add tap01 tool action acceptance probe
Oct 8, 2026
21c26d4
test(tower): add rbp02 rebase acceptance probe
Oct 8, 2026
659b4eb
test(tower): add rbp03 rebase acceptance probe
Oct 8, 2026
de971d0
fix(tower): simplify changeset
Oct 8, 2026
03993f8
fix(tower): update changeset
Oct 8, 2026
bc9fa8f
fix(tower): update changeset
Oct 8, 2026
885c132
fix(tower): remove tmp files
Oct 8, 2026
9e768a7
fix(tower): restore attachment guidance for mission context
Oct 8, 2026
c5cf171
fix(tower): engage bash guard only while the workspace has an owner
Oct 8, 2026
8fa5213
fix(tower): veto git clean only with force and -x/-X; drop markInboxRead
Oct 8, 2026
6d6a725
chore(tower): drop per-fix changesets to keep a single concise one
Oct 8, 2026
a27da78
fix(tower): check tower owner before preparing the user base
Oct 8, 2026
e95a83d
feat(tower): show a notice when goal activation ends tower mode
Oct 8, 2026
bd8842a
fix(tower): unify the mode-mutex exit reason across goal, plan, and s…
Oct 8, 2026
0634916
refactor(tower): drop token stamping from protocol artifacts
Oct 8, 2026
72cabd8
test: add rebase probe rbp04 acceptance file
Oct 8, 2026
188bb25
test(tower): add RBP05 rebase probe acceptance file
Oct 8, 2026
469c923
test(tower): add tap02 acceptance probe file
Oct 8, 2026
f633ba5
fix(tower): remove useless
Oct 9, 2026
5bc3273
fix(agent-core-v2): deny tower workers subagent delegation
Oct 9, 2026
b830472
test: add subprobe01 acceptance probe file
Oct 9, 2026
61b167e
test(tower): remove the acceptance probe artifact
Oct 9, 2026
aa60d91
test(tower): stub ILogService in every AgentTowerService test scope
Oct 9, 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
5 changes: 5 additions & 0 deletions .changeset/tower-deterministic-orchestration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Tower mode: deterministic orchestration with TowerRebase and LLM-free status, goal/tower mutual exclusion, and workspace protection.
15 changes: 12 additions & 3 deletions apps/kimi-code/src/tui/commands/tower.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Session } from '@moonshot-ai/kimi-code-sdk';

import { TOWER_STATUS_PROMPT, TOWER_TEARDOWN_PROMPT } from '../constant/kimi-tui';
import { TOWER_TEARDOWN_PROMPT } from '../constant/kimi-tui';
import { formatErrorMessage } from '../utils/event-payload';
import type { SlashCommandHost } from './dispatch';

Expand All @@ -17,7 +17,16 @@ export async function handleTowerCommand(host: SlashCommandHost, args: string):
return;
}
if (sub === '' || sub === 'status') {
host.sendNormalUserInput(TOWER_STATUS_PROMPT);
const session = await requireSessionEnsured(host);
if (session === undefined) return;
try {
// The SDK returns the engine-rendered summary; its first line is the
// state headline, which the notice lifts into the title.
const [title, ...rest] = (await session.getTowerStatus()).split('\n');
host.showNotice(title ?? 'Tower status', rest.length > 0 ? rest.join('\n') : undefined);
} catch (error) {
host.showError(`Failed to read tower status: ${formatErrorMessage(error)}`);
}
return;
}
if (sub === 'teardown') {
Expand Down Expand Up @@ -84,7 +93,7 @@ async function setTowerMode(

async function requireSessionEnsured(host: SlashCommandHost): Promise<Session | undefined> {
if (host.session !== undefined) return host.session;
// v2 session-less: lazy-create the session, then toggle — the same path
// v2 session-less: lazy-create the session on first use — the same path
// the first prompt takes.
return host.ensureSession();
}
2 changes: 0 additions & 2 deletions apps/kimi-code/src/tui/constant/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ export const OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE = 'OAuth login expired. Send /l
export const TUI_MODE_RESTART_NOTICE = 'TUI mode takes effect after restarting Kimi Code.';
export const SESSIONLESS_STARTUP_NOTICE =
'No session yet — one will be created on your first message.';
export const TOWER_STATUS_PROMPT =
'Report the current tower status: call TowerStatus and give a compact summary.';
export const TOWER_TEARDOWN_PROMPT =
'Tear down the tower: call TowerTeardown and report what it did. It refuses to destroy dirty worktrees unless forced.';
export const EXIT_CONFIRM_WINDOW_MS = 1500;
Expand Down
10 changes: 10 additions & 0 deletions apps/kimi-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,10 @@ export class SessionEventHandler {
event.swarmMode === false &&
this.host.state.appState.swarmMode &&
this.host.state.swarmModeEntry === 'task';
const shouldRenderTowerGoalExit =
event.towerMode === false &&
this.host.state.appState.towerMode &&
event.towerExitReason === 'mode-mutex';
const patch: Partial<AppState> = {};
if (event.contextTokens !== undefined) patch.contextTokens = event.contextTokens;
if (event.maxContextTokens !== undefined) patch.maxContextTokens = event.maxContextTokens;
Expand Down Expand Up @@ -763,6 +767,12 @@ export class SessionEventHandler {
this.renderSwarmModeMarker('ended');
}
}
if (shouldRenderTowerGoalExit) {
this.host.state.transcriptContainer.addChild(
new StatusMessageComponent('Tower mode ended — a goal was activated'),
);
this.host.state.ui.requestRender();
}
}

private renderSwarmModeMarker(state: SwarmModeMarkerState): void {
Expand Down
80 changes: 75 additions & 5 deletions apps/kimi-code/test/tui/commands/tower.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { Session } from '@moonshot-ai/kimi-code-sdk';

import { handleTowerCommand } from '#/tui/commands/index';
import type { SlashCommandHost } from '#/tui/commands/dispatch';
import { TOWER_STATUS_PROMPT, TOWER_TEARDOWN_PROMPT } from '#/tui/constant/kimi-tui';
import { TOWER_TEARDOWN_PROMPT } from '#/tui/constant/kimi-tui';

function makeHost(
overrides: {
Expand All @@ -20,6 +20,9 @@ function makeHost(
if (!(overrides.refuseTowerEntry && enabled)) engineMode = enabled;
}),
getStatus: vi.fn(async () => ({ towerMode: engineMode })),
getTowerStatus: vi.fn(async (): Promise<string> =>
engineMode ? 'Tower mode: ON\nTower is not initialized.' : 'Tower mode: OFF',
),
};
const hasSession = overrides.hasSession ?? true;
const host = {
Expand All @@ -43,28 +46,95 @@ function makeHost(
showStatus: vi.fn(),
showNotice: vi.fn(),
sendNormalUserInput: vi.fn(),
mountEditorReplacement: vi.fn(),
restoreEditor: vi.fn(),
restoreInputText: vi.fn(),
} as unknown as SlashCommandHost;
return { host, session };
}

describe('handleTowerCommand', () => {
it('reports tower status when called without args, without touching the mode', async () => {
it('reads tower status without args through the SDK without prompting or touching the editor', async () => {
const { host, session } = makeHost({ towerMode: false });

await handleTowerCommand(host, '');

expect(host.sendNormalUserInput).toHaveBeenCalledWith(TOWER_STATUS_PROMPT);
expect(session.getTowerStatus).toHaveBeenCalledOnce();
expect(host.showNotice).toHaveBeenCalledWith('Tower mode: OFF', undefined);
expect(session.setTowerMode).not.toHaveBeenCalled();
expect(host.ensureSession).not.toHaveBeenCalled();
expect(host.sendNormalUserInput).not.toHaveBeenCalled();
expect(host.mountEditorReplacement).not.toHaveBeenCalled();
expect(host.restoreEditor).not.toHaveBeenCalled();
expect(host.restoreInputText).not.toHaveBeenCalled();
});

it('reports tower status for the status subcommand, without touching the mode', async () => {
it('reports active but uninitialized status without calling it OFF', async () => {
const { host, session } = makeHost({ towerMode: true });

await handleTowerCommand(host, 'status');

expect(host.sendNormalUserInput).toHaveBeenCalledWith(TOWER_STATUS_PROMPT);
expect(session.getTowerStatus).toHaveBeenCalledOnce();
expect(host.showNotice).toHaveBeenCalledWith('Tower mode: ON', 'Tower is not initialized.');
expect(host.showNotice).not.toHaveBeenCalledWith('Tower mode: OFF', undefined);
expect(session.setTowerMode).not.toHaveBeenCalled();
expect(host.sendNormalUserInput).not.toHaveBeenCalled();
});

it('shows the engine-rendered status summary through showNotice', async () => {
const { host, session } = makeHost({ towerMode: true });
const detail = [
'Base: main (mode: branch) · You are: tower',
'Missions:',
' M1 Build engine — active · owner w1 · feat/build-engine',
'Roster:',
' w1 (worker) — mission M1 · agent-w1',
'Review gate:',
' M1 feat/build-engine — BLOCKED: not-completed',
'Inbox: 2 message(s)',
'Concurrency: budget: 3 agent(s) · inflight: 1 · spawns open',
'Recent activity:',
' 2026-09-30T00:00:00.000Z tower plan missions=1',
].join('\n');
session.getTowerStatus.mockResolvedValueOnce(`Tower status — ON\n${detail}`);

await handleTowerCommand(host, 'status');

expect(host.showNotice).toHaveBeenCalledWith('Tower status — ON', detail);

session.getTowerStatus.mockResolvedValueOnce(`Tower mode: OFF\n${detail}`);
await handleTowerCommand(host, 'status');

expect(host.showNotice).toHaveBeenCalledWith('Tower mode: OFF', detail);
expect(host.sendNormalUserInput).not.toHaveBeenCalled();
expect(host.mountEditorReplacement).not.toHaveBeenCalled();
});

it('lazy-creates a missing session before reading tower status', async () => {
const { host, session } = makeHost({ hasSession: false });

await handleTowerCommand(host, 'status');

expect(host.ensureSession).toHaveBeenCalledOnce();
expect(session.getTowerStatus).toHaveBeenCalledOnce();
expect(host.showNotice).toHaveBeenCalledWith('Tower mode: OFF', undefined);
expect(host.sendNormalUserInput).not.toHaveBeenCalled();
});

it('shows status errors without a prompt fallback', async () => {
const { host, session } = makeHost({ towerMode: true });
session.getTowerStatus.mockRejectedValueOnce(new Error('backend unavailable'));

await handleTowerCommand(host, 'status');

expect(host.showError).toHaveBeenCalledWith(
'Failed to read tower status: backend unavailable',
);
expect(host.showNotice).not.toHaveBeenCalled();
expect(host.sendNormalUserInput).not.toHaveBeenCalled();
expect(host.mountEditorReplacement).not.toHaveBeenCalled();
expect(host.restoreEditor).not.toHaveBeenCalled();
expect(host.restoreInputText).not.toHaveBeenCalled();
});

it('sends the teardown instruction for the teardown subcommand, without touching the mode', async () => {
Expand Down
56 changes: 56 additions & 0 deletions apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6168,6 +6168,62 @@ command = "vim"
expect(driver.state.appState.towerMode).toBe(false);
});

it('renders a marker when tower mode exits because a goal was activated', async () => {
const { driver } = await makeDriver();

driver.sessionEventHandler.handleEvent(
{
type: 'agent.status.updated',
agentId: 'main',
sessionId: 'ses-1',
towerMode: true,
} as Event,
vi.fn(),
);
expect(driver.state.appState.towerMode).toBe(true);

driver.sessionEventHandler.handleEvent(
{
type: 'agent.status.updated',
agentId: 'main',
sessionId: 'ses-1',
towerMode: false,
towerExitReason: 'mode-mutex',
} as Event,
vi.fn(),
);

expect(driver.state.appState.towerMode).toBe(false);
const transcript = stripSgr(renderTranscript(driver));
expect(countOccurrences(transcript, 'Tower mode ended — a goal was activated')).toBe(1);
});

it('does not render the goal-exit marker for a reasonless tower exit', async () => {
const { driver } = await makeDriver();

driver.sessionEventHandler.handleEvent(
{
type: 'agent.status.updated',
agentId: 'main',
sessionId: 'ses-1',
towerMode: true,
} as Event,
vi.fn(),
);
driver.sessionEventHandler.handleEvent(
{
type: 'agent.status.updated',
agentId: 'main',
sessionId: 'ses-1',
towerMode: false,
} as Event,
vi.fn(),
);

expect(driver.state.appState.towerMode).toBe(false);
expect(stripSgr(renderTranscript(driver))).not.toContain('Tower mode ended');
});

it('renders swarm mode markers from /swarm commands, not tool-triggered status updates', async () => {
const { driver } = await makeDriver();

Expand Down
5 changes: 1 addition & 4 deletions packages/agent-core-v2/docs/state-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
// references become '(circular)', and class instances collapse to a '(ClassName)'
// marker — the wire shape of an entry is the JSON projection of the type here.
//
// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 73 keys)
// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 72 keys)
// App
// Workspace
// workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts
Expand Down Expand Up @@ -116,7 +116,6 @@
// toolExecutor.toolCallDupTypes src/agent/toolExecutor/toolExecutorService.ts
// toolSelect.pendingLoaded src/agent/toolSelect/toolSelectService.ts
// tower src/features/tower/towerOps.ts
// tower.base src/features/tower/towerOps.ts
// tower.owner src/features/tower/towerOps.ts
// turn src/agent/loop/turnOps.ts
// userTool src/agent/userTool/userToolOps.ts
Expand Down Expand Up @@ -1307,8 +1306,6 @@ export interface AgentStateSnapshot {
// replayable · durable — folds: TowerModeEnter, TowerModeExit
'tower': boolean;
// replayable · durable — folds: TowerModeEnter, TowerModeExit
'tower.base': string | null;
// replayable · durable — folds: TowerModeEnter, TowerModeExit
'tower.owner': string | undefined;
}

Expand Down
9 changes: 4 additions & 5 deletions packages/agent-core-v2/docs/wire-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@
// tools.set_active_tools profile.activeTools src/agent/profile/profileOps.ts
// tools.unregister_user_tool userTool src/agent/userTool/userToolOps.ts
// tools.update_store (none) src/features/todo/todoOps.ts
// tower_mode.enter tower, tower.base, tower.owner src/features/tower/towerOps.ts
// tower_mode.exit tower, tower.base, tower.owner src/features/tower/towerOps.ts
// tower_mode.enter tower, tower.owner src/features/tower/towerOps.ts
// tower_mode.exit tower, tower.owner src/features/tower/towerOps.ts
// turn.cancel turn src/agent/loop/turnOps.ts
// turn.ended turn src/agent/loop/turnOps.ts
// turn.prompt turn src/agent/loop/turnOps.ts
Expand Down Expand Up @@ -794,18 +794,17 @@ interface ToolsUpdateStorePayload {
}

/**
* states: tower, tower.base, tower.owner
* states: tower, tower.owner
* owner: src/features/tower/towerOps.ts
*/
interface TowerModeEnterPayload {
_name: 'tower_mode.enter';
agentId: string;
sessionId?: string;
base?: string;
}

/**
* states: tower, tower.base, tower.owner
* states: tower, tower.owner
* owner: src/features/tower/towerOps.ts
*/
interface TowerModeExitPayload {
Expand Down
27 changes: 25 additions & 2 deletions packages/agent-core-v2/src/agent/modeMutex/modeMutexService.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { Disposable } from '#/_base/di/lifecycle';
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { IAgentStateService } from '#/agent/state/agentState';
import { AgentStatusUpdated } from '#/agent/usage/usageEvents';
import { IEventBus } from '#/app/event/eventBus';
import { LifecycleScope } from '#/app/scopes';
import { GoalUpdated } from '#/features/goal/goalOps';
import { IAgentGoalService } from '#/features/goal/goalService';
import { IAgentPlanService } from '#/features/plan/plan';
import { PlanModeEnter, planKey } from '#/features/plan/planOps';
import { IAgentSwarmService } from '#/features/swarm/agent/swarm';
import { SwarmModeEnter } from '#/features/swarm/swarmOps';
import { IAgentTowerService } from '#/features/tower/tower';
import { TowerModeEnter } from '#/features/tower/towerOps';
import { IEventDispatcher } from '#/state/eventDispatcher';

import { IAgentModeMutexService } from './modeMutex';

Expand All @@ -19,24 +23,43 @@ export class AgentModeMutexService extends Disposable implements IAgentModeMutex
@IAgentPlanService private readonly plan: IAgentPlanService,
@IAgentSwarmService private readonly swarm: IAgentSwarmService,
@IAgentTowerService private readonly tower: IAgentTowerService,
@IAgentGoalService private readonly goal: IAgentGoalService | undefined,
@IAgentStateService private readonly agentState: IAgentStateService,
@IEventDispatcher private readonly dispatcher: IEventDispatcher,
@IEventBus eventBus: IEventBus,
) {
super();
this._register(
eventBus.subscribe(PlanModeEnter, () => {
if (this.tower.isActive) void this.tower.exit();
if (this.tower.isActive) void this.tower.exit('mode-mutex');
}),
);
this._register(
eventBus.subscribe(SwarmModeEnter, () => {
if (this.tower.isActive) void this.tower.exit();
if (this.tower.isActive) void this.tower.exit('mode-mutex');
}),
);
this._register(
eventBus.subscribe(TowerModeEnter, () => {
if (this.agentState.get(planKey).active) this.plan.exit();
if (this.swarm.isActive) this.swarm.exit();
if (this.goal?.getGoal().goal?.status === 'active') {
void this.goal.pauseGoal({ reason: 'Paused after tower mode entered' }, 'system');
}
}),
);
this._register(
eventBus.subscribe(GoalUpdated, (event) => {
if (event.snapshot?.status !== 'active') return;
if (!this.tower.isActive) return;
void this.dispatcher.dispatch(
new AgentStatusUpdated({
agentId: event.agentId,
towerMode: false,
towerExitReason: 'mode-mutex',
}),
);
void this.tower.exit('mode-mutex');
}),
);
}
Expand Down
Loading
Loading