diff --git a/.changeset/tower-mode-improvements.md b/.changeset/tower-mode-improvements.md new file mode 100644 index 00000000000..5b3eeb163bf --- /dev/null +++ b/.changeset/tower-mode-improvements.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. 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. 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/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/protocol/baseWip.ts b/packages/agent-core-v2/src/features/tower/protocol/baseWip.ts new file mode 100644 index 00000000000..7a92b8f2f9d --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/protocol/baseWip.ts @@ -0,0 +1,55 @@ +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.split('/').includes(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 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(topLevel, ['read-tree', baseTip], { env }); + for (let i = 0; i < paths.length; i += ADD_PATHS_CHUNK) { + await git(topLevel, ['add', '-A', '--', ...paths.slice(i, i + ADD_PATHS_CHUNK)], { env }); + } + 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(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 b446de79a23..9ccdd8c7136 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)); @@ -61,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/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..be7eeb36130 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, @@ -11,6 +12,7 @@ import { currentBranch, diffNameOnly, hasAnyCommit, + isAncestor, isInsideRepo, isWorktreeDirty, mergeNoFf, @@ -110,6 +112,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 +436,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 +503,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 +513,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 +777,7 @@ export class TowerStore { } if (mission.kind === 'survey') { - const changed = await diffNameOnly(this.repoRoot, state.base, branch); + const changed = await diffNameOnly(this.repoRoot, await this.diffBase(state, mission), branch); if (changed.length > 0) { throw await block( 'read-only-survey', @@ -794,7 +814,7 @@ export class TowerStore { ); } - const changed = await diffNameOnly(this.repoRoot, state.base, 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)), ); @@ -821,6 +841,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 +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, state.base, 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 }); @@ -843,11 +875,52 @@ export class TowerStore { return { mergeCommit, conflictsWith }; } - async addWorktree(worktree: string, branch: string, base: string): Promise { + 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 { 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', + ); + } + 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, + 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..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,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 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 425d2aed016..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,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 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 a50eb5c83be..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 @@ -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,15 @@ 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 !== 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` + - `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/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/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/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(); + }); +}); 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..44a740f35c7 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,202 @@ 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('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'); + 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('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/**'] }]); + 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('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', + 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..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 @@ -446,4 +446,60 @@ 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('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); + + 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(); + }); }); 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 () => { 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 {