fix(tower): remove command queue (#3193)

* fix(tower): remove command queue

* feat(agent-core-v2): support an explicit base branch in TowerInit

* fix(kimi-code): keep tower objective order across a mid-turn compaction

* feat(agent-core-v2): add abandoned tower mission status to release stale scopes

* chore: consolidate tower changesets into one feature entry

* chore: consolidate tower changesets into one feature entry

---------

Co-authored-by: konghuanjun <konghuanjun@moonshot.ai>
This commit is contained in:
tpoisonooo 2026-08-24 20:30:04 +08:00 committed by GitHub
parent 2d00599010
commit 15f20537c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 482 additions and 38 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---
Tower mode is Kimi Code's experimental multi-agent collaboration mode. Once enabled with `/tower on`, you can hand off research, development, or verification tasks at any time, and orchestration begins right away: agents work in parallel, each in its own isolated git worktree; an independent reviewer agent examines every change, and only approved work is merged into your chosen branch — with a summary reported back when done. Task boundaries, reviews, and merge order are enforced by tooling rather than prompts alone, so tasks never interfere with one another. Everything is logged and auditable, and you can adjust requirements at any point while work is underway.

View file

@ -196,15 +196,10 @@ export const BUILTIN_SLASH_COMMANDS = [
priority: 100,
argumentHint: '[status|teardown|on|off] | <objective>',
completeArgs: towerArgumentCompletions,
availability: (args) => {
const sub = args.trim().toLowerCase();
// Objective args enable the mode and queue the prompt: they must wait
// for idle so the running turn is not hijacked mid-flight. Pure reads
// (status/teardown) and deliberate toggles stay always-available.
return sub === '' || sub === 'on' || sub === 'off' || sub === 'status' || sub === 'teardown'
? 'always'
: 'idle-only';
},
// Every form stays available while busy: objectives steer into the
// running coordinator turn (see sendMessage in kimi-tui.ts), so /tower
// commands never wait for the previous one to finish.
availability: 'always',
experimentalFlag: 'tower',
requiresEngineV2: true,
},

View file

@ -1921,6 +1921,55 @@ export class KimiTUI {
}
private sendMessage(session: Session, input: string, options?: SendMessageOptions): void {
const phase = this.state.appState.streamingPhase;
// Tower mode keeps the main agent as a long-lived coordinator: while its
// turn is live, new input steers into that turn instead of queueing
// behind it, so consecutive /tower objectives are accepted immediately
// rather than serialized one turn at a time. A foreground shell command
// ('shell') has no turn to steer into and keeps queue semantics, as do
// input deferral and compaction.
const steerIntoCoordinator =
this.state.appState.towerMode &&
phase !== 'idle' &&
phase !== 'shell' &&
!this.deferUserMessages &&
!this.state.appState.isCompacting;
// Submission order must survive a mid-turn compaction: objectives queued
// while compacting stay queued when the turn outlives the compaction, so
// steering this input ahead of them would reorder the conversation.
// Prompt-only backlog rides along in the same steer batch, ahead of the
// new input; a non-steerable backlog (bash, slash-skill, inline-skill
// bundle) cannot, and then this input queues behind it instead.
const backlog = this.state.queuedMessages;
const backlogSteerable = backlog.every(
(m) => m.inlineSkillActivations === undefined && m.mode !== 'bash' && m.mode !== 'skill',
);
if (steerIntoCoordinator && backlogSteerable) {
// Same lease hand-off as the queue path below: the pre-dispatch lease
// defers to the raw ids on the steer item, which re-leases inside
// steerMessage and binds to the running turn.
this.staging.defer(options?.lease);
const items: SteerInputItem[] = [
...backlog.map((m) => ({
text: m.text,
parts: m.parts,
imageAttachmentIds: m.imageAttachmentIds,
videoAttachmentIds: m.videoAttachmentIds,
})),
{
text: input,
parts: options?.parts,
imageAttachmentIds: options?.imageAttachmentIds,
videoAttachmentIds: options?.videoAttachmentIds,
},
];
if (backlog.length > 0) {
this.state.queuedMessages = [];
this.updateQueueDisplay();
}
this.steerMessage(session, items);
return;
}
if (
this.deferUserMessages ||
this.state.appState.streamingPhase !== 'idle' ||

View file

@ -223,7 +223,7 @@ describe('built-in slash command registry', () => {
expect((command as KimiSlashCommand).requiresEngineV2).toBe(true);
});
it('keeps tower reads and toggles always available but defers objectives to idle', () => {
it('keeps every tower subcommand always available, including objectives', () => {
const command = findBuiltInSlashCommand('tower');
expect(command).toBeDefined();
expect(resolveSlashCommandAvailability(command!, '')).toBe('always');
@ -231,6 +231,6 @@ describe('built-in slash command registry', () => {
expect(resolveSlashCommandAvailability(command!, 'off')).toBe('always');
expect(resolveSlashCommandAvailability(command!, 'status')).toBe('always');
expect(resolveSlashCommandAvailability(command!, 'teardown')).toBe('always');
expect(resolveSlashCommandAvailability(command!, 'Ship feature X')).toBe('idle-only');
expect(resolveSlashCommandAvailability(command!, 'Ship feature X')).toBe('always');
});
});

View file

@ -3588,6 +3588,92 @@ command = "vim"
]);
});
it('steers fresh input into the running turn while tower mode is active', async () => {
const { driver, session } = await makeDriver();
driver.state.appState.towerMode = true;
driver.state.appState.streamingPhase = 'waiting';
driver.handleUserInput('second objective');
expect(session.steer).toHaveBeenCalledWith('second objective');
expect(session.prompt).not.toHaveBeenCalled();
expect(driver.state.queuedMessages).toEqual([]);
expect(driver.state.transcriptEntries).toEqual([
expect.objectContaining({ kind: 'user', content: 'second objective' }),
]);
});
it('prompts immediately while tower mode is active and the session is idle', async () => {
const { driver, session } = await makeDriver();
driver.state.appState.towerMode = true;
driver.handleUserInput('first objective');
expect(session.prompt).toHaveBeenCalledWith('first objective', { promptId: undefined });
expect(session.steer).not.toHaveBeenCalled();
});
it('queues input while tower mode is active but a foreground shell command is running', async () => {
const { driver, session } = await makeDriver();
driver.state.appState.towerMode = true;
driver.state.appState.streamingPhase = 'shell';
driver.handleUserInput('objective during shell');
expect(session.steer).not.toHaveBeenCalled();
expect(session.prompt).not.toHaveBeenCalled();
expect(driver.state.queuedMessages).toEqual([
{ text: 'objective during shell', agentId: 'main' },
]);
});
it('queues input while tower mode is active but compaction is running', async () => {
const { driver, session } = await makeDriver();
driver.state.appState.towerMode = true;
driver.state.appState.streamingPhase = 'waiting';
driver.state.appState.isCompacting = true;
driver.handleUserInput('objective during compaction');
expect(session.steer).not.toHaveBeenCalled();
expect(session.prompt).not.toHaveBeenCalled();
expect(driver.state.queuedMessages).toEqual([
{ text: 'objective during compaction', agentId: 'main' },
]);
});
it('steers the compaction backlog ahead of fresh input once compaction ends mid-turn', async () => {
const { driver, session } = await makeDriver();
driver.state.appState.towerMode = true;
driver.state.appState.streamingPhase = 'waiting';
driver.state.appState.isCompacting = true;
driver.handleUserInput('objective one');
expect(driver.state.queuedMessages).toHaveLength(1);
driver.state.appState.isCompacting = false;
driver.handleUserInput('objective two');
expect(session.steer).toHaveBeenCalledWith('objective one\n\nobjective two');
expect(session.prompt).not.toHaveBeenCalled();
expect(driver.state.queuedMessages).toEqual([]);
});
it('queues fresh input behind a non-steerable backlog instead of jumping ahead', async () => {
const { driver, session } = await makeDriver();
driver.state.appState.towerMode = true;
driver.state.appState.streamingPhase = 'waiting';
driver.state.queuedMessages = [{ text: 'make build', agentId: 'main', mode: 'bash' }];
driver.handleUserInput('objective two');
expect(session.steer).not.toHaveBeenCalled();
expect(session.prompt).not.toHaveBeenCalled();
expect(driver.state.queuedMessages).toEqual([
{ text: 'make build', agentId: 'main', mode: 'bash' },
{ text: 'objective two', agentId: 'main' },
]);
});
it('resets the streaming phase when steering mid-goal input fails', async () => {
const session = makeSession({
steer: vi.fn(async () => {

View file

@ -23,7 +23,7 @@ Working principles:
## Tower workflow
1. **Init**`TowerInit`. It creates `.tower/`, enables the tower tool set, and records the base branch. Workers and reviewers never prompt for tool approvals — they are pinned to the auto permission mode at spawn, whatever the session's mode. Your own orchestration calls still follow the session mode, so if it would interrupt you with constant prompts, tell the human once that a more autonomous mode fits tower better — then proceed regardless.
1. **Init**`TowerInit`. It creates `.tower/`, enables the tower tool set, and records the base branch. Workers and reviewers never prompt for tool approvals — they are pinned to the auto permission mode at spawn, whatever the session's mode. Your own orchestration calls still follow the session mode, so if it would interrupt you with constant prompts, tell the human once that a more autonomous mode fits tower better — then proceed regardless. When `TowerInit` reports carried-over open missions from a previous session, settle them **before planning**: continue the ones that belong to the current objective with fresh workers, and abandon the unrelated ones (`TowerMission status=abandoned`) — missions that are neither merged nor abandoned keep their scopes reserved, so `TowerPlan` rejects any new mission overlapping them.
2. **Plan** — break the objective into 24 missions and call `TowerPlan` with each mission's title, **disjoint** scope globs (picomatch: `**` crosses directories), tasks, and dependencies. Mark read-only investigation missions `kind: "survey"`: a survey's scope is informational (it reserves nothing, so surveys and builds may overlap the same paths), the worker must not change code, and it closes with a zero-diff `TowerMerge` — no reviewer needed. Shared files (lockfiles, central configs) belong to exactly one build mission or to your own integration work. Post the plan to the human in one compact message and launch immediately — their words are plan changes, never a gate.
3. **Spawn** — one `TowerSpawn` per mission (`kind: "worker"`, background, code-built briefing), and **spawn every dependency-unblocked mission right away**: fire the `TowerSpawn` calls back to back, never trickle them out one at a time and never wait for one worker before launching the next — the fleet exists to run in parallel. The tool refuses duplicate names — resume the existing agent with the `Agent` tool instead. Workers commit on their branch; their completion wakes you. Once the batch is running, **end your turn**: completions and inbox traffic arrive as notifications, so never poll `TowerInbox`/`TowerStatus` in a loop and never sit synchronously waiting on a worker. Workers bind the configured secondary model when the secondary-model experiment is on (they inherit your model otherwise); reviewers always bind your primary model — review quality is not where you save. The resolved model is shown in the spawn output and the `spawn` line of `activity.log`.
4. **Supervise** — on every wake (worker completion, human message): `TowerInbox` and `TowerStatus`, then act:

View file

@ -14,6 +14,7 @@ import {
isInsideRepo,
isWorktreeDirty,
mergeNoFf,
tryGit,
worktreeAdd,
worktreeRemove,
} from './git';
@ -64,6 +65,22 @@ export interface TowerInitResult {
* different session. Empty on creation and on same-session re-init.
*/
readonly retiredAgents: readonly string[];
/**
* The branch checked out in the main worktree at init time ('HEAD' when
* detached). Merges stay blocked while this differs from `base`.
*/
readonly checkout: string;
/**
* The base argument dropped because the existing workspace already records
* a different base re-init never resets recorded state.
*/
readonly ignoredBase?: string;
/**
* Ids of missions still holding their scope (not merged, not abandoned)
* on re-init these are the carried-over missions a new plan must either
* continue or abandon. Empty on creation.
*/
readonly openMissions: readonly string[];
}
export interface TowerPlanInput {
@ -123,8 +140,13 @@ const STATUS_EMOJI: Record<TowerMissionStatus, string> = {
blocked: '🔴',
paused: '⏸️',
merged: '✅',
abandoned: '🚫',
};
function isOpenMission(mission: Pick<TowerMission, 'status'>): boolean {
return mission.status !== 'merged' && mission.status !== 'abandoned';
}
export class TowerStore {
/** Absolute path of the main checkout (the session working directory). */
constructor(readonly repoRoot: string) {}
@ -147,7 +169,7 @@ export class TowerStore {
* session's freshly issued `agent-N` ids), missions/worktrees survive, and
* an `adopt` line marks the session boundary in the activity log.
*/
async init(sessionId?: string): Promise<TowerInitResult> {
async init(sessionId?: string, base?: string): Promise<TowerInitResult> {
if (!(await isInsideRepo(this.repoRoot))) {
throw new TowerProtocolError(
'tower needs a git repository (the session working directory is not inside one)',
@ -161,7 +183,32 @@ export class TowerStore {
if (await this.isInitialized()) {
const state = await this.load();
const retiredAgents = await this.adoptForeignRoster(state, sessionId);
return { base: state.base, created: false, retiredAgents };
return {
base: state.base,
created: false,
retiredAgents,
checkout: await this.checkedOutBranch(),
ignoredBase: base !== undefined && base !== state.base ? base : undefined,
openMissions: state.missions.filter(isOpenMission).map((m) => m.id),
};
}
const checkout = await this.checkedOutBranch();
let resolvedBase: string;
if (base !== undefined) {
if (!(await branchExists(this.repoRoot, base))) {
throw new TowerProtocolError(
`base branch "${base}" does not exist as a local branch — merges land on a local branch, so remote-tracking refs and tags are not accepted; create a local branch first`,
);
}
resolvedBase = base;
} else {
if (checkout === 'HEAD') {
throw new TowerProtocolError(
'cannot determine the base branch from a detached HEAD — pass the base branch explicitly',
);
}
resolvedBase = checkout;
}
for (const dir of [INBOX_DIR, FINDINGS_DIR, REVIEWS_DIR, MISSIONS_DIR, LOG_DIR, WORKTREES_DIR]) {
@ -169,10 +216,9 @@ export class TowerStore {
}
await this.ensureGitExclude();
const base = await currentBranch(this.repoRoot);
const state: TowerState = {
version: 1,
base,
base: resolvedBase,
mode: 'branch',
createdAt: new Date().toISOString(),
sessionId,
@ -182,8 +228,13 @@ export class TowerStore {
await this.save(state);
await writeFile(this.abs(ACTIVITY_LOG), '', 'utf8');
await this.renderMissionsIndex(state);
await this.appendLog(TOWER_NAME, 'init', { mode: state.mode, base }, MISSIONS_INDEX);
return { base, created: true, retiredAgents: [] };
await this.appendLog(TOWER_NAME, 'init', { mode: state.mode, base: resolvedBase }, MISSIONS_INDEX);
return { base: resolvedBase, created: true, retiredAgents: [], checkout, openMissions: [] };
}
/** Branch checked out in the main worktree, or 'HEAD' when detached. */
private async checkedOutBranch(): Promise<string> {
return (await tryGit(this.repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD'])) ?? 'HEAD';
}
/**
@ -345,7 +396,7 @@ export class TowerStore {
}
}
this.assertScopesDisjoint([
...state.missions.filter((m) => m.status !== 'merged'),
...state.missions.filter(isOpenMission),
...missions,
]);
@ -391,7 +442,7 @@ export class TowerStore {
if (a.id === b.id) continue;
if (a.stem === b.stem || a.stem.startsWith(`${b.stem}/`) || b.stem.startsWith(`${a.stem}/`)) {
throw new TowerProtocolError(
`mission scopes overlap: ${a.id} ("${a.raw}") vs ${b.id} ("${b.raw}") — split the shared files into exactly one mission`,
`mission scopes overlap: ${a.id} ("${a.raw}") vs ${b.id} ("${b.raw}") — split the shared files into exactly one mission; if one of them is stale finished work, abandon it first (TowerMission status=abandoned)`,
);
}
}
@ -443,12 +494,19 @@ export class TowerStore {
);
}
this.assertScopesDisjoint([
...state.missions.filter((m) => m.id !== id && m.status !== 'merged'),
...state.missions.filter((m) => m.id !== id && isOpenMission(m)),
{ ...mission, scope: [...patch.scope] },
]);
mission.scope = [...patch.scope];
}
if (patch.status !== undefined) mission.status = patch.status;
if (patch.status !== undefined) {
if (patch.status === 'abandoned' && callerName !== TOWER_NAME) {
throw new TowerProtocolError(
`agent "${callerName}" cannot abandon mission ${id} — abandoning releases the mission scope, so only the tower does it`,
);
}
mission.status = patch.status;
}
if (patch.note !== undefined) mission.notes.push(patch.note);
if (patch.blocker !== undefined) {
mission.blockers.push(patch.blocker);
@ -738,7 +796,7 @@ export class TowerStore {
const unmergedDeps = mission.deps.filter((dep) => {
const depMission = state.missions.find((m) => m.id === dep);
return depMission !== undefined && depMission.status !== 'merged';
return depMission !== undefined && isOpenMission(depMission);
});
if (unmergedDeps.length > 0) {
throw await block(
@ -818,7 +876,7 @@ export class TowerStore {
const changedSet = new Set(changed);
const conflictsWith: Array<{ readonly branch: string; readonly files: readonly string[] }> = [];
for (const other of state.missions) {
if (other.branch === branch || other.status === 'merged') continue;
if (other.branch === branch || !isOpenMission(other)) continue;
if (!(await branchExists(this.repoRoot, other.branch))) continue;
const otherChanged = await diffNameOnly(this.repoRoot, state.base, other.branch);
const overlap = otherChanged.filter((file) => changedSet.has(file));
@ -894,7 +952,7 @@ export class TowerStore {
'| -- | ------- | ------ | -------- | ------ | ----- |',
...rows,
'',
'Status: 🟡 planned · 🔵 active · 🟢 completed · 🔴 blocked · ⏸️ paused · ✅ merged',
'Status: 🟡 planned · 🔵 active · 🟢 completed · 🔴 blocked · ⏸️ paused · ✅ merged · 🚫 abandoned',
`Mode: ${state.mode} — Base: ${state.base}`,
'',
'## Dependency Flow',

View file

@ -29,13 +29,21 @@ export interface TowerRoster {
readonly agents: TowerRosterEntry[];
}
/**
* Lifecycle of a mission. `merged` (landed) and `abandoned` (given up without
* merging) are the two closed states: a closed mission stops reserving its
* scope, counts as satisfied for dependents, and drops out of merge-conflict
* checks. Abandoning is tower-only; the mission stays visible as the audit
* trail.
*/
export type TowerMissionStatus =
| 'planned'
| 'active'
| 'completed'
| 'blocked'
| 'paused'
| 'merged';
| 'merged'
| 'abandoned';
/**
* `build` missions change code: their scope reserves write access (plan-time

View file

@ -3,3 +3,5 @@ Initialize a tower multi-agent workspace in the current repository.
Creates the .tower/ directory (comms state, inbox, findings, reviews, missions, activity log, worktree slots), enters tower mode, and activates the full tower tool set (TowerPlan/TowerSpawn/TowerMerge/TowerTeardown plus the shared TowerSend/TowerInbox/TowerFinding/TowerReview/TowerMission/TowerStatus).
Use this when a task is large enough to split across multiple parallel agents with isolated git worktrees and a review-gated merge protocol. Safe to call again — an existing workspace is reported, never reset. Re-entering from a new CLI session adopts the workspace: roster entries the previous session spawned are retired (their agent ids cannot be resumed across sessions), while missions, worktrees, and the activity log carry over.
Takes an optional `base`: the local branch that every mission forks from and merges back into (default: the branch currently checked out in the main worktree). It is recorded for the workspace's lifetime — missions, reviews, and the merge gate all evaluate against it — so choose it at init time; a re-init reporting an existing workspace keeps the recorded base. Only local branches are accepted: a remote-tracking ref such as "origin/main" cannot receive merges, so create a local branch for it first. When the base differs from the main checkout (or the checkout is detached), work proceeds normally but merges stay blocked until the checkout is switched to the base.

View file

@ -3,7 +3,17 @@ import { z } from 'zod';
import { createDecorator } from '#/_base/di/instantiation';
import { type AgentTool } from '#/tool/toolContract';
export const TowerInitToolInputSchema = z.object({}).strict();
export const TowerInitToolInputSchema = z
.object({
base: z
.string()
.min(1)
.optional()
.describe(
'Local branch that missions fork from and merge back into (e.g. "develop"). Defaults to the branch currently checked out in the main worktree. Remote-tracking refs (e.g. "origin/main") and tags are not accepted — create a local branch first.',
),
})
.strict();
export type TowerInitToolInput = z.infer<typeof TowerInitToolInputSchema>;

View file

@ -24,7 +24,7 @@ export class TowerInitTool implements ITowerInitTool {
@IAgentScopeContext private readonly scopeContext: IAgentScopeContext,
) {}
resolveExecution(_args: TowerInitToolInput): ToolExecution {
resolveExecution(args: TowerInitToolInput): ToolExecution {
if (this.scopeContext.agentId !== MAIN_AGENT_ID) {
return {
isError: true,
@ -50,7 +50,7 @@ export class TowerInitTool implements ITowerInitTool {
`tower workspace is owned by a live session (${priorOwner}) — adopting it would retire that session's roster. Use the tower from that session, or close it first.`,
);
}
const result = await store.init(this.sessionContext.sessionId);
const result = await store.init(this.sessionContext.sessionId, args.base);
await this.tower.enter();
return {
output: [
@ -58,7 +58,24 @@ export class TowerInitTool implements ITowerInitTool {
? 'tower workspace initialized'
: 'tower workspace already initialized — existing state preserved',
`base branch: ${result.base}`,
...(result.ignoredBase !== undefined
? [
`requested base "${result.ignoredBase}" ignored — the existing workspace already records base "${result.base}"; tear it down first to rebase the tower`,
]
: []),
...(result.checkout !== result.base
? [
result.checkout === 'HEAD'
? `note: the main checkout is in a detached HEAD state — merges stay blocked until the base is checked out (git checkout ${result.base})`
: `note: the main checkout is on "${result.checkout}", not base "${result.base}" — merges stay blocked until it is switched over (git checkout ${result.base})`,
]
: []),
'workspace: .tower/ (comms under .tower/comms/, worktrees under .tower/worktrees/)',
...(result.openMissions.length > 0
? [
`carried-over open missions: ${result.openMissions.join(', ')} — their scopes are still reserved. Continue them (TowerSpawn fresh workers), or — when they belong to an unrelated earlier task — abandon them first (TowerMission status=abandoned) so a new plan can use those files.`,
]
: []),
...(result.retiredAgents.length > 0
? [
`adopted from a previous session — retired its stale roster entries: ${result.retiredAgents.join(', ')}. ` +

View file

@ -1,3 +1,5 @@
Read or update a tower mission.
With only an id, returns the mission view (status, tasks, blockers, notes). With patch fields, applies them: workers may only update the mission they own — the store rejects anything else. Use task_done to tick checklist items, note to log decisions, blocker when stuck (the tower watches for blocked missions).
Tower only: status=abandoned gives a mission up without merging — its scope stops reserving files for TowerPlan, its dependents may merge, and its branch drops out of conflict checks. Use it for stale missions carried over from a previous session, or for work that will not land; abandoned missions stay in MISSIONS.md (🚫) as the audit trail.

View file

@ -7,9 +7,11 @@ export const TowerMissionToolInputSchema = z
.object({
id: z.string().describe('Mission id (e.g. "M1")'),
status: z
.enum(['planned', 'active', 'completed', 'blocked', 'paused', 'merged'])
.enum(['planned', 'active', 'completed', 'blocked', 'paused', 'merged', 'abandoned'])
.optional()
.describe('New lifecycle status'),
.describe(
'New lifecycle status. "abandoned" is tower-only: it gives the mission up without merging — releasing its scope, satisfying its dependents, and excluding its branch from conflict checks.',
),
note: z.string().optional().describe('Append a decision-log note'),
blocker: z.string().optional().describe('Report a blocker (also sets status to blocked)'),
clear_blockers: z.boolean().optional().describe('Clear all recorded blockers'),

View file

@ -24,6 +24,7 @@ const STATUS_EMOJI: Record<TowerMission['status'], string> = {
blocked: '🔴',
paused: '⏸️',
merged: '✅',
abandoned: '🚫',
};
const INBOX_COUNT_LIMIT = 1000;
@ -69,13 +70,15 @@ export class TowerStatusTool implements ITowerStatusTool {
if (
state.missions.length > 0 &&
state.missions.every((mission) => mission.status === 'merged')
state.missions.every(
(mission) => mission.status === 'merged' || mission.status === 'abandoned',
)
) {
sections.push(
'',
'## Done',
'',
'All missions are merged. Free the worktree checkouts now: run TowerTeardown (branches and .tower/comms/ are kept; dirty worktrees are protected).',
'All missions are merged or abandoned. Free the worktree checkouts now: run TowerTeardown (branches and .tower/comms/ are kept; dirty worktrees are protected).',
);
}
@ -101,8 +104,10 @@ export class TowerStatusTool implements ITowerStatusTool {
}
private async renderReviewGate(store: TowerStore, state: TowerState): Promise<string[]> {
const pending = state.missions.filter((m) => m.status !== 'merged');
if (pending.length === 0) return ['(all missions merged — or none planned yet)'];
const pending = state.missions.filter(
(m) => m.status !== 'merged' && m.status !== 'abandoned',
);
if (pending.length === 0) return ['(no open missions — or none planned yet)'];
const lines: string[] = [];
for (const mission of pending) {
const review = await store.latestReview(mission.branch);

View file

@ -93,7 +93,13 @@ async function cleanReview(reviewer: string, target: string): Promise<void> {
describe('init', () => {
it('creates the directory skeleton, state.json, and the git exclude entry', async () => {
const result = await store.init();
expect(result).toEqual({ base: 'main', created: true, retiredAgents: [] });
expect(result).toEqual({
base: 'main',
created: true,
retiredAgents: [],
checkout: 'main',
openMissions: [],
});
for (const sub of ['inbox', 'findings', 'reviews', 'missions', 'log']) {
expect((await stat(join(repo, '.tower/comms', sub))).isDirectory()).toBe(true);
@ -119,7 +125,13 @@ describe('init', () => {
await store.plan([{ title: 'kept mission', scope: ['src/kept/**'] }]);
const second = await store.init();
expect(second).toEqual({ base: 'main', created: false, retiredAgents: [] });
expect(second).toEqual({
base: 'main',
created: false,
retiredAgents: [],
checkout: 'main',
openMissions: ['M1'],
});
const state = await store.load();
expect(state.missions).toHaveLength(1);
});
@ -130,7 +142,13 @@ describe('init', () => {
const second = await store.init('session-a');
expect(second).toEqual({ base: 'main', created: false, retiredAgents: [] });
expect(second).toEqual({
base: 'main',
created: false,
retiredAgents: [],
checkout: 'main',
openMissions: [],
});
const state = await store.load();
expect(state.roster.agents.map((agent) => agent.name)).toEqual(['agent-build']);
});
@ -147,6 +165,8 @@ describe('init', () => {
base: 'main',
created: false,
retiredAgents: ['agent-build', 'reviewer-a'],
checkout: 'main',
openMissions: ['M1'],
});
const state = await store.load();
expect(state.sessionId).toBe('session-b');
@ -155,6 +175,72 @@ describe('init', () => {
const log = await store.recentLog(5);
expect(log.some((line) => line.includes(' adopt ') && line.includes('session=session-b') && line.includes('previous=session-a') && line.includes('retired=agent-build,reviewer-a'))).toBe(true);
});
it('records an explicit local base branch instead of the checked-out one', async () => {
await git(repo, 'branch', 'develop');
const result = await store.init(undefined, 'develop');
expect(result).toEqual({
base: 'develop',
created: true,
retiredAgents: [],
checkout: 'main',
openMissions: [],
});
const state = await store.load();
expect(state.base).toBe('develop');
});
it('rejects a base that is not a local branch and stays uninitialized', async () => {
await expect(store.init(undefined, 'origin/main')).rejects.toThrow(
/base branch "origin\/main" does not exist as a local branch/,
);
await expect(store.init(undefined, 'no-such-branch')).rejects.toThrow(
/base branch "no-such-branch" does not exist as a local branch/,
);
expect(await store.isInitialized()).toBe(false);
});
it('allows a detached HEAD when the base is given explicitly', async () => {
await git(repo, 'checkout', '--detach', 'HEAD');
const result = await store.init(undefined, 'main');
expect(result).toEqual({
base: 'main',
created: true,
retiredAgents: [],
checkout: 'HEAD',
openMissions: [],
});
});
it('refuses a detached HEAD without an explicit base', async () => {
await git(repo, 'checkout', '--detach', 'HEAD');
await expect(store.init()).rejects.toThrow(/detached HEAD/);
expect(await store.isInitialized()).toBe(false);
});
it('ignores a conflicting base on re-init and keeps the recorded one', async () => {
await git(repo, 'branch', 'develop');
await store.init();
const second = await store.init(undefined, 'develop');
expect(second).toEqual({
base: 'main',
created: false,
retiredAgents: [],
checkout: 'main',
ignoredBase: 'develop',
openMissions: [],
});
const state = await store.load();
expect(state.base).toBe('main');
});
});
describe('plan', () => {
@ -618,6 +704,57 @@ describe('merge gate', () => {
await store.merge(build.branch);
expect((await store.load()).missions.find((m) => m.id === build.id)?.status).toBe('merged');
});
it('treats an abandoned dependency as satisfied', async () => {
const [, followUp] = await store.plan([
{ title: 'base work', scope: ['src/a/**'] },
{ title: 'follow up', scope: ['src/b/**'], deps: ['M1'] },
]);
const state = await store.load();
await store.addWorktree(followUp!.worktree, followUp!.branch, state.base);
await commitFile(worktreeOf(followUp!), 'src/b/b.ts', 'b\n', 'work on M2');
await store.registerAgent(
rosterEntry({ name: 'rev', kind: 'reviewer', reviewTarget: followUp!.branch }),
);
await expect(store.merge(followUp!.branch)).rejects.toThrow(/dependencies not merged yet/);
await store.updateMission('tower', 'M1', { status: 'abandoned' });
await cleanReview('rev', followUp!.branch);
await store.merge(followUp!.branch);
expect((await store.load()).missions.find((m) => m.id === 'M2')?.status).toBe('merged');
});
it('excludes abandoned branches from the post-merge conflict report', async () => {
const [first, second] = await store.plan([
{ title: 'first', scope: ['src/a/**'] },
{ title: 'second', scope: ['src/b/**'] },
]);
const state = await store.load();
await store.addWorktree(first!.worktree, first!.branch, state.base);
await store.addWorktree(second!.worktree, second!.branch, state.base);
await commitFile(worktreeOf(first!), 'src/a/shared.ts', 'from first\n', 'first');
await commitFile(worktreeOf(second!), 'src/a/shared.ts', 'from second\n', 'second strays');
await store.registerAgent(
rosterEntry({ name: 'rev', kind: 'reviewer', reviewTarget: first!.branch }),
);
await cleanReview('rev', first!.branch);
const before = await store.merge(first!.branch);
expect(before.conflictsWith.map((c) => c.branch)).toContain(second!.branch);
await store.updateMission('tower', 'M2', { status: 'abandoned' });
const [third] = await store.plan([{ title: 'third', scope: ['src/a/**'] }]);
await store.addWorktree(third!.worktree, third!.branch, state.base);
await commitFile(worktreeOf(third!), 'src/a/shared.ts', 'from third\n', 'third');
await store.registerAgent(
rosterEntry({ name: 'rev3', kind: 'reviewer', reviewTarget: third!.branch }),
);
await cleanReview('rev3', third!.branch);
const after = await store.merge(third!.branch);
expect(after.conflictsWith.map((c) => c.branch)).not.toContain(second!.branch);
});
});
describe('updateMission', () => {
@ -694,6 +831,43 @@ describe('updateMission', () => {
const index = await readFile(join(repo, '.tower/comms/MISSIONS.md'), 'utf8');
expect(index).toContain('| M1 | alpha | feat/alpha | wt-1 | 🟡 | w1 |');
});
it('lets only the tower abandon a mission, and logs it', async () => {
await expect(store.updateMission('w1', 'M1', { status: 'abandoned' })).rejects.toThrow(
/cannot abandon/,
);
expect((await store.load()).missions[0]?.status).toBe('planned');
const abandoned = await store.updateMission('tower', 'M1', { status: 'abandoned' });
expect(abandoned.status).toBe('abandoned');
const index = await readFile(join(repo, '.tower/comms/MISSIONS.md'), 'utf8');
expect(index).toContain('🚫');
const log = (await store.recentLog(5)).join('\n');
expect(log).toContain('mission.update');
expect(log).toContain('status=abandoned');
});
it('frees an abandoned mission\'s scope for new plans', async () => {
await expect(store.plan([{ title: 'gamma', scope: ['src/alpha/**'] }])).rejects.toThrow(
/scopes overlap/,
);
await store.updateMission('tower', 'M1', { status: 'abandoned' });
const [gamma] = await store.plan([{ title: 'gamma', scope: ['src/alpha/**'] }]);
expect(gamma!.id).toBe('M3');
});
it('frees an abandoned mission\'s scope for scope patches', async () => {
await expect(
store.updateMission('tower', 'M2', { scope: ['src/alpha/**'] }),
).rejects.toThrow(/scopes overlap/);
await store.updateMission('tower', 'M1', { status: 'abandoned' });
const patched = await store.updateMission('tower', 'M2', { scope: ['src/alpha/**'] });
expect(patched.scope).toEqual(['src/alpha/**']);
});
});
describe('roster', () => {

View file

@ -175,6 +175,37 @@ describe('TowerInitTool', () => {
expect(towerActive).toBe(true);
});
it('accepts an explicit base branch and notes the checkout mismatch', async () => {
await git(repo, 'branch', 'develop');
const result = await run(ix.get(ITowerInitTool), { base: 'develop' });
expect(result.isError).toBeFalsy();
expect(result.output).toContain('base branch: develop');
expect(result.output).toContain('the main checkout is on "main", not base "develop"');
const state = await new TowerStore(repo).load();
expect(state.base).toBe('develop');
});
it('reports an ignored base when re-initializing with a different one', async () => {
await git(repo, 'branch', 'develop');
await initViaTool();
const second = await run(ix.get(ITowerInitTool), { base: 'develop' });
expect(second.isError).toBeFalsy();
expect(second.output).toContain('requested base "develop" ignored');
const state = await new TowerStore(repo).load();
expect(state.base).toBe('main');
});
it('rejects a base that is not a local branch', async () => {
const result = await run(ix.get(ITowerInitTool), { base: 'origin/main' });
expect(result.isError).toBe(true);
expect(result.output).toContain('does not exist as a local branch');
});
it('is idempotent — a second run reports already-initialized and keeps state', async () => {
await initViaTool();
await run(ix.get(ITowerPlanTool), {