From 7e454a396e1a83e772581ce532dfcb57a8ed991b Mon Sep 17 00:00:00 2001 From: konghuanjun Date: Fri, 28 Aug 2026 16:02:48 +0800 Subject: [PATCH 1/9] feat(agent-core-v2): spawn tower workers on a snapshot of base checkout WIP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tower worktrees were created from the base branch tip, so a worker never saw uncommitted changes sitting in the base checkout — it would start building on a foundation that did not exist in its worktree. When a mission branch is first created, the store now captures the base checkout's dirty paths (staged, unstaged, and untracked per git status; .tower/ and git's own ignore rules excluded; unmerged paths refuse the spawn) into a synthetic snapshot commit built through a temporary index (read-tree + add + write-tree + commit-tree), so the user's checkout, index, and base branch are never touched. The mission branch starts at that commit and the mission records it as spawnBase. The merge gate diffs the branch from spawnBase instead of the base tip, so snapshotted WIP is never mistaken for a worker scope violation, and the reviewer briefing diffs from the same point. Alternatives considered: applying the WIP as uncommitted changes inside the new worktree (fragile — it mixes into the worker's first commit and can be lost before that), and excluding the WIP file set inside the gate (permanent holes in scope enforcement, hidden provenance). A snapshot commit keeps the gate strict and makes the WIP an explicit, mergeable part of the branch history. TowerMerge now also refuses to merge while the main checkout holds uncommitted changes in files the merge would overwrite (blocked reason base-dirty) and tells the user to commit or stash them first; dirt that does not intersect the merge no longer blocks it. --- .changeset/tower-spawn-base-wip-snapshot.md | 5 + .../src/features/tower/protocol/baseWip.ts | 54 ++++++++ .../src/features/tower/protocol/git.ts | 17 ++- .../src/features/tower/protocol/index.ts | 1 + .../src/features/tower/protocol/store.ts | 69 ++++++++-- .../src/features/tower/protocol/types.ts | 1 + .../src/features/tower/tools/merge/merge.md | 4 +- .../src/features/tower/tools/spawn/spawn.md | 2 + .../features/tower/tools/spawn/spawnTool.ts | 20 ++- .../test/features/tower/store.test.ts | 119 ++++++++++++++++++ .../features/tower/tools/spawnTool.test.ts | 27 ++++ 11 files changed, 303 insertions(+), 16 deletions(-) create mode 100644 .changeset/tower-spawn-base-wip-snapshot.md create mode 100644 packages/agent-core-v2/src/features/tower/protocol/baseWip.ts diff --git a/.changeset/tower-spawn-base-wip-snapshot.md b/.changeset/tower-spawn-base-wip-snapshot.md new file mode 100644 index 00000000000..5d155ab23e8 --- /dev/null +++ b/.changeset/tower-spawn-base-wip-snapshot.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Tower mode (experimental, `KIMI_CODE_EXPERIMENTAL_TOWER=1`): spawned workers now start from the base checkout's uncommitted changes instead of missing them, and TowerMerge refuses to merge while the checkout still holds those changes uncommitted. diff --git a/packages/agent-core-v2/src/features/tower/protocol/baseWip.ts b/packages/agent-core-v2/src/features/tower/protocol/baseWip.ts new file mode 100644 index 00000000000..69e782b858e --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/protocol/baseWip.ts @@ -0,0 +1,54 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { git } from './git'; +import { TOWER_ROOT } from './paths'; + +export interface BaseDirtyEntry { + readonly path: string; + readonly unmerged: boolean; +} + +const UNMERGED_CODES = new Set(['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU']); +const ADD_PATHS_CHUNK = 100; + +export async function listBaseDirtyEntries(cwd: string): Promise { + const out = await git(cwd, ['status', '--porcelain', '-z', '--no-renames', '--untracked-files=normal']); + const entries: BaseDirtyEntry[] = []; + for (const record of out.split('\0')) { + if (record.length < 4) continue; + const code = record.slice(0, 2); + const raw = record.slice(3).replace(/\/+$/, ''); + if (raw.length === 0 || raw === TOWER_ROOT || raw.startsWith(`${TOWER_ROOT}/`)) continue; + entries.push({ path: raw, unmerged: UNMERGED_CODES.has(code) }); + } + return entries; +} + +export async function snapshotBaseWip( + cwd: string, + base: string, + paths: readonly string[], + message: string, +): Promise { + if (paths.length === 0) return null; + const baseTip = await git(cwd, ['rev-parse', base]); + const indexDir = await mkdtemp(join(tmpdir(), 'tower-wip-index-')); + const env = { + GIT_INDEX_FILE: join(indexDir, 'index'), + GIT_LITERAL_PATHSPECS: '1', + }; + try { + await git(cwd, ['read-tree', baseTip], { env }); + for (let i = 0; i < paths.length; i += ADD_PATHS_CHUNK) { + await git(cwd, ['add', '-A', '--', ...paths.slice(i, i + ADD_PATHS_CHUNK)], { env }); + } + const tree = await git(cwd, ['write-tree'], { env }); + const baseTree = await git(cwd, ['rev-parse', `${baseTip}^{tree}`]); + if (tree === baseTree) return null; + return await git(cwd, ['commit-tree', tree, '-p', baseTip, '-m', message], { env }); + } finally { + await rm(indexDir, { recursive: true, force: true }); + } +} diff --git a/packages/agent-core-v2/src/features/tower/protocol/git.ts b/packages/agent-core-v2/src/features/tower/protocol/git.ts index b446de79a23..c1f01d27e76 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/git.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/git.ts @@ -12,12 +12,25 @@ export class GitError extends Error { } } -export async function git(cwd: string, args: readonly string[]): Promise { +export interface GitOptions { + readonly env?: Readonly>; +} + +export async function git( + cwd: string, + args: readonly string[], + options: GitOptions = {}, +): Promise { return new Promise((resolve, reject) => { execFile( 'git', [...args], - { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }, + { + cwd, + timeout: GIT_TIMEOUT_MS, + maxBuffer: 16 * 1024 * 1024, + env: options.env === undefined ? process.env : { ...process.env, ...options.env }, + }, (error, stdout, stderr) => { if (error !== null) { reject(new GitError(args, stderr || error.message)); diff --git a/packages/agent-core-v2/src/features/tower/protocol/index.ts b/packages/agent-core-v2/src/features/tower/protocol/index.ts index a58bf65f37f..eccb5de4d59 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/index.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/index.ts @@ -1,3 +1,4 @@ +export * from './baseWip'; export * from './frontmatter'; export * from './git'; export * from './paths'; diff --git a/packages/agent-core-v2/src/features/tower/protocol/store.ts b/packages/agent-core-v2/src/features/tower/protocol/store.ts index fa996ccf591..7db0f0095ce 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/store.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/store.ts @@ -4,6 +4,7 @@ import { dirname, join } from 'node:path'; import picomatch from 'picomatch'; +import { listBaseDirtyEntries, snapshotBaseWip } from './baseWip'; import { parseFrontmatter, renderFrontmatter } from './frontmatter'; import { branchExists, @@ -110,6 +111,12 @@ export interface TowerMissionPatch { readonly taskDone?: string; readonly owner?: string; readonly scope?: readonly string[]; + readonly spawnBase?: string; +} + +export interface TowerAddWorktreeResult { + readonly rel: string; + readonly spawnBase?: string; } const FINDING_TYPES: readonly TowerFindingType[] = ['bug', 'improve', 'vuln', 'idea']; @@ -428,9 +435,19 @@ export class TowerStore { patch.clearBlockers === undefined && patch.taskDone === undefined && patch.owner === undefined && - patch.scope === undefined; + patch.scope === undefined && + patch.spawnBase === undefined; if (isNoOp) return mission; + if (patch.spawnBase !== undefined) { + if (callerName !== TOWER_NAME) { + throw new TowerProtocolError( + `agent "${callerName}" cannot record a mission spawn base — only the tower does`, + ); + } + mission.spawnBase = patch.spawnBase; + } + if (patch.owner !== undefined) { if (callerName !== TOWER_NAME) { throw new TowerProtocolError( @@ -485,7 +502,8 @@ export class TowerStore { patch.blocker === undefined && patch.clearBlockers === undefined && patch.owner === undefined && - patch.scope === undefined; + patch.scope === undefined && + patch.spawnBase === undefined; if (!taskTickOnly && options.silent !== true) { await this.appendLog(callerName, 'mission.update', { id, @@ -494,6 +512,7 @@ export class TowerStore { blocker: patch.blocker !== undefined ? 'added' : undefined, owner: patch.owner, scope: patch.scope?.join(','), + spawn_base: patch.spawnBase, }); } return mission; @@ -757,7 +776,7 @@ export class TowerStore { } if (mission.kind === 'survey') { - const changed = await diffNameOnly(this.repoRoot, state.base, branch); + const changed = await diffNameOnly(this.repoRoot, this.diffBase(state, mission), branch); if (changed.length > 0) { throw await block( 'read-only-survey', @@ -794,7 +813,7 @@ export class TowerStore { ); } - const changed = await diffNameOnly(this.repoRoot, state.base, branch); + const changed = await diffNameOnly(this.repoRoot, this.diffBase(state, mission), branch); const outOfScope = changed.filter( (file) => !mission.scope.some((glob) => picomatch.isMatch(file, glob)), ); @@ -821,6 +840,18 @@ export class TowerStore { ); } + const touched = await diffNameOnly(this.repoRoot, 'HEAD', branch); + if (touched.length > 0) { + const dirty = new Set((await listBaseDirtyEntries(this.repoRoot)).map((entry) => entry.path)); + const blocked = touched.filter((file) => dirty.has(file)); + if (blocked.length > 0) { + throw await block( + 'base-dirty', + `merge blocked: the main checkout has uncommitted changes in file(s) this merge would overwrite: ${blocked.slice(0, 5).join(', ')} — commit or stash them first, then retry; nothing was merged`, + ); + } + } + const mergeCommit = await mergeNoFf(this.repoRoot, branch); mission.status = 'merged'; @@ -829,7 +860,7 @@ export class TowerStore { for (const other of state.missions) { 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 otherChanged = await diffNameOnly(this.repoRoot, this.diffBase(state, other), other.branch); const overlap = otherChanged.filter((file) => changedSet.has(file)); if (overlap.length > 0) { conflictsWith.push({ branch: other.branch, files: overlap }); @@ -843,11 +874,31 @@ export class TowerStore { return { mergeCommit, conflictsWith }; } - async addWorktree(worktree: string, branch: string, base: string): Promise { + private diffBase(state: TowerState, mission: TowerMission): string { + return mission.spawnBase ?? state.base; + } + + async addWorktree(worktree: string, branch: string, base: string): Promise { const rel = join(WORKTREES_DIR, worktree); - await worktreeAdd(this.repoRoot, this.abs(rel), branch, base); - await this.appendLog(TOWER_NAME, 'worktree.add', { worktree, branch, base }); - return rel; + let spawnBase: string | undefined; + if (!(await branchExists(this.repoRoot, branch))) { + const dirty = await listBaseDirtyEntries(this.repoRoot); + if (dirty.some((entry) => entry.unmerged)) { + throw new TowerProtocolError( + 'the base checkout has unmerged paths (an in-progress merge, rebase, or cherry-pick) — finish or abort it before spawning workers', + ); + } + spawnBase = + (await snapshotBaseWip( + this.repoRoot, + base, + dirty.map((entry) => entry.path), + `tower: snapshot of uncommitted base checkout changes (worktree ${worktree})`, + )) ?? undefined; + } + await worktreeAdd(this.repoRoot, this.abs(rel), branch, spawnBase ?? base); + await this.appendLog(TOWER_NAME, 'worktree.add', { worktree, branch, base, spawn_base: spawnBase }); + return { rel, spawnBase }; } async teardown(options: { readonly force?: boolean } = {}): Promise { diff --git a/packages/agent-core-v2/src/features/tower/protocol/types.ts b/packages/agent-core-v2/src/features/tower/protocol/types.ts index d351e7252a1..a35c3e64ac5 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/types.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/types.ts @@ -40,6 +40,7 @@ export interface TowerMission { scope: string[]; readonly branch: string; readonly worktree: string; + spawnBase?: string; readonly deps: readonly string[]; status: TowerMissionStatus; owner?: string; diff --git a/packages/agent-core-v2/src/features/tower/tools/merge/merge.md b/packages/agent-core-v2/src/features/tower/tools/merge/merge.md index afaeaab4069..878ae52f2a1 100644 --- a/packages/agent-core-v2/src/features/tower/tools/merge/merge.md +++ b/packages/agent-core-v2/src/features/tower/tools/merge/merge.md @@ -1,3 +1,5 @@ Merge a tower mission branch into the base branch (--no-ff). -Hard gate, enforced by the store — the merge is refused unless: the branch's latest review is "clean" and was written against the current branch tip, all dependency missions are already merged, and every changed file falls inside the mission's declared scope. On refusal, the error message tells you exactly what to do next (assign a reviewer, wait for fixes, re-review a moved tip, merge deps first, widen the scope or revert the extra changes). After a merge, branches reported as conflicting must rebase onto the new base and be re-reviewed before they can merge. +Hard gate, enforced by the store — the merge is refused unless: the branch's latest review is "clean" and was written against the current branch tip, all dependency missions are already merged, and every changed file falls inside the mission's declared scope. The scope diff starts from the mission's recorded spawn base, so base-checkout WIP captured as a snapshot commit at spawn time is never mistaken for a worker scope violation. On refusal, the error message tells you exactly what to do next (assign a reviewer, wait for fixes, re-review a moved tip, merge deps first, widen the scope or revert the extra changes). After a merge, branches reported as conflicting must rebase onto the new base and be re-reviewed before they can merge. + +The main checkout must be clean for the files the merge touches: if it still has uncommitted changes in any file the merge would overwrite, the merge is refused and nothing is merged — commit or stash those changes first, then retry. This matters when a mission branch carries a snapshot of the checkout's WIP: that WIP merges into the base history, so the checkout must not still hold the same changes uncommitted. diff --git a/packages/agent-core-v2/src/features/tower/tools/spawn/spawn.md b/packages/agent-core-v2/src/features/tower/tools/spawn/spawn.md index 425d2aed016..6713815b2ad 100644 --- a/packages/agent-core-v2/src/features/tower/tools/spawn/spawn.md +++ b/packages/agent-core-v2/src/features/tower/tools/spawn/spawn.md @@ -2,4 +2,6 @@ Spawn a tower worker or reviewer as a background subagent and register it in the Workers: pass mission_id — the tool creates the mission worktree, marks the mission active with this worker as owner, and briefs the agent with the full mission text. Reviewers: pass review_target — the agent gets a review checklist and must submit its verdict via TowerReview. +If the base checkout has uncommitted changes (staged, unstaged, or untracked) when a worker spawns, the tool captures them as a snapshot commit that becomes the mission branch's first commit — the worker starts from HEAD + that WIP instead of plain HEAD. The checkout itself is never touched (nothing is committed, staged, or stashed there), and the merge gate later diffs the branch from that snapshot, so the WIP is never mistaken for the worker's own scope. The snapshot only happens when the branch is first created; re-adding an existing branch reuses it as-is. + The briefing prompt is assembled by this tool (worktree path, scope, protocol rules); use instructions only for extra context. If the name is already registered, resume the existing agent with the Agent tool instead of spawning a duplicate. diff --git a/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts b/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts index a50eb5c83be..449c1387899 100644 --- a/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts @@ -132,7 +132,14 @@ export class TowerSpawnTool implements ITowerSpawnTool { }; } try { - await store.addWorktree(mission.worktree, mission.branch, state.base); + const added = await store.addWorktree(mission.worktree, mission.branch, state.base); + if (added.spawnBase !== undefined) { + await store.updateMission(TOWER_NAME, mission.id, { spawnBase: added.spawnBase }, { silent: true }); + mission = { ...mission, spawnBase: added.spawnBase }; + notes.push( + `base snapshot: ${added.spawnBase.slice(0, 7)} — the base checkout had uncommitted changes; they are committed as the branch's first commit (the checkout itself was left untouched)`, + ); + } } catch (error) { notes.push( `worktree setup warning (continuing): ${error instanceof Error ? error.message : String(error)}`, @@ -346,6 +353,9 @@ export class TowerSpawnTool implements ITowerSpawnTool { `# Your workplace\n` + `- Your private git worktree: ${worktreeAbs}\n` + `- Your branch: ${mission.branch} (base: ${state.base})\n` + + (mission.spawnBase !== undefined + ? `- Your branch starts from snapshot commit ${mission.spawnBase.slice(0, 7)}: the base checkout's uncommitted changes (WIP), captured at spawn so you can build on them. That commit is your foundation — never revert, amend, or claim it as your own work; your own commits go on top of it.\n` + : '') + `- Your working directory is the main checkout, NOT your worktree — address the worktree explicitly: every Read/Write/Edit/Grep/Glob path must be absolute and under ${worktreeAbs}, and every Bash command must \`cd ${worktreeAbs}\` first. A permission guard hard-denies any Write/Edit outside it. Never touch the main checkout (${store.repoRoot}) or another agent's worktree slot.\n` + (mission.kind === 'survey' ? `- Scope — what you investigate (read-only; reserves nothing): ${mission.scope.join(', ')}\n\n` @@ -386,12 +396,14 @@ export class TowerSpawnTool implements ITowerSpawnTool { ); } const target = reviewTarget ?? ''; - const author = state.missions.find((m) => m.branch === target)?.owner; + const targetMission = state.missions.find((m) => m.branch === target); + const author = targetMission?.owner; + const reviewBase = targetMission?.spawnBase ?? state.base; return ( `You are "${args.name}", a tower reviewer agent in a multi-agent workspace.\n\n` + `# Your assignment\n` + - `Review branch "${target}" against base "${state.base}".\n` + - `- Work read-only in the main checkout (${store.repoRoot}): \`git diff ${state.base}...${target}\`, \`git log ${state.base}..${target}\`, and read files as needed.\n` + + `Review branch "${target}" against base "${reviewBase}".\n` + + `- Work read-only in the main checkout (${store.repoRoot}): \`git diff ${reviewBase}...${target}\`, \`git log ${reviewBase}..${target}\`, and read files as needed.\n` + '- Do NOT modify any code, and never create or edit files under `.tower/` by hand — protocol artifacts go through the tower tools.\n\n' + `# Review checklist (in priority order)\n` + '1. Security\n2. Data integrity\n3. Performance\n4. Error handling\n5. Code quality\n\n' + diff --git a/packages/agent-core-v2/test/features/tower/store.test.ts b/packages/agent-core-v2/test/features/tower/store.test.ts index 6d8bfd3afc2..05d8e53b629 100644 --- a/packages/agent-core-v2/test/features/tower/store.test.ts +++ b/packages/agent-core-v2/test/features/tower/store.test.ts @@ -757,6 +757,125 @@ describe('merge gate', () => { }); }); +describe('dirty base checkout', () => { + beforeEach(async () => { + await store.init(); + }); + + it('snapshots uncommitted base changes as the mission branch base without touching the checkout', async () => { + await writeFile(join(repo, 'README.md'), '# fixture\nwip edit\n'); + await writeFile(join(repo, 'staged.ts'), 'export const staged = 1;\n'); + await git(repo, 'add', 'staged.ts'); + await writeFile(join(repo, 'untracked.ts'), 'export const untracked = 1;\n'); + const statusBefore = await git(repo, 'status', '--porcelain'); + const baseTip = await git(repo, 'rev-parse', 'main'); + + const [mission] = await store.plan([{ title: 'wip consumer', scope: ['src/**'] }]); + const state = await store.load(); + const added = await store.addWorktree(mission!.worktree, mission!.branch, state.base); + + expect(added.spawnBase).toBeDefined(); + const wt = worktreeOf(mission!); + expect(await readFile(join(wt, 'README.md'), 'utf8')).toBe('# fixture\nwip edit\n'); + expect(await readFile(join(wt, 'staged.ts'), 'utf8')).toBe('export const staged = 1;\n'); + expect(await readFile(join(wt, 'untracked.ts'), 'utf8')).toBe('export const untracked = 1;\n'); + expect(await git(wt, 'status', '--porcelain')).toBe(''); + + expect(await git(repo, 'rev-parse', `${added.spawnBase}^`)).toBe(baseTip); + expect(await git(repo, 'rev-parse', mission!.branch)).toBe(added.spawnBase); + + expect(await git(repo, 'status', '--porcelain')).toBe(statusBefore); + expect(await git(repo, 'rev-parse', 'main')).toBe(baseTip); + expect((await store.load()).missions[0]?.spawnBase).toBeUndefined(); + + const log = (await store.recentLog(3)).join('\n'); + expect(log).toContain('worktree.add'); + expect(log).toContain(`spawn_base=${added.spawnBase}`); + }); + + it('excludes .tower/ protocol files and gitignored paths from the snapshot', async () => { + await commitFile(repo, '.gitignore', 'ignored/\n', 'ignore rules'); + await mkdir(join(repo, 'ignored'), { recursive: true }); + await writeFile(join(repo, 'ignored/blob.txt'), 'ignored\n'); + await writeFile(join(repo, 'wip.ts'), 'wip\n'); + + const [mission] = await store.plan([{ title: 'wip consumer', scope: ['src/**'] }]); + const state = await store.load(); + const added = await store.addWorktree(mission!.worktree, mission!.branch, state.base); + + expect(added.spawnBase).toBeDefined(); + const snapshotFiles = (await git(repo, 'diff', '--name-only', `${added.spawnBase}^`, added.spawnBase!)).split('\n'); + expect(snapshotFiles).toEqual(['wip.ts']); + await expect(stat(join(worktreeOf(mission!), '.tower'))).rejects.toThrow(); + await expect(stat(join(worktreeOf(mission!), 'ignored'))).rejects.toThrow(); + }); + + it('refuses to create a worktree while the base checkout has unmerged paths', async () => { + await git(repo, 'checkout', '-b', 'side'); + await commitFile(repo, 'conflict.txt', 'side\n', 'side change'); + await git(repo, 'checkout', 'main'); + await commitFile(repo, 'conflict.txt', 'main\n', 'main change'); + await expect(git(repo, 'merge', 'side')).rejects.toThrow(); + + const [mission] = await store.plan([{ title: 'wip consumer', scope: ['src/**'] }]); + const state = await store.load(); + await expect( + store.addWorktree(mission!.worktree, mission!.branch, state.base), + ).rejects.toThrow(/unmerged paths/); + + await git(repo, 'merge', '--abort'); + }); + + it('the merge gate ignores snapshotted base WIP and blocks only while the checkout still holds it', async () => { + await writeFile(join(repo, 'wip.ts'), 'export const wip = 1;\n'); + const [mission] = await store.plan([{ title: 'feature x', scope: ['src/x/**'] }]); + const state = await store.load(); + const added = await store.addWorktree(mission!.worktree, mission!.branch, state.base); + expect(added.spawnBase).toBeDefined(); + await store.updateMission('tower', mission!.id, { spawnBase: added.spawnBase }); + await store.registerAgent( + rosterEntry({ name: 'rev', kind: 'reviewer', reviewTarget: mission!.branch }), + ); + await commitFile(worktreeOf(mission!), 'src/x/x.ts', 'export const x = 1;\n', 'work on M1'); + await cleanReview('rev', mission!.branch); + + await expect(store.merge(mission!.branch)).rejects.toThrow( + /uncommitted changes in file\(s\) this merge would overwrite: wip\.ts/, + ); + const log = (await store.recentLog(3)).join('\n'); + expect(log).toContain('merge.blocked'); + expect(log).toContain('reason=base-dirty'); + expect((await store.load()).missions[0]?.status).not.toBe('merged'); + + await git(repo, 'add', 'wip.ts'); + await git(repo, 'commit', '-m', 'commit my wip'); + + const { mergeCommit } = await store.merge(mission!.branch); + expect(mergeCommit).toBe(await git(repo, 'rev-parse', 'HEAD')); + expect((await store.load()).missions[0]?.status).toBe('merged'); + expect(await readFile(join(repo, 'wip.ts'), 'utf8')).toBe('export const wip = 1;\n'); + expect(await readFile(join(repo, 'src/x/x.ts'), 'utf8')).toBe('export const x = 1;\n'); + }); + + it('merges when checkout dirt does not intersect the files the merge touches', async () => { + const mission = await setupMission({ + title: 'feature x', + scope: 'src/x/**', + file: 'src/x/x.ts', + content: 'x\n', + }); + await store.registerAgent( + rosterEntry({ name: 'rev', kind: 'reviewer', reviewTarget: mission.branch }), + ); + await cleanReview('rev', mission.branch); + await writeFile(join(repo, 'scratch.txt'), 'unrelated wip\n'); + + const { mergeCommit } = await store.merge(mission.branch); + expect(mergeCommit).toBe(await git(repo, 'rev-parse', 'HEAD')); + expect((await store.load()).missions[0]?.status).toBe('merged'); + }); +}); + describe('updateMission', () => { beforeEach(async () => { await store.init(); diff --git a/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts b/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts index af01729be2b..d639c13b28b 100644 --- a/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts +++ b/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts @@ -446,4 +446,31 @@ describe('TowerSpawnTool', () => { expect(result.output).toContain('Agent(resume="agent-old"'); expect(createAgent).not.toHaveBeenCalled(); }); + + it('snapshots base WIP into the worker branch and records the spawn base', async () => { + await writeFile(join(repo, 'wip.ts'), 'export const wip = 1;\n'); + + const result = await execute(WORKER_ARGS); + + expect(result.isError).toBeUndefined(); + expect(result.output).toContain('base snapshot:'); + const worktreeAbs = join(repo, '.tower/worktrees/wt-1'); + expect(await readFile(join(worktreeAbs, 'wip.ts'), 'utf8')).toBe('export const wip = 1;\n'); + expect(runAgent).toHaveBeenCalledWith( + expect.objectContaining({ agentId: 'agent-7' }), + { kind: 'prompt', prompt: expect.stringContaining('snapshot commit') }, + { signal: expect.any(AbortSignal) }, + ); + const mission = (await store.load()).missions.find((m) => m.id === 'M1'); + expect(mission?.spawnBase).toBeDefined(); + }); + + it('records no spawn base when the base checkout is clean', async () => { + const result = await execute(WORKER_ARGS); + + expect(result.isError).toBeUndefined(); + expect(result.output).not.toContain('base snapshot:'); + const mission = (await store.load()).missions.find((m) => m.id === 'M1'); + expect(mission?.spawnBase).toBeUndefined(); + }); }); From bae044a1520419bf5e819a37162ba9aa163d14ec Mon Sep 17 00:00:00 2001 From: konghuanjun Date: Fri, 28 Aug 2026 20:06:58 +0800 Subject: [PATCH 2/9] fix(agent-core-v2): harden tower base-WIP snapshot edge cases - fall back to the base branch as diff base once a rebase drops the snapshot commit (it is only used while still an ancestor), so scope checks, reviewer prompts, and conflict attribution stop blaming base changes on the worker - run snapshot index commands from the worktree top-level and filter .tower by path segment, fixing dirty-base spawning from a repo subdirectory (porcelain paths are worktree-root relative) - refuse to snapshot WIP collected from a checkout that is not the recorded base (or a detached HEAD) instead of mixing another branch's content into base history --- .../src/features/tower/protocol/baseWip.ts | 15 ++-- .../src/features/tower/protocol/git.ts | 4 + .../src/features/tower/protocol/store.ts | 32 ++++++-- .../src/features/tower/tools/merge/merge.md | 2 +- .../src/features/tower/tools/spawn/spawn.md | 2 +- .../features/tower/tools/spawn/spawnTool.ts | 3 +- .../test/features/tower/store.test.ts | 77 +++++++++++++++++++ .../features/tower/tools/spawnTool.test.ts | 29 +++++++ 8 files changed, 149 insertions(+), 15 deletions(-) diff --git a/packages/agent-core-v2/src/features/tower/protocol/baseWip.ts b/packages/agent-core-v2/src/features/tower/protocol/baseWip.ts index 69e782b858e..7a92b8f2f9d 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/baseWip.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/baseWip.ts @@ -20,7 +20,7 @@ export async function listBaseDirtyEntries(cwd: string): Promise { if (paths.length === 0) return null; - const baseTip = await git(cwd, ['rev-parse', base]); + const topLevel = await git(cwd, ['rev-parse', '--show-toplevel']); + const baseTip = await git(topLevel, ['rev-parse', base]); const indexDir = await mkdtemp(join(tmpdir(), 'tower-wip-index-')); const env = { GIT_INDEX_FILE: join(indexDir, 'index'), GIT_LITERAL_PATHSPECS: '1', }; try { - await git(cwd, ['read-tree', baseTip], { env }); + await git(topLevel, ['read-tree', baseTip], { env }); for (let i = 0; i < paths.length; i += ADD_PATHS_CHUNK) { - await git(cwd, ['add', '-A', '--', ...paths.slice(i, i + ADD_PATHS_CHUNK)], { env }); + await git(topLevel, ['add', '-A', '--', ...paths.slice(i, i + ADD_PATHS_CHUNK)], { env }); } - const tree = await git(cwd, ['write-tree'], { env }); - const baseTree = await git(cwd, ['rev-parse', `${baseTip}^{tree}`]); + const tree = await git(topLevel, ['write-tree'], { env }); + const baseTree = await git(topLevel, ['rev-parse', `${baseTip}^{tree}`]); if (tree === baseTree) return null; - return await git(cwd, ['commit-tree', tree, '-p', baseTip, '-m', message], { env }); + return await git(topLevel, ['commit-tree', tree, '-p', baseTip, '-m', message], { env }); } finally { await rm(indexDir, { recursive: true, force: true }); } diff --git a/packages/agent-core-v2/src/features/tower/protocol/git.ts b/packages/agent-core-v2/src/features/tower/protocol/git.ts index c1f01d27e76..9ccdd8c7136 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/git.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/git.ts @@ -74,6 +74,10 @@ export async function branchExists(cwd: string, branch: string): Promise { + return (await tryGit(cwd, ['merge-base', '--is-ancestor', ancestor, ref])) !== null; +} + export async function worktreeAdd( cwd: string, path: string, diff --git a/packages/agent-core-v2/src/features/tower/protocol/store.ts b/packages/agent-core-v2/src/features/tower/protocol/store.ts index 7db0f0095ce..be7eeb36130 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/store.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/store.ts @@ -12,6 +12,7 @@ import { currentBranch, diffNameOnly, hasAnyCommit, + isAncestor, isInsideRepo, isWorktreeDirty, mergeNoFf, @@ -776,7 +777,7 @@ export class TowerStore { } if (mission.kind === 'survey') { - const changed = await diffNameOnly(this.repoRoot, this.diffBase(state, mission), branch); + const changed = await diffNameOnly(this.repoRoot, await this.diffBase(state, mission), branch); if (changed.length > 0) { throw await block( 'read-only-survey', @@ -813,7 +814,7 @@ export class TowerStore { ); } - const changed = await diffNameOnly(this.repoRoot, this.diffBase(state, mission), branch); + const changed = await diffNameOnly(this.repoRoot, await this.diffBase(state, mission), branch); const outOfScope = changed.filter( (file) => !mission.scope.some((glob) => picomatch.isMatch(file, glob)), ); @@ -860,7 +861,7 @@ export class TowerStore { for (const other of state.missions) { if (other.branch === branch || !isOpenMission(other)) continue; if (!(await branchExists(this.repoRoot, other.branch))) continue; - const otherChanged = await diffNameOnly(this.repoRoot, this.diffBase(state, other), other.branch); + const otherChanged = await diffNameOnly(this.repoRoot, await this.diffBase(state, other), other.branch); const overlap = otherChanged.filter((file) => changedSet.has(file)); if (overlap.length > 0) { conflictsWith.push({ branch: other.branch, files: overlap }); @@ -874,8 +875,14 @@ export class TowerStore { return { mergeCommit, conflictsWith }; } - private diffBase(state: TowerState, mission: TowerMission): string { - return mission.spawnBase ?? state.base; + async diffBase(state: TowerState, mission: TowerMission): Promise { + if ( + mission.spawnBase !== undefined && + (await isAncestor(this.repoRoot, mission.spawnBase, mission.branch)) + ) { + return mission.spawnBase; + } + return state.base; } async addWorktree(worktree: string, branch: string, base: string): Promise { @@ -888,6 +895,21 @@ export class TowerStore { 'the base checkout has unmerged paths (an in-progress merge, rebase, or cherry-pick) — finish or abort it before spawning workers', ); } + if (dirty.length > 0) { + let checkout: string; + try { + checkout = await currentBranch(this.repoRoot); + } catch { + throw new TowerProtocolError( + `the main checkout is in a detached HEAD state with uncommitted changes, and the recorded base is "${base}" — a WIP snapshot would carry detached-HEAD content into the mission branch; check out "${base}" (\`git checkout ${base}\`) or commit/stash the changes before spawning workers`, + ); + } + if (checkout !== base) { + throw new TowerProtocolError( + `the main checkout is on "${checkout}" with uncommitted changes, not the recorded base "${base}" — a WIP snapshot would carry "${checkout}" content into the mission branch; switch back to "${base}" (\`git checkout ${base}\`) or commit/stash the changes before spawning workers`, + ); + } + } spawnBase = (await snapshotBaseWip( this.repoRoot, diff --git a/packages/agent-core-v2/src/features/tower/tools/merge/merge.md b/packages/agent-core-v2/src/features/tower/tools/merge/merge.md index 878ae52f2a1..062d5a21b7b 100644 --- a/packages/agent-core-v2/src/features/tower/tools/merge/merge.md +++ b/packages/agent-core-v2/src/features/tower/tools/merge/merge.md @@ -1,5 +1,5 @@ Merge a tower mission branch into the base branch (--no-ff). -Hard gate, enforced by the store — the merge is refused unless: the branch's latest review is "clean" and was written against the current branch tip, all dependency missions are already merged, and every changed file falls inside the mission's declared scope. The scope diff starts from the mission's recorded spawn base, so base-checkout WIP captured as a snapshot commit at spawn time is never mistaken for a worker scope violation. On refusal, the error message tells you exactly what to do next (assign a reviewer, wait for fixes, re-review a moved tip, merge deps first, widen the scope or revert the extra changes). After a merge, branches reported as conflicting must rebase onto the new base and be re-reviewed before they can merge. +Hard gate, enforced by the store — the merge is refused unless: the branch's latest review is "clean" and was written against the current branch tip, all dependency missions are already merged, and every changed file falls inside the mission's declared scope. The scope diff starts from the mission's recorded spawn base while that snapshot commit is still part of the branch's history, so base-checkout WIP captured as a snapshot commit at spawn time is never mistaken for a worker scope violation; once a rebase drops the snapshot (typically because the WIP has since been committed on the base branch), the diff falls back to the base branch. On refusal, the error message tells you exactly what to do next (assign a reviewer, wait for fixes, re-review a moved tip, merge deps first, widen the scope or revert the extra changes). After a merge, branches reported as conflicting must rebase onto the new base and be re-reviewed before they can merge. The main checkout must be clean for the files the merge touches: if it still has uncommitted changes in any file the merge would overwrite, the merge is refused and nothing is merged — commit or stash those changes first, then retry. This matters when a mission branch carries a snapshot of the checkout's WIP: that WIP merges into the base history, so the checkout must not still hold the same changes uncommitted. diff --git a/packages/agent-core-v2/src/features/tower/tools/spawn/spawn.md b/packages/agent-core-v2/src/features/tower/tools/spawn/spawn.md index 6713815b2ad..09c149b56b3 100644 --- a/packages/agent-core-v2/src/features/tower/tools/spawn/spawn.md +++ b/packages/agent-core-v2/src/features/tower/tools/spawn/spawn.md @@ -2,6 +2,6 @@ Spawn a tower worker or reviewer as a background subagent and register it in the Workers: pass mission_id — the tool creates the mission worktree, marks the mission active with this worker as owner, and briefs the agent with the full mission text. Reviewers: pass review_target — the agent gets a review checklist and must submit its verdict via TowerReview. -If the base checkout has uncommitted changes (staged, unstaged, or untracked) when a worker spawns, the tool captures them as a snapshot commit that becomes the mission branch's first commit — the worker starts from HEAD + that WIP instead of plain HEAD. The checkout itself is never touched (nothing is committed, staged, or stashed there), and the merge gate later diffs the branch from that snapshot, so the WIP is never mistaken for the worker's own scope. The snapshot only happens when the branch is first created; re-adding an existing branch reuses it as-is. +If the base checkout has uncommitted changes (staged, unstaged, or untracked) when a worker spawns, the tool captures them as a snapshot commit that becomes the mission branch's first commit — the worker starts from HEAD + that WIP instead of plain HEAD. The checkout itself is never touched (nothing is committed, staged, or stashed there), and the merge gate later diffs the branch from that snapshot while it remains part of the branch's history (falling back to the base branch once a rebase drops the snapshot commit), so the WIP is never mistaken for the worker's own scope. Snapshotting requires the main checkout to be on the recorded base branch: WIP sitting on a different branch (or a detached HEAD) belongs to that line of work, so the spawn is refused rather than mixing that content into the base — switch back to the base or commit/stash first. The snapshot only happens when the branch is first created; re-adding an existing branch reuses it as-is. The briefing prompt is assembled by this tool (worktree path, scope, protocol rules); use instructions only for extra context. If the name is already registered, resume the existing agent with the Agent tool instead of spawning a duplicate. diff --git a/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts b/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts index 449c1387899..2bef3acf4db 100644 --- a/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts @@ -398,7 +398,8 @@ export class TowerSpawnTool implements ITowerSpawnTool { const target = reviewTarget ?? ''; const targetMission = state.missions.find((m) => m.branch === target); const author = targetMission?.owner; - const reviewBase = targetMission?.spawnBase ?? state.base; + const reviewBase = + targetMission !== undefined ? await store.diffBase(state, targetMission) : state.base; return ( `You are "${args.name}", a tower reviewer agent in a multi-agent workspace.\n\n` + `# Your assignment\n` + diff --git a/packages/agent-core-v2/test/features/tower/store.test.ts b/packages/agent-core-v2/test/features/tower/store.test.ts index 05d8e53b629..44a740f35c7 100644 --- a/packages/agent-core-v2/test/features/tower/store.test.ts +++ b/packages/agent-core-v2/test/features/tower/store.test.ts @@ -810,6 +810,26 @@ describe('dirty base checkout', () => { await expect(stat(join(worktreeOf(mission!), 'ignored'))).rejects.toThrow(); }); + it('snapshots base WIP when the tower root is a repository subdirectory', async () => { + const sub = join(repo, 'sub'); + await mkdir(sub, { recursive: true }); + const subStore = new TowerStore(sub); + await subStore.init(); + await writeFile(join(sub, 'wip.ts'), 'export const wip = 1;\n'); + + const [mission] = await subStore.plan([{ title: 'wip consumer', scope: ['src/**'] }]); + const state = await subStore.load(); + const added = await subStore.addWorktree(mission!.worktree, mission!.branch, state.base); + + expect(added.spawnBase).toBeDefined(); + const snapshotFiles = ( + await git(repo, 'diff', '--name-only', `${added.spawnBase}^`, added.spawnBase!) + ).split('\n'); + expect(snapshotFiles).toEqual(['sub/wip.ts']); + const wt = join(sub, '.tower/worktrees', mission!.worktree); + expect(await readFile(join(wt, 'sub/wip.ts'), 'utf8')).toBe('export const wip = 1;\n'); + }); + it('refuses to create a worktree while the base checkout has unmerged paths', async () => { await git(repo, 'checkout', '-b', 'side'); await commitFile(repo, 'conflict.txt', 'side\n', 'side change'); @@ -826,6 +846,31 @@ describe('dirty base checkout', () => { await git(repo, 'merge', '--abort'); }); + it('refuses to snapshot WIP from a checkout that is not the recorded base', async () => { + await git(repo, 'checkout', '-b', 'side'); + await commitFile(repo, 'README.md', '# side\n', 'side version'); + await writeFile(join(repo, 'README.md'), '# side wip\n'); + + const [mission] = await store.plan([{ title: 'wip consumer', scope: ['src/**'] }]); + const state = await store.load(); + await expect( + store.addWorktree(mission!.worktree, mission!.branch, state.base), + ).rejects.toThrow(/on "side" with uncommitted changes, not the recorded base "main"/); + await expect(stat(join(repo, '.tower/worktrees', mission!.worktree))).rejects.toThrow(); + await expect(git(repo, 'rev-parse', '--verify', mission!.branch)).rejects.toThrow(); + }); + + it('refuses to snapshot WIP from a detached HEAD checkout', async () => { + await writeFile(join(repo, 'wip.ts'), 'export const wip = 1;\n'); + await git(repo, 'checkout', '--detach', 'HEAD'); + + const [mission] = await store.plan([{ title: 'wip consumer', scope: ['src/**'] }]); + const state = await store.load(); + await expect( + store.addWorktree(mission!.worktree, mission!.branch, state.base), + ).rejects.toThrow(/detached HEAD state with uncommitted changes/); + }); + it('the merge gate ignores snapshotted base WIP and blocks only while the checkout still holds it', async () => { await writeFile(join(repo, 'wip.ts'), 'export const wip = 1;\n'); const [mission] = await store.plan([{ title: 'feature x', scope: ['src/x/**'] }]); @@ -857,6 +902,38 @@ describe('dirty base checkout', () => { expect(await readFile(join(repo, 'src/x/x.ts'), 'utf8')).toBe('export const x = 1;\n'); }); + it('falls back to the base branch for the scope diff after a rebase drops the snapshot', async () => { + await writeFile(join(repo, 'wip.ts'), 'export const wip = 1;\n'); + const [mission] = await store.plan([{ title: 'feature x', scope: ['src/x/**'] }]); + const state = await store.load(); + const added = await store.addWorktree(mission!.worktree, mission!.branch, state.base); + expect(added.spawnBase).toBeDefined(); + await store.updateMission('tower', mission!.id, { spawnBase: added.spawnBase }); + const wt = worktreeOf(mission!); + await commitFile(wt, 'src/x/x.ts', 'export const x = 1;\n', 'work on M1'); + await store.registerAgent( + rosterEntry({ name: 'rev', kind: 'reviewer', reviewTarget: mission!.branch }), + ); + + await git(repo, 'add', 'wip.ts'); + await git(repo, 'commit', '-m', 'commit my wip'); + await commitFile(repo, 'src/other/base.ts', 'export const other = 1;\n', 'later base work'); + + await git(wt, 'rebase', state.base); + await expect( + git(repo, 'merge-base', '--is-ancestor', added.spawnBase!, mission!.branch), + ).rejects.toThrow(); + + await cleanReview('rev', mission!.branch); + const { mergeCommit } = await store.merge(mission!.branch); + expect(mergeCommit).toBe(await git(repo, 'rev-parse', 'HEAD')); + expect((await store.load()).missions[0]?.status).toBe('merged'); + expect(await readFile(join(repo, 'src/x/x.ts'), 'utf8')).toBe('export const x = 1;\n'); + expect(await readFile(join(repo, 'src/other/base.ts'), 'utf8')).toBe( + 'export const other = 1;\n', + ); + }); + it('merges when checkout dirt does not intersect the files the merge touches', async () => { const mission = await setupMission({ title: 'feature x', diff --git a/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts b/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts index d639c13b28b..21535b1861f 100644 --- a/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts +++ b/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts @@ -465,6 +465,35 @@ describe('TowerSpawnTool', () => { expect(mission?.spawnBase).toBeDefined(); }); + it('bases the reviewer prompt on the base branch once a rebase drops the snapshot', async () => { + await writeFile(join(repo, 'wip.ts'), 'export const wip = 1;\n'); + const workerResult = await execute(WORKER_ARGS); + expect(workerResult.isError).toBeUndefined(); + const snapshot = (await store.load()).missions.find((m) => m.id === 'M1')?.spawnBase; + expect(snapshot).toBeDefined(); + const worktreeAbs = join(repo, '.tower/worktrees/wt-1'); + + await git(repo, 'add', 'wip.ts'); + await git(repo, 'commit', '-m', 'commit my wip'); + await git(worktreeAbs, 'rebase', 'main'); + await expect( + git(worktreeAbs, 'merge-base', '--is-ancestor', snapshot!, 'feat/build-gemm'), + ).rejects.toThrow(); + + const result = await execute({ + name: 'reviewer-a', + kind: 'reviewer', + review_target: 'feat/build-gemm', + }); + + expect(result.isError).toBeUndefined(); + expect(runAgent).toHaveBeenLastCalledWith( + expect.objectContaining({ agentId: 'agent-7' }), + { kind: 'prompt', prompt: expect.stringContaining('against base "main"') }, + { signal: expect.any(AbortSignal) }, + ); + }); + it('records no spawn base when the base checkout is clean', async () => { const result = await execute(WORKER_ARGS); From 79ab2055179a529d71ff39707e9cdc5a51e01f1e Mon Sep 17 00:00:00 2001 From: konghuanjun Date: Fri, 28 Aug 2026 20:56:08 +0800 Subject: [PATCH 3/9] fix(agent-core-v2): let tower mode take over from a live but idle owner session The tower-mode ownership check refused entry whenever the recorded owner session was still materialized in the process. kap-server keeps sessions materialized until explicit close, so a session that stopped abnormally (closed tab, failed turn) owned the workspace tower forever and no second session could enter tower mode. Now the owner is only protected while actually occupied: entry is refused when the owner session has an active turn or a pending interaction; a live but idle owner is exited remotely (durable TowerModeExit in its own event log) and the entering session takes over. --- .changeset/tower-mode-idle-owner-takeover.md | 5 + .../src/features/tower/towerService.ts | 18 ++- .../test/features/tower/towerService.test.ts | 103 +++++++++++++++++- 3 files changed, 115 insertions(+), 11 deletions(-) create mode 100644 .changeset/tower-mode-idle-owner-takeover.md diff --git a/.changeset/tower-mode-idle-owner-takeover.md b/.changeset/tower-mode-idle-owner-takeover.md new file mode 100644 index 00000000000..ecafb160228 --- /dev/null +++ b/.changeset/tower-mode-idle-owner-takeover.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Tower mode (experimental, `KIMI_CODE_EXPERIMENTAL_TOWER=1`): a new session can now enter tower mode after the previous owning session stopped without exiting, instead of being refused while that session stays open. diff --git a/packages/agent-core-v2/src/features/tower/towerService.ts b/packages/agent-core-v2/src/features/tower/towerService.ts index f0428cd6eeb..eea16e5cbc1 100644 --- a/packages/agent-core-v2/src/features/tower/towerService.ts +++ b/packages/agent-core-v2/src/features/tower/towerService.ts @@ -19,6 +19,7 @@ import { LifecycleScope } from '#/app/scopes'; import { IFlagService } from '#/app/flag/flag'; import { ISessionManager } from '#/app/sessionManager/sessionManager'; import { IEventDispatcher } from '#/state/eventDispatcher'; +import { ISessionActivityView } from '#/session/sessionActivity/sessionActivity'; import { isWithinDirectory } from '#/tool/path-access'; import type { ToolFileAccess } from '#/tool/toolContract'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -173,12 +174,17 @@ export class AgentTowerService extends Disposable implements IAgentTowerService if (!isTowerFeatureAssembled(this.flags)) return; if (this.isActive) return; const owner = await this.resolveTowerOwner(); - if ( - owner !== undefined && - owner !== this.sessionCtx.sessionId && - this.sessions.get(owner) !== undefined - ) { - return; + if (owner !== undefined && owner !== this.sessionCtx.sessionId) { + const ownerHandle = this.sessions.get(owner); + if (ownerHandle !== undefined) { + const activity = ownerHandle.accessor.get(ISessionActivityView).state(); + if (activity.busy || activity.pendingInteraction !== 'none') return; + ownerHandle.accessor + .get(IAgentLifecycleService) + .handleOf('main') + ?.accessor.get(IAgentTowerService) + .exit(); + } } for (const name of TOWER_MODE_TOOLS) this.profile.addActiveTool(name); this.lastPublished = true; diff --git a/packages/agent-core-v2/test/features/tower/towerService.test.ts b/packages/agent-core-v2/test/features/tower/towerService.test.ts index a7dce353ca5..80aad76394d 100644 --- a/packages/agent-core-v2/test/features/tower/towerService.test.ts +++ b/packages/agent-core-v2/test/features/tower/towerService.test.ts @@ -37,6 +37,10 @@ import { IConfigService } from '#/app/config/config'; import { IFeatureManager } from '#/app/feature/featureManager'; import { IFlagService } from '#/app/flag/flag'; import { ISessionManager } from '#/app/sessionManager/sessionManager'; +import { + ISessionActivityView, + type SessionPendingInteraction, +} from '#/session/sessionActivity/sessionActivity'; import type { ToolCall } from '#/kosong/contract/message'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; @@ -131,7 +135,7 @@ describe('AgentTowerService', () => { let addedTools: string[]; let removedTools: string[]; let activeTools: string[] | undefined; - let liveSessionIds: string[]; + let liveSessions: Map void> }>; let fireUnitsChanged: () => void = () => {}; beforeEach(() => { @@ -147,9 +151,40 @@ describe('AgentTowerService', () => { ix.stub(IAgentToolApprovalService, { formatDenyMessage }); towerFlagOn = true; ix.stub(IFlagService, stubFlag((id) => towerFlagOn && id === TOWER_FLAG_ID)); - liveSessionIds = []; + liveSessions = new Map(); ix.stub(ISessionManager, { - get: (id: string) => (liveSessionIds.includes(id) ? {} : undefined), + get: (id: string) => { + const stub = liveSessions.get(id); + if (stub === undefined) return undefined; + return { + accessor: { + get: (token: unknown) => { + if (token === (ISessionActivityView as unknown)) { + return { + state: () => ({ + busy: stub.busy, + mainTurnActive: stub.busy, + pendingInteraction: stub.pendingInteraction, + }), + }; + } + if (token === (IAgentLifecycleService as unknown)) { + return { + handleOf: () => ({ + accessor: { + get: (agentToken: unknown) => + agentToken === (IAgentTowerService as unknown) + ? { exit: stub.exit } + : undefined, + }, + }), + }; + } + return undefined; + }, + }, + }; + }, } as unknown as ISessionManager); ix.stub(IFeatureManager, { onDidChangeUnits: (handler: () => void) => { @@ -465,7 +500,20 @@ describe('AgentTowerService', () => { expect(events).toContainEqual({ type: 'agent.status.updated', towerMode: true }); }); - it('enter() is a no-op while a foreign session owns the tower in this process', async () => { + function stubLiveSession( + id: string, + init: { busy?: boolean; pendingInteraction?: SessionPendingInteraction } = {}, + ): Mock<() => void> { + const exit = vi.fn(); + liveSessions.set(id, { + busy: init.busy ?? false, + pendingInteraction: init.pendingInteraction ?? 'none', + exit, + }); + return exit; + } + + it('enter() is a no-op while a busy foreign session owns the tower in this process', async () => { const repo = await mkdtemp(join(tmpdir(), 'tower-enter-foreign-')); try { await initGitRepo(repo); @@ -474,7 +522,7 @@ describe('AgentTowerService', () => { await execFileAsync('git', ['commit', '-m', 'initial'], { cwd: repo }); await new TowerStore(repo).init('session-original'); - liveSessionIds = ['session-original']; + stubLiveSession('session-original', { busy: true }); ix.stub(ISessionContext, { cwd: repo, sessionId: 'session-fork' } as unknown as ISessionContext); const tower = ix.get(IAgentTowerService); @@ -487,6 +535,51 @@ describe('AgentTowerService', () => { } }); + it('enter() is a no-op while the owning session waits on an interaction', async () => { + const repo = await mkdtemp(join(tmpdir(), 'tower-enter-pending-')); + try { + await initGitRepo(repo); + await writeFile(join(repo, 'README.md'), '# fixture\n'); + await execFileAsync('git', ['add', 'README.md'], { cwd: repo }); + await execFileAsync('git', ['commit', '-m', 'initial'], { cwd: repo }); + await new TowerStore(repo).init('session-original'); + + stubLiveSession('session-original', { pendingInteraction: 'approval' }); + ix.stub(ISessionContext, { cwd: repo, sessionId: 'session-fork' } as unknown as ISessionContext); + const tower = ix.get(IAgentTowerService); + + await tower.enter(); + + expect(tower.isActive).toBe(false); + expect(addedTools).toEqual([]); + } finally { + await rm(repo, { recursive: true, force: true }); + } + }); + + it('enter() takes the tower over from a live but idle owner session', async () => { + const repo = await mkdtemp(join(tmpdir(), 'tower-enter-takeover-')); + try { + await initGitRepo(repo); + await writeFile(join(repo, 'README.md'), '# fixture\n'); + await execFileAsync('git', ['add', 'README.md'], { cwd: repo }); + await execFileAsync('git', ['commit', '-m', 'initial'], { cwd: repo }); + await new TowerStore(repo).init('session-original'); + + const ownerExit = stubLiveSession('session-original'); + ix.stub(ISessionContext, { cwd: repo, sessionId: 'session-fork' } as unknown as ISessionContext); + const tower = ix.get(IAgentTowerService); + + await tower.enter(); + + expect(tower.isActive).toBe(true); + expect(addedTools).toEqual([...TOWER_MODE_TOOLS]); + expect(ownerExit).toHaveBeenCalledTimes(1); + } finally { + await rm(repo, { recursive: true, force: true }); + } + }); + it('enter() adopts the tower once the owning session is gone — TowerInit stays reachable', async () => { const repo = await mkdtemp(join(tmpdir(), 'tower-enter-stale-')); try { From 664b1f6c783889a216cb21d317663a17acf3acdd Mon Sep 17 00:00:00 2001 From: konghuanjun Date: Fri, 28 Aug 2026 21:37:24 +0800 Subject: [PATCH 4/9] feat(agent-core-v2): keep tower mode active after tower teardown --- .../features/tower/injection/tower-mode-full-reminder.md | 2 +- .../src/features/tower/tools/teardown/teardown.md | 2 +- .../src/features/tower/tools/teardown/teardownTool.ts | 5 +---- .../test/features/tower/tools/towerTools.test.ts | 6 +++--- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/agent-core-v2/src/features/tower/injection/tower-mode-full-reminder.md b/packages/agent-core-v2/src/features/tower/injection/tower-mode-full-reminder.md index 423613aeb8f..4886b82093c 100644 --- a/packages/agent-core-v2/src/features/tower/injection/tower-mode-full-reminder.md +++ b/packages/agent-core-v2/src/features/tower/injection/tower-mode-full-reminder.md @@ -33,7 +33,7 @@ Working principles: - Finding → triage: assign to a mission, plan a new one, or backlog — the disposition is your call; tell the human. - Completion report with a suspicious diff (🟢 claimed, zero changed files) → investigate before accepting. 5. **Merge** — `TowerMerge(branch)` in Dependency Flow order. The gate refuses when there is no clean review for the current tip, dependencies are unmerged, or files escaped the scope — the error message is your next step. After a merge, the result lists branches that now conflict: tell those workers (resume) to rebase onto the new base, resolve, push, and request re-review; their moved tip makes the gate demand a fresh clean review. -6. **Teardown promptly** — when `TowerStatus` shows every mission ✅ merged and no unactioned inbox items remain, call `TowerTeardown` **right away** and report the final summary (missions, merges, review rounds, findings and their disposition). Do not wait for the human to ask: branches and `.tower/comms/` (including the activity log) are kept and dirty worktrees are protected by the tool — only disk is freed. A `/tower teardown` from the human is the same instruction at any earlier point. +6. **Teardown promptly** — when `TowerStatus` shows every mission ✅ merged and no unactioned inbox items remain, call `TowerTeardown` **right away** and report the final summary (missions, merges, review rounds, findings and their disposition). Do not wait for the human to ask: branches and `.tower/comms/` (including the activity log) are kept and dirty worktrees are protected by the tool — only disk is freed. Teardown does **not** exit tower mode — you remain the tower, ready to `TowerInit` the next objective, until the human turns the mode off with `/tower off`. A `/tower teardown` from the human is the same instruction at any earlier point. ## Hard rules for the tower diff --git a/packages/agent-core-v2/src/features/tower/tools/teardown/teardown.md b/packages/agent-core-v2/src/features/tower/tools/teardown/teardown.md index 9e9a6bfee42..83161321b7c 100644 --- a/packages/agent-core-v2/src/features/tower/tools/teardown/teardown.md +++ b/packages/agent-core-v2/src/features/tower/tools/teardown/teardown.md @@ -1,3 +1,3 @@ Tear down the tower workspace after all missions are merged (or abandoned). -Removes the mission worktrees — worktrees with uncommitted changes are kept and listed unless force is set. Exits tower mode. The .tower/comms/ directory (state, inbox, findings, reviews, activity log) is always kept as the audit trail. +Removes the mission worktrees — worktrees with uncommitted changes are kept and listed unless force is set. Tower mode stays active after teardown: the next objective starts with TowerInit, and the human turns the mode off explicitly with /tower off. The .tower/comms/ directory (state, inbox, findings, reviews, activity log) is always kept as the audit trail. diff --git a/packages/agent-core-v2/src/features/tower/tools/teardown/teardownTool.ts b/packages/agent-core-v2/src/features/tower/tools/teardown/teardownTool.ts index 9572c863b2e..8515ccb3be5 100644 --- a/packages/agent-core-v2/src/features/tower/tools/teardown/teardownTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/teardown/teardownTool.ts @@ -1,6 +1,5 @@ import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { ISessionManager } from '#/app/sessionManager/sessionManager'; -import { IAgentTowerService } from '#/features/tower/tower'; import { TowerProtocolError } from '#/features/tower/protocol/index'; import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -23,7 +22,6 @@ export class TowerTeardownTool implements ITowerTeardownTool { constructor( @ISessionContext private readonly sessionContext: ISessionContext, - @IAgentTowerService private readonly tower: IAgentTowerService, @ISessionManager private readonly sessions: ISessionManager, @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, ) {} @@ -55,13 +53,12 @@ export class TowerTeardownTool implements ITowerTeardownTool { ); } const report = await store.teardown({ force: args.force }); - this.tower.exit(); return { output: [ 'tower teardown:', ...report.map((line) => `- ${line}`), '', - 'Tower mode exited. .tower/comms/ (state, inbox, findings, reviews, activity log) is kept as the audit trail — remove it by hand only if you are sure.', + 'Tower mode stays active — the next objective starts with TowerInit, and the human can turn the mode off with /tower off. .tower/comms/ (state, inbox, findings, reviews, activity log) is kept as the audit trail — remove it by hand only if you are sure.', ].join('\n'), }; }), diff --git a/packages/agent-core-v2/test/features/tower/tools/towerTools.test.ts b/packages/agent-core-v2/test/features/tower/tools/towerTools.test.ts index c2c00ef0550..8c3f0f7f673 100644 --- a/packages/agent-core-v2/test/features/tower/tools/towerTools.test.ts +++ b/packages/agent-core-v2/test/features/tower/tools/towerTools.test.ts @@ -282,15 +282,15 @@ describe('TowerPlanTool', () => { }); describe('TowerTeardownTool', () => { - it('tears down the workspace and exits tower mode', async () => { + it('tears down the workspace and keeps tower mode active', async () => { await initViaTool(); const result = await run(ix.get(ITowerTeardownTool), {}); expect(result.isError).toBeFalsy(); expect(result.output).toContain('tower teardown:'); - expect(result.output).toContain('Tower mode exited.'); - expect(towerActive).toBe(false); + expect(result.output).toContain('Tower mode stays active'); + expect(towerActive).toBe(true); }); it('refuses to tear down while the owning session is live in this process', async () => { From 23d20f46a0b07064dd4883761d6bfd647ae52fc3 Mon Sep 17 00:00:00 2001 From: konghuanjun Date: Fri, 28 Aug 2026 21:37:48 +0800 Subject: [PATCH 5/9] chore: add changeset for tower teardown staying active --- .changeset/tower-teardown-stays-active.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tower-teardown-stays-active.md diff --git a/.changeset/tower-teardown-stays-active.md b/.changeset/tower-teardown-stays-active.md new file mode 100644 index 00000000000..65a56b3a312 --- /dev/null +++ b/.changeset/tower-teardown-stays-active.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Keep tower mode on after tower teardown; turn it off explicitly with /tower off. From d6cd7859cbfee765c1134682bbc3d8c1e324e390 Mon Sep 17 00:00:00 2001 From: konghuanjun Date: Sun, 30 Aug 2026 13:21:23 +0800 Subject: [PATCH 6/9] docs(tower): merge changeset --- .changeset/tower-mode-idle-owner-takeover.md | 5 ----- ...spawn-base-wip-snapshot.md => tower-mode-improvements.md} | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 .changeset/tower-mode-idle-owner-takeover.md rename .changeset/{tower-spawn-base-wip-snapshot.md => tower-mode-improvements.md} (52%) diff --git a/.changeset/tower-mode-idle-owner-takeover.md b/.changeset/tower-mode-idle-owner-takeover.md deleted file mode 100644 index ecafb160228..00000000000 --- a/.changeset/tower-mode-idle-owner-takeover.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Tower mode (experimental, `KIMI_CODE_EXPERIMENTAL_TOWER=1`): a new session can now enter tower mode after the previous owning session stopped without exiting, instead of being refused while that session stays open. diff --git a/.changeset/tower-spawn-base-wip-snapshot.md b/.changeset/tower-mode-improvements.md similarity index 52% rename from .changeset/tower-spawn-base-wip-snapshot.md rename to .changeset/tower-mode-improvements.md index 5d155ab23e8..6e84b9a6eaa 100644 --- a/.changeset/tower-spawn-base-wip-snapshot.md +++ b/.changeset/tower-mode-improvements.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Tower mode (experimental, `KIMI_CODE_EXPERIMENTAL_TOWER=1`): spawned workers now start from the base checkout's uncommitted changes instead of missing them, and TowerMerge refuses to merge while the checkout still holds those changes uncommitted. +Tower mode (experimental, `KIMI_CODE_EXPERIMENTAL_TOWER=1`): spawned workers now start from the base checkout's uncommitted changes instead of missing them, and TowerMerge refuses to merge while the checkout still holds those changes uncommitted. Also, a new session can now enter tower mode after the previous owning session stopped without exiting, instead of being refused while that session stays open. From 2d2d26a62df0d9f720e86b0f8f7b247c8107e999 Mon Sep 17 00:00:00 2001 From: konghuanjun Date: Sun, 30 Aug 2026 13:33:14 +0800 Subject: [PATCH 7/9] docs(tower): merge changeset --- .changeset/tower-mode-improvements.md | 2 +- .changeset/tower-teardown-stays-active.md | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 .changeset/tower-teardown-stays-active.md diff --git a/.changeset/tower-mode-improvements.md b/.changeset/tower-mode-improvements.md index 6e84b9a6eaa..035ca165169 100644 --- a/.changeset/tower-mode-improvements.md +++ b/.changeset/tower-mode-improvements.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Tower mode (experimental, `KIMI_CODE_EXPERIMENTAL_TOWER=1`): spawned workers now start from the base checkout's uncommitted changes instead of missing them, and TowerMerge refuses to merge while the checkout still holds those changes uncommitted. Also, a new session can now enter tower mode after the previous owning session stopped without exiting, instead of being refused while that session stays open. +Tower mode (experimental, `KIMI_CODE_EXPERIMENTAL_TOWER=1`): spawned workers now start from the base checkout's uncommitted changes instead of missing them, and TowerMerge refuses to merge while the checkout still holds those changes uncommitted. Also, a new session can now enter tower mode after the previous owning session stopped without exiting, instead of being refused while that session stays open. Tower mode now stays on after tower teardown; turn it off explicitly with /tower off. diff --git a/.changeset/tower-teardown-stays-active.md b/.changeset/tower-teardown-stays-active.md deleted file mode 100644 index 65a56b3a312..00000000000 --- a/.changeset/tower-teardown-stays-active.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Keep tower mode on after tower teardown; turn it off explicitly with /tower off. From b1a1f167b3cfadb53e2ed7e6f6e2ebc679d7fd63 Mon Sep 17 00:00:00 2001 From: konghuanjun Date: Sun, 30 Aug 2026 16:44:56 +0800 Subject: [PATCH 8/9] fix(agent-core-v2): make tower mode mutually exclusive with plan and swarm modes --- .../src/agent/modeMutex/modeMutex.ts | 8 ++ .../src/agent/modeMutex/modeMutexService.ts | 51 +++++++++ packages/agent-core-v2/src/index.ts | 2 + .../test/agent/modeMutex/modeMutex.test.ts | 103 ++++++++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 packages/agent-core-v2/src/agent/modeMutex/modeMutex.ts create mode 100644 packages/agent-core-v2/src/agent/modeMutex/modeMutexService.ts create mode 100644 packages/agent-core-v2/test/agent/modeMutex/modeMutex.test.ts diff --git a/packages/agent-core-v2/src/agent/modeMutex/modeMutex.ts b/packages/agent-core-v2/src/agent/modeMutex/modeMutex.ts new file mode 100644 index 00000000000..1d6f5825650 --- /dev/null +++ b/packages/agent-core-v2/src/agent/modeMutex/modeMutex.ts @@ -0,0 +1,8 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IAgentModeMutexService { + readonly _serviceBrand: undefined; +} + +export const IAgentModeMutexService: ServiceIdentifier = + createDecorator('agentModeMutexService'); diff --git a/packages/agent-core-v2/src/agent/modeMutex/modeMutexService.ts b/packages/agent-core-v2/src/agent/modeMutex/modeMutexService.ts new file mode 100644 index 00000000000..cf95315248a --- /dev/null +++ b/packages/agent-core-v2/src/agent/modeMutex/modeMutexService.ts @@ -0,0 +1,51 @@ +import { Disposable } from '#/_base/di/lifecycle'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { IEventBus } from '#/app/event/eventBus'; +import { LifecycleScope } from '#/app/scopes'; +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 { IAgentModeMutexService } from './modeMutex'; + +export class AgentModeMutexService extends Disposable implements IAgentModeMutexService { + declare readonly _serviceBrand: undefined; + + constructor( + @IAgentPlanService private readonly plan: IAgentPlanService, + @IAgentSwarmService private readonly swarm: IAgentSwarmService, + @IAgentTowerService private readonly tower: IAgentTowerService, + @IAgentStateService private readonly agentState: IAgentStateService, + @IEventBus eventBus: IEventBus, + ) { + super(); + this._register( + eventBus.subscribe(PlanModeEnter, () => { + if (this.tower.isActive) this.tower.exit(); + }), + ); + this._register( + eventBus.subscribe(SwarmModeEnter, () => { + if (this.tower.isActive) this.tower.exit(); + }), + ); + this._register( + eventBus.subscribe(TowerModeEnter, () => { + if (this.agentState.get(planKey).active) this.plan.exit(); + if (this.swarm.isActive) this.swarm.exit(); + }), + ); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentModeMutexService, + AgentModeMutexService, + ScopeActivation.OnScopeCreated, + 'modeMutex', +); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index d5a646d23cd..0519573dc66 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -327,6 +327,8 @@ export * from '#/app/flag/flagService'; export * from '#/agent/activityView/activityView'; import '#/agent/activityView/activityViewService'; +export * from '#/agent/modeMutex/modeMutex'; +import '#/agent/modeMutex/modeMutexService'; export * from '#/features/btw/btw'; export * from '#/features/btw/btwService'; import '#/features/btw/btwFeature'; diff --git a/packages/agent-core-v2/test/agent/modeMutex/modeMutex.test.ts b/packages/agent-core-v2/test/agent/modeMutex/modeMutex.test.ts new file mode 100644 index 00000000000..562681c9cfe --- /dev/null +++ b/packages/agent-core-v2/test/agent/modeMutex/modeMutex.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { IAgentModeMutexService } from '#/agent/modeMutex/modeMutex'; +import { AgentModeMutexService } from '#/agent/modeMutex/modeMutexService'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { AgentStateService } from '#/agent/state/agentStateService'; +import { IEventBus } from '#/app/event/eventBus'; +import { EventBusService } from '#/app/event/eventBusService'; +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 { registerTestAgentWire, testWireScope } from '../../wire/stubs'; + +describe('AgentModeMutexService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + let planExit: ReturnType; + let swarmExit: ReturnType; + let towerExit: ReturnType; + let swarmActive: boolean; + let towerActive: boolean; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + ix.set(IEventBus, new SyncDescriptor(EventBusService)); + ix.set(IAgentStateService, new AgentStateService()); + registerTestAgentWire(ix, testWireScope('wire', 'mode-mutex-test'), { + eventBus: ix.get(IEventBus), + }); + planExit = vi.fn(); + swarmExit = vi.fn(); + towerExit = vi.fn(); + swarmActive = false; + towerActive = false; + ix.stub(IAgentPlanService, { exit: planExit } as unknown as IAgentPlanService); + ix.stub(IAgentSwarmService, { + exit: swarmExit, + get isActive() { + return swarmActive; + }, + } as unknown as IAgentSwarmService); + ix.stub(IAgentTowerService, { + exit: towerExit, + get isActive() { + return towerActive; + }, + } as unknown as IAgentTowerService); + ix.get(IAgentStateService).contributeState(planKey); + ix.set(IAgentModeMutexService, new SyncDescriptor(AgentModeMutexService)); + ix.get(IAgentModeMutexService); + }); + afterEach(() => disposables.dispose()); + + function publish(event: PlanModeEnter | SwarmModeEnter | TowerModeEnter): void { + const agentContext = ix.get(IAgentScopeContext).agentContext; + ix.get(IEventBus).publish(event, agentContext); + } + + it('plan mode entry exits an active tower mode', () => { + towerActive = true; + publish(new PlanModeEnter({ agentId: 'test-agent', id: 'plan_1' })); + expect(towerExit).toHaveBeenCalledTimes(1); + }); + + it('plan mode entry leaves an inactive tower mode alone', () => { + publish(new PlanModeEnter({ agentId: 'test-agent', id: 'plan_1' })); + expect(towerExit).not.toHaveBeenCalled(); + }); + + it('swarm mode entry exits an active tower mode', () => { + towerActive = true; + publish(new SwarmModeEnter({ agentId: 'test-agent', trigger: 'manual' })); + expect(towerExit).toHaveBeenCalledTimes(1); + }); + + it('swarm mode entry leaves an inactive tower mode alone', () => { + publish(new SwarmModeEnter({ agentId: 'test-agent', trigger: 'manual' })); + expect(towerExit).not.toHaveBeenCalled(); + }); + + it('tower mode entry exits an active plan mode and an active swarm mode', () => { + ix.get(IAgentStateService).set(planKey, { active: true, id: 'plan_1' }); + swarmActive = true; + publish(new TowerModeEnter({ agentId: 'test-agent' })); + expect(planExit).toHaveBeenCalledTimes(1); + expect(swarmExit).toHaveBeenCalledTimes(1); + }); + + it('tower mode entry leaves inactive plan and swarm modes alone', () => { + publish(new TowerModeEnter({ agentId: 'test-agent' })); + expect(planExit).not.toHaveBeenCalled(); + expect(swarmExit).not.toHaveBeenCalled(); + }); +}); From 59c3824896b83a0e78871f0e32688304f3e56d25 Mon Sep 17 00:00:00 2001 From: konghuanjun Date: Sun, 30 Aug 2026 16:45:52 +0800 Subject: [PATCH 9/9] chore: add changeset for tower mode mutual exclusion --- .changeset/tower-mode-improvements.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/tower-mode-improvements.md b/.changeset/tower-mode-improvements.md index 035ca165169..5b3eeb163bf 100644 --- a/.changeset/tower-mode-improvements.md +++ b/.changeset/tower-mode-improvements.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Tower mode (experimental, `KIMI_CODE_EXPERIMENTAL_TOWER=1`): spawned workers now start from the base checkout's uncommitted changes instead of missing them, and TowerMerge refuses to merge while the checkout still holds those changes uncommitted. Also, a new session can now enter tower mode after the previous owning session stopped without exiting, instead of being refused while that session stays open. Tower mode now stays on after tower teardown; turn it off explicitly with /tower off. +Tower mode (experimental, `KIMI_CODE_EXPERIMENTAL_TOWER=1`): spawned workers now start from the base checkout's uncommitted changes instead of missing them, and TowerMerge refuses to merge while the checkout still holds those changes uncommitted. Also, a new session can now enter tower mode after the previous owning session stopped without exiting, instead of being refused while that session stays open. Tower mode now stays on after tower teardown; turn it off explicitly with /tower off. Tower mode is now mutually exclusive with plan mode and swarm mode: entering any one of them exits the others.