diff --git a/.changeset/tower-deterministic-orchestration.md b/.changeset/tower-deterministic-orchestration.md new file mode 100644 index 00000000000..33df38bec3c --- /dev/null +++ b/.changeset/tower-deterministic-orchestration.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Tower mode: deterministic orchestration with TowerRebase and LLM-free status, goal/tower mutual exclusion, and workspace protection. diff --git a/apps/kimi-code/src/tui/commands/tower.ts b/apps/kimi-code/src/tui/commands/tower.ts index 890a1c3fa4f..b2912cae6c8 100644 --- a/apps/kimi-code/src/tui/commands/tower.ts +++ b/apps/kimi-code/src/tui/commands/tower.ts @@ -1,6 +1,6 @@ import type { Session } from '@moonshot-ai/kimi-code-sdk'; -import { TOWER_STATUS_PROMPT, TOWER_TEARDOWN_PROMPT } from '../constant/kimi-tui'; +import { TOWER_TEARDOWN_PROMPT } from '../constant/kimi-tui'; import { formatErrorMessage } from '../utils/event-payload'; import type { SlashCommandHost } from './dispatch'; @@ -17,7 +17,16 @@ export async function handleTowerCommand(host: SlashCommandHost, args: string): return; } if (sub === '' || sub === 'status') { - host.sendNormalUserInput(TOWER_STATUS_PROMPT); + const session = await requireSessionEnsured(host); + if (session === undefined) return; + try { + // The SDK returns the engine-rendered summary; its first line is the + // state headline, which the notice lifts into the title. + const [title, ...rest] = (await session.getTowerStatus()).split('\n'); + host.showNotice(title ?? 'Tower status', rest.length > 0 ? rest.join('\n') : undefined); + } catch (error) { + host.showError(`Failed to read tower status: ${formatErrorMessage(error)}`); + } return; } if (sub === 'teardown') { @@ -84,7 +93,7 @@ async function setTowerMode( async function requireSessionEnsured(host: SlashCommandHost): Promise { if (host.session !== undefined) return host.session; - // v2 session-less: lazy-create the session, then toggle — the same path + // v2 session-less: lazy-create the session on first use — the same path // the first prompt takes. return host.ensureSession(); } diff --git a/apps/kimi-code/src/tui/constant/kimi-tui.ts b/apps/kimi-code/src/tui/constant/kimi-tui.ts index f9b946523ed..1a098837b47 100644 --- a/apps/kimi-code/src/tui/constant/kimi-tui.ts +++ b/apps/kimi-code/src/tui/constant/kimi-tui.ts @@ -12,8 +12,6 @@ export const OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE = 'OAuth login expired. Send /l export const TUI_MODE_RESTART_NOTICE = 'TUI mode takes effect after restarting Kimi Code.'; export const SESSIONLESS_STARTUP_NOTICE = 'No session yet — one will be created on your first message.'; -export const TOWER_STATUS_PROMPT = - 'Report the current tower status: call TowerStatus and give a compact summary.'; export const TOWER_TEARDOWN_PROMPT = 'Tear down the tower: call TowerTeardown and report what it did. It refuses to destroy dirty worktrees unless forced.'; export const EXIT_CONFIRM_WINDOW_MS = 1500; diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index c3e0fa03199..d01ef0117a4 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -730,6 +730,10 @@ export class SessionEventHandler { event.swarmMode === false && this.host.state.appState.swarmMode && this.host.state.swarmModeEntry === 'task'; + const shouldRenderTowerGoalExit = + event.towerMode === false && + this.host.state.appState.towerMode && + event.towerExitReason === 'mode-mutex'; const patch: Partial = {}; if (event.contextTokens !== undefined) patch.contextTokens = event.contextTokens; if (event.maxContextTokens !== undefined) patch.maxContextTokens = event.maxContextTokens; @@ -763,6 +767,12 @@ export class SessionEventHandler { this.renderSwarmModeMarker('ended'); } } + if (shouldRenderTowerGoalExit) { + this.host.state.transcriptContainer.addChild( + new StatusMessageComponent('Tower mode ended — a goal was activated'), + ); + this.host.state.ui.requestRender(); + } } private renderSwarmModeMarker(state: SwarmModeMarkerState): void { diff --git a/apps/kimi-code/test/tui/commands/tower.test.ts b/apps/kimi-code/test/tui/commands/tower.test.ts index 3033bf31449..c3b953f890f 100644 --- a/apps/kimi-code/test/tui/commands/tower.test.ts +++ b/apps/kimi-code/test/tui/commands/tower.test.ts @@ -4,7 +4,7 @@ import type { Session } from '@moonshot-ai/kimi-code-sdk'; import { handleTowerCommand } from '#/tui/commands/index'; import type { SlashCommandHost } from '#/tui/commands/dispatch'; -import { TOWER_STATUS_PROMPT, TOWER_TEARDOWN_PROMPT } from '#/tui/constant/kimi-tui'; +import { TOWER_TEARDOWN_PROMPT } from '#/tui/constant/kimi-tui'; function makeHost( overrides: { @@ -20,6 +20,9 @@ function makeHost( if (!(overrides.refuseTowerEntry && enabled)) engineMode = enabled; }), getStatus: vi.fn(async () => ({ towerMode: engineMode })), + getTowerStatus: vi.fn(async (): Promise => + engineMode ? 'Tower mode: ON\nTower is not initialized.' : 'Tower mode: OFF', + ), }; const hasSession = overrides.hasSession ?? true; const host = { @@ -43,28 +46,95 @@ function makeHost( showStatus: vi.fn(), showNotice: vi.fn(), sendNormalUserInput: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + restoreInputText: vi.fn(), } as unknown as SlashCommandHost; return { host, session }; } describe('handleTowerCommand', () => { - it('reports tower status when called without args, without touching the mode', async () => { + it('reads tower status without args through the SDK without prompting or touching the editor', async () => { const { host, session } = makeHost({ towerMode: false }); await handleTowerCommand(host, ''); - expect(host.sendNormalUserInput).toHaveBeenCalledWith(TOWER_STATUS_PROMPT); + expect(session.getTowerStatus).toHaveBeenCalledOnce(); + expect(host.showNotice).toHaveBeenCalledWith('Tower mode: OFF', undefined); expect(session.setTowerMode).not.toHaveBeenCalled(); expect(host.ensureSession).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + expect(host.restoreEditor).not.toHaveBeenCalled(); + expect(host.restoreInputText).not.toHaveBeenCalled(); }); - it('reports tower status for the status subcommand, without touching the mode', async () => { + it('reports active but uninitialized status without calling it OFF', async () => { const { host, session } = makeHost({ towerMode: true }); await handleTowerCommand(host, 'status'); - expect(host.sendNormalUserInput).toHaveBeenCalledWith(TOWER_STATUS_PROMPT); + expect(session.getTowerStatus).toHaveBeenCalledOnce(); + expect(host.showNotice).toHaveBeenCalledWith('Tower mode: ON', 'Tower is not initialized.'); + expect(host.showNotice).not.toHaveBeenCalledWith('Tower mode: OFF', undefined); expect(session.setTowerMode).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('shows the engine-rendered status summary through showNotice', async () => { + const { host, session } = makeHost({ towerMode: true }); + const detail = [ + 'Base: main (mode: branch) · You are: tower', + 'Missions:', + ' M1 Build engine — active · owner w1 · feat/build-engine', + 'Roster:', + ' w1 (worker) — mission M1 · agent-w1', + 'Review gate:', + ' M1 feat/build-engine — BLOCKED: not-completed', + 'Inbox: 2 message(s)', + 'Concurrency: budget: 3 agent(s) · inflight: 1 · spawns open', + 'Recent activity:', + ' 2026-09-30T00:00:00.000Z tower plan missions=1', + ].join('\n'); + session.getTowerStatus.mockResolvedValueOnce(`Tower status — ON\n${detail}`); + + await handleTowerCommand(host, 'status'); + + expect(host.showNotice).toHaveBeenCalledWith('Tower status — ON', detail); + + session.getTowerStatus.mockResolvedValueOnce(`Tower mode: OFF\n${detail}`); + await handleTowerCommand(host, 'status'); + + expect(host.showNotice).toHaveBeenCalledWith('Tower mode: OFF', detail); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + + it('lazy-creates a missing session before reading tower status', async () => { + const { host, session } = makeHost({ hasSession: false }); + + await handleTowerCommand(host, 'status'); + + expect(host.ensureSession).toHaveBeenCalledOnce(); + expect(session.getTowerStatus).toHaveBeenCalledOnce(); + expect(host.showNotice).toHaveBeenCalledWith('Tower mode: OFF', undefined); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('shows status errors without a prompt fallback', async () => { + const { host, session } = makeHost({ towerMode: true }); + session.getTowerStatus.mockRejectedValueOnce(new Error('backend unavailable')); + + await handleTowerCommand(host, 'status'); + + expect(host.showError).toHaveBeenCalledWith( + 'Failed to read tower status: backend unavailable', + ); + expect(host.showNotice).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + expect(host.restoreEditor).not.toHaveBeenCalled(); + expect(host.restoreInputText).not.toHaveBeenCalled(); }); it('sends the teardown instruction for the teardown subcommand, without touching the mode', async () => { diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index f2eee71bbc6..2e5c4c0c550 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -6168,6 +6168,62 @@ command = "vim" expect(driver.state.appState.towerMode).toBe(false); }); + it('renders a marker when tower mode exits because a goal was activated', async () => { + const { driver } = await makeDriver(); + + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'main', + sessionId: 'ses-1', + towerMode: true, + } as Event, + vi.fn(), + ); + expect(driver.state.appState.towerMode).toBe(true); + + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'main', + sessionId: 'ses-1', + towerMode: false, + towerExitReason: 'mode-mutex', + } as Event, + vi.fn(), + ); + + expect(driver.state.appState.towerMode).toBe(false); + const transcript = stripSgr(renderTranscript(driver)); + expect(countOccurrences(transcript, 'Tower mode ended — a goal was activated')).toBe(1); + }); + + it('does not render the goal-exit marker for a reasonless tower exit', async () => { + const { driver } = await makeDriver(); + + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'main', + sessionId: 'ses-1', + towerMode: true, + } as Event, + vi.fn(), + ); + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'main', + sessionId: 'ses-1', + towerMode: false, + } as Event, + vi.fn(), + ); + + expect(driver.state.appState.towerMode).toBe(false); + expect(stripSgr(renderTranscript(driver))).not.toContain('Tower mode ended'); + }); + it('renders swarm mode markers from /swarm commands, not tool-triggered status updates', async () => { const { driver } = await makeDriver(); diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index d96c14a1a22..994d7c5e2b7 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -27,7 +27,7 @@ // references become '(circular)', and class instances collapse to a '(ClassName)' // marker — the wire shape of an entry is the JSON projection of the type here. // -// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 73 keys) +// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 72 keys) // App // Workspace // workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts @@ -116,7 +116,6 @@ // toolExecutor.toolCallDupTypes src/agent/toolExecutor/toolExecutorService.ts // toolSelect.pendingLoaded src/agent/toolSelect/toolSelectService.ts // tower src/features/tower/towerOps.ts -// tower.base src/features/tower/towerOps.ts // tower.owner src/features/tower/towerOps.ts // turn src/agent/loop/turnOps.ts // userTool src/agent/userTool/userToolOps.ts @@ -1307,8 +1306,6 @@ export interface AgentStateSnapshot { // replayable · durable — folds: TowerModeEnter, TowerModeExit 'tower': boolean; // replayable · durable — folds: TowerModeEnter, TowerModeExit - 'tower.base': string | null; - // replayable · durable — folds: TowerModeEnter, TowerModeExit 'tower.owner': string | undefined; } diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index 5845454c21d..fcb577ac0fc 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -80,8 +80,8 @@ // tools.set_active_tools profile.activeTools src/agent/profile/profileOps.ts // tools.unregister_user_tool userTool src/agent/userTool/userToolOps.ts // tools.update_store (none) src/features/todo/todoOps.ts -// tower_mode.enter tower, tower.base, tower.owner src/features/tower/towerOps.ts -// tower_mode.exit tower, tower.base, tower.owner src/features/tower/towerOps.ts +// tower_mode.enter tower, tower.owner src/features/tower/towerOps.ts +// tower_mode.exit tower, tower.owner src/features/tower/towerOps.ts // turn.cancel turn src/agent/loop/turnOps.ts // turn.ended turn src/agent/loop/turnOps.ts // turn.prompt turn src/agent/loop/turnOps.ts @@ -794,18 +794,17 @@ interface ToolsUpdateStorePayload { } /** - * states: tower, tower.base, tower.owner + * states: tower, tower.owner * owner: src/features/tower/towerOps.ts */ interface TowerModeEnterPayload { _name: 'tower_mode.enter'; agentId: string; sessionId?: string; - base?: string; } /** - * states: tower, tower.base, tower.owner + * states: tower, tower.owner * owner: src/features/tower/towerOps.ts */ interface TowerModeExitPayload { diff --git a/packages/agent-core-v2/src/agent/modeMutex/modeMutexService.ts b/packages/agent-core-v2/src/agent/modeMutex/modeMutexService.ts index 54f36afbe4a..bd8b3a1ce1b 100644 --- a/packages/agent-core-v2/src/agent/modeMutex/modeMutexService.ts +++ b/packages/agent-core-v2/src/agent/modeMutex/modeMutexService.ts @@ -1,14 +1,18 @@ import { Disposable } from '#/_base/di/lifecycle'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentStateService } from '#/agent/state/agentState'; +import { AgentStatusUpdated } from '#/agent/usage/usageEvents'; import { IEventBus } from '#/app/event/eventBus'; import { LifecycleScope } from '#/app/scopes'; +import { GoalUpdated } from '#/features/goal/goalOps'; +import { IAgentGoalService } from '#/features/goal/goalService'; import { IAgentPlanService } from '#/features/plan/plan'; import { PlanModeEnter, planKey } from '#/features/plan/planOps'; import { IAgentSwarmService } from '#/features/swarm/agent/swarm'; import { SwarmModeEnter } from '#/features/swarm/swarmOps'; import { IAgentTowerService } from '#/features/tower/tower'; import { TowerModeEnter } from '#/features/tower/towerOps'; +import { IEventDispatcher } from '#/state/eventDispatcher'; import { IAgentModeMutexService } from './modeMutex'; @@ -19,24 +23,43 @@ export class AgentModeMutexService extends Disposable implements IAgentModeMutex @IAgentPlanService private readonly plan: IAgentPlanService, @IAgentSwarmService private readonly swarm: IAgentSwarmService, @IAgentTowerService private readonly tower: IAgentTowerService, + @IAgentGoalService private readonly goal: IAgentGoalService | undefined, @IAgentStateService private readonly agentState: IAgentStateService, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, @IEventBus eventBus: IEventBus, ) { super(); this._register( eventBus.subscribe(PlanModeEnter, () => { - if (this.tower.isActive) void this.tower.exit(); + if (this.tower.isActive) void this.tower.exit('mode-mutex'); }), ); this._register( eventBus.subscribe(SwarmModeEnter, () => { - if (this.tower.isActive) void this.tower.exit(); + if (this.tower.isActive) void this.tower.exit('mode-mutex'); }), ); this._register( eventBus.subscribe(TowerModeEnter, () => { if (this.agentState.get(planKey).active) this.plan.exit(); if (this.swarm.isActive) this.swarm.exit(); + if (this.goal?.getGoal().goal?.status === 'active') { + void this.goal.pauseGoal({ reason: 'Paused after tower mode entered' }, 'system'); + } + }), + ); + this._register( + eventBus.subscribe(GoalUpdated, (event) => { + if (event.snapshot?.status !== 'active') return; + if (!this.tower.isActive) return; + void this.dispatcher.dispatch( + new AgentStatusUpdated({ + agentId: event.agentId, + towerMode: false, + towerExitReason: 'mode-mutex', + }), + ); + void this.tower.exit('mode-mutex'); }), ); } diff --git a/packages/agent-core-v2/src/agent/usage/usageEvents.ts b/packages/agent-core-v2/src/agent/usage/usageEvents.ts index 1e32d314bdc..33c5921093d 100644 --- a/packages/agent-core-v2/src/agent/usage/usageEvents.ts +++ b/packages/agent-core-v2/src/agent/usage/usageEvents.ts @@ -2,6 +2,7 @@ import type { TurnEndReason } from '#/agent/loop/turnEvents'; import type { PermissionMode } from '#/agent/permissionPolicy/types'; import { AgentEvent2 } from '#/app/event/event2'; +import type { TowerExitReason } from '#/features/tower/tower'; import type { UsageStatus } from './usage'; @@ -10,6 +11,7 @@ export interface AgentStatusUpdatedPayload { usage?: UsageStatus; swarmMode?: boolean; towerMode?: boolean; + towerExitReason?: TowerExitReason; planMode?: boolean; model?: string; thinkingEffort?: string; @@ -87,6 +89,7 @@ export interface AgentStatusUpdatedEvent { readonly planMode?: boolean; readonly swarmMode?: boolean; readonly towerMode?: boolean; + readonly towerExitReason?: TowerExitReason; readonly permission?: PermissionMode; readonly usage?: UsageStatus; readonly phase?: AgentPhase; diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index c997fb5a819..ab8e9b42810 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -1,3 +1,5 @@ +import type { TowerExitReason } from '#/features/tower/tower'; + import type { TelemetryPrimitive } from './telemetry'; export interface TelemetryEventMeta { @@ -207,7 +209,7 @@ export interface TowerModeEnterEvent { } export interface TowerModeExitEvent { - reason: 'user' | 'takeover' | 'foreign-reconcile'; + reason: TowerExitReason; } export interface SwarmModeTransitionEvent { diff --git a/packages/agent-core-v2/src/features/tower/bashGuard.ts b/packages/agent-core-v2/src/features/tower/bashGuard.ts new file mode 100644 index 00000000000..2a98902a84b --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/bashGuard.ts @@ -0,0 +1,523 @@ +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; + +import type { BashParseResult, BashSyntaxNode } from '#/app/bashParser/bashParser'; + +import { COMMS_DIR, TOWER_ROOT, WORKTREES_DIR } from './protocol/paths'; + +export const TOWER_BASH_GUARD_PARSE_OPTIONS = { timeoutMs: 500, maxNodes: 10_000 } as const; + +export type TowerBashGuardParse = (source: string) => BashParseResult; + +export async function towerWorkspaceOwned(stateFile: string): Promise { + return readFile(stateFile, 'utf8').then( + (raw) => { + const sessionId = (JSON.parse(raw) as { sessionId?: unknown }).sessionId; + return typeof sessionId === 'string' && sessionId.length > 0; + }, + () => false, + ); +} + +export interface TowerBashGuardTarget { + readonly command: string; + readonly cwd: string; + readonly mainCheckout: string; +} + +interface TowerLayout { + readonly mainCheckout: string; + readonly towerRoot: string; + readonly commsDir: string; + readonly worktreesDir: string; +} + +interface GuardContext { + readonly layout: TowerLayout; + readonly parse: TowerBashGuardParse; + readonly depth: number; +} + +interface WalkResult { + readonly veto?: string; + readonly cwd: string; +} + +const MAX_NESTED_PARSE_DEPTH = 4; + +const GIT_KEYWORD = /\bgit\b/; +const CLEAN_KEYWORD = /\bclean\b/; +const RESET_KEYWORD = /\breset\b/; +const RM_KEYWORD = /\brm\b/; + +export function commandNeedsTowerGuard(command: string): boolean { + if (RM_KEYWORD.test(command)) return true; + return GIT_KEYWORD.test(command) && (CLEAN_KEYWORD.test(command) || RESET_KEYWORD.test(command)); +} + +export function analyzeTowerBashCommand( + target: TowerBashGuardTarget, + parse: TowerBashGuardParse, +): string | undefined { + const layout = towerLayout(target.mainCheckout); + return analyzeSource(target.command, resolve(target.cwd), layout, parse, 0); +} + +function towerLayout(mainCheckout: string): TowerLayout { + const root = resolve(mainCheckout); + return { + mainCheckout: root, + towerRoot: join(root, TOWER_ROOT), + commsDir: join(root, COMMS_DIR), + worktreesDir: join(root, WORKTREES_DIR), + }; +} + +function analyzeSource( + source: string, + cwd: string, + layout: TowerLayout, + parse: TowerBashGuardParse, + depth: number, +): string | undefined { + const parsed = parse(source); + if (!parsed.ok || parsed.hasError) return unanalyzableVeto(); + return walkNode(parsed.root, cwd, { layout, parse, depth }).veto; +} + +function walkNode(node: BashSyntaxNode, cwd: string, ctx: GuardContext): WalkResult { + if (node.type === 'command') return analyzeCommand(node, cwd, ctx); + if (node.type === 'pipeline') { + for (const child of namedChildren(node)) { + const result = walkNode(child, cwd, ctx); + if (result.veto !== undefined) return { veto: result.veto, cwd }; + } + return { cwd }; + } + if (node.type === 'subshell' || node.type === 'command_substitution') { + const result = walkChildren(node, cwd, ctx); + return result.veto === undefined ? { cwd } : { veto: result.veto, cwd }; + } + return walkChildren(node, cwd, ctx); +} + +function walkChildren(node: BashSyntaxNode, cwd: string, ctx: GuardContext): WalkResult { + let current = cwd; + for (const child of namedChildren(node)) { + const result = walkNode(child, current, ctx); + if (result.veto !== undefined) return { veto: result.veto, cwd: current }; + current = result.cwd; + } + return { cwd: current }; +} + +function namedChildren(node: BashSyntaxNode): BashSyntaxNode[] { + return node.children.filter((child) => child.isNamed); +} + +const SKIPPED_COMMAND_CHILDREN: ReadonlySet = new Set([ + 'variable_assignment', + 'file_redirect', + 'heredoc_redirect', +]); + +function analyzeCommand(node: BashSyntaxNode, cwd: string, ctx: GuardContext): WalkResult { + const nameIndex = node.children.findIndex((child) => child.type === 'command_name'); + if (nameIndex < 0) return { cwd }; + const nameWord = node.children[nameIndex]!.children.find((child) => child.isNamed); + const rawName = nameWord === undefined ? undefined : literalText(nameWord); + if (rawName === undefined || rawName.length === 0) return { cwd }; + const args: string[] = []; + let dropped = false; + for (const child of node.children.slice(nameIndex + 1)) { + if (!child.isNamed) continue; + if (SKIPPED_COMMAND_CHILDREN.has(child.type)) continue; + const value = literalText(child); + if (value === undefined) { + dropped = true; + } else if (value.length > 0) { + args.push(value); + } + } + return analyzeInvocation(normalizeCommandName(rawName), args, dropped, cwd, ctx); +} + +function analyzeInvocation( + name: string, + args: readonly string[], + dropped: boolean, + cwd: string, + ctx: GuardContext, +): WalkResult { + if (PRIVILEGE_WRAPPERS.has(name)) { + const rest = dropLeadingOptions(args, PRIVILEGE_VALUE_OPTIONS); + const inner = rest[0]; + if (inner === undefined) return { cwd }; + return analyzeInvocation(normalizeCommandName(inner), rest.slice(1), dropped, cwd, ctx); + } + if (LAUNCH_WRAPPERS.has(name)) { + const rest = dropLaunchWrapperOperands(name, args); + const inner = rest[0]; + if (inner === undefined) return { cwd }; + return analyzeInvocation(normalizeCommandName(inner), rest.slice(1), dropped, cwd, ctx); + } + if (NESTED_SHELLS.has(name)) { + let payloadIndex = -1; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]!; + if (arg === '--') break; + if (/^-[a-zA-Z]+$/.test(arg)) { + if (arg.includes('c')) payloadIndex = i + 1; + } else { + break; + } + } + const payload = payloadIndex < 0 ? undefined : args[payloadIndex]; + if (payload === undefined || ctx.depth >= MAX_NESTED_PARSE_DEPTH) return { cwd }; + const veto = analyzeSource(payload, cwd, ctx.layout, ctx.parse, ctx.depth + 1); + return veto === undefined ? { cwd } : { veto, cwd }; + } + if (name === 'eval') { + if (args.length === 0 || ctx.depth >= MAX_NESTED_PARSE_DEPTH) return { cwd }; + const veto = analyzeSource(args.join(' '), cwd, ctx.layout, ctx.parse, ctx.depth + 1); + return veto === undefined ? { cwd } : { veto, cwd }; + } + if (name === 'busybox') { + const applet = args[0]; + if (applet === undefined || applet.startsWith('-')) return { cwd }; + return analyzeInvocation(normalizeCommandName(applet), args.slice(1), dropped, cwd, ctx); + } + if (name === 'cd') { + let index = 0; + while (index < args.length && (args[index] === '-L' || args[index] === '-P' || args[index] === '--')) { + index += 1; + } + const dir = args[index]; + if (dir === undefined) return { cwd: dropped ? cwd : homedir() }; + if (dir === '-') return { cwd }; + return { cwd: resolveOperand(cwd, dir) }; + } + if (name === 'git') return analyzeGit(args, dropped, cwd, ctx); + if (name === 'rm') return analyzeRm(args, cwd, ctx.layout); + return { cwd }; +} + +function analyzeGit( + args: readonly string[], + dropped: boolean, + cwd: string, + ctx: GuardContext, +): WalkResult { + if (dropped) return { cwd }; + let gitCwd = cwd; + let subcommand: string | undefined; + let subargs: readonly string[] = []; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]!; + if (arg === '-C') { + const dir = args[i + 1]; + if (dir === undefined) return { cwd }; + gitCwd = resolveOperand(gitCwd, dir); + i += 1; + continue; + } + if (arg === '-c') { + i += 1; + continue; + } + if (arg.startsWith('-')) continue; + subcommand = arg; + subargs = args.slice(i + 1); + break; + } + if (subcommand === 'clean') { + const veto = analyzeGitClean(subargs, gitCwd, ctx.layout); + return veto === undefined ? { cwd } : { veto, cwd }; + } + if (subcommand === 'reset') { + const veto = analyzeGitReset(subargs, gitCwd, ctx.layout); + return veto === undefined ? { cwd } : { veto, cwd }; + } + return { cwd }; +} + +function analyzeGitClean( + args: readonly string[], + gitCwd: string, + layout: TowerLayout, +): string | undefined { + let forceCount = 0; + let ignored = false; + let dryRun = false; + let interactive = false; + const pathspecs: string[] = []; + let optionsEnded = false; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]!; + if (!optionsEnded && arg === '--') { + optionsEnded = true; + continue; + } + if (!optionsEnded && arg.startsWith('--')) { + if (arg === '--force') forceCount += 1; + else if (arg === '--dry-run') dryRun = true; + else if (arg === '--interactive') interactive = true; + else if (arg === '--exclude') i += 1; + continue; + } + if (!optionsEnded && /^-[a-zA-Z]+$/.test(arg)) { + const excludeAt = arg.indexOf('e'); + const cluster = excludeAt === -1 ? arg : arg.slice(0, excludeAt); + forceCount += (cluster.match(/f/g) ?? []).length; + if (cluster.includes('n')) dryRun = true; + if (cluster.includes('i')) interactive = true; + if (cluster.includes('x') || cluster.includes('X')) ignored = true; + if (excludeAt === arg.length - 1) i += 1; + continue; + } + pathspecs.push(arg); + } + if (dryRun || interactive || forceCount === 0) return undefined; + if (!ignored) return undefined; + if (pathspecs.length === 0) { + return isWithinOrEqual(layout.commsDir, gitCwd) ? gitCleanVeto() : undefined; + } + const gitCwdSlotDepth = slotDepth(gitCwd, layout.worktreesDir); + for (const spec of pathspecs) { + const abs = resolveOperand(gitCwd, spec); + if (gitCwdSlotDepth !== undefined && gitCwdSlotDepth >= 1) { + const specSlotDepth = slotDepth(abs, layout.worktreesDir); + if (specSlotDepth !== undefined && specSlotDepth >= 1) continue; + } + if (operandBucket(abs, layout) !== undefined) return gitCleanVeto(); + } + return undefined; +} + +function analyzeGitReset( + args: readonly string[], + gitCwd: string, + layout: TowerLayout, +): string | undefined { + if (!args.includes('--hard')) return undefined; + if (!isWithinOrEqual(gitCwd, layout.mainCheckout)) return undefined; + if (isWithinOrEqual(gitCwd, layout.worktreesDir) && !pathsEqual(gitCwd, layout.worktreesDir)) { + return undefined; + } + return gitResetVeto(); +} + +function analyzeRm(args: readonly string[], cwd: string, layout: TowerLayout): WalkResult { + let recursive = false; + const operands: string[] = []; + let optionsEnded = false; + for (const arg of args) { + if (!optionsEnded && arg === '--') { + optionsEnded = true; + continue; + } + if (!optionsEnded && arg.startsWith('--')) { + if (arg === '--recursive') recursive = true; + continue; + } + if (!optionsEnded && /^-[a-zA-Z]+$/.test(arg)) { + if (/[rR]/.test(arg)) recursive = true; + continue; + } + operands.push(arg); + } + for (const operand of operands) { + const bucket = operandBucket(resolveOperand(cwd, operand), layout); + if (bucket === 'comms') return { veto: rmVeto(operand), cwd }; + if ((bucket === 'tower-ancestor' || bucket === 'protected-interior') && recursive) { + return { veto: rmVeto(operand), cwd }; + } + } + return { cwd }; +} + +type OperandBucket = 'comms' | 'tower-ancestor' | 'protected-interior' | undefined; + +function operandBucket(abs: string, layout: TowerLayout): OperandBucket { + if (isWithinOrEqual(abs, layout.commsDir)) return 'comms'; + if (isWithinOrEqual(layout.towerRoot, abs)) return 'tower-ancestor'; + if (!isWithinOrEqual(abs, layout.towerRoot)) return undefined; + const depth = slotDepth(abs, layout.worktreesDir); + return depth !== undefined && depth >= 2 ? undefined : 'protected-interior'; +} + +function isWithinOrEqual(candidate: string, base: string): boolean { + const rel = relative(base, candidate); + return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)); +} + +function pathsEqual(a: string, b: string): boolean { + return relative(a, b) === ''; +} + +function slotDepth(path: string, worktreesDir: string): number | undefined { + const rel = relative(worktreesDir, path); + if (rel === '') return 0; + if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return undefined; + return rel.split(sep).length; +} + +function resolveOperand(cwd: string, operand: string): string { + if (operand === '~') return homedir(); + if (operand.startsWith('~/') || operand.startsWith(`~${sep}`)) { + return resolve(homedir(), operand.slice(2)); + } + return isAbsolute(operand) ? resolve(operand) : resolve(cwd, operand); +} + +const DYNAMIC_OPERAND = /[$`*?[\]{}]/; + +function literalText(node: BashSyntaxNode): string | undefined { + switch (node.type) { + case 'word': { + const raw = node.text; + if (DYNAMIC_OPERAND.test(raw)) return undefined; + const unescaped = raw.replaceAll(/\\(.)/gs, '$1'); + return DYNAMIC_OPERAND.test(unescaped) ? undefined : unescaped; + } + case 'number': + return node.text; + case 'raw_string': { + if (node.text.length < 2) return undefined; + const value = node.text.slice(1, -1); + return DYNAMIC_OPERAND.test(value) ? undefined : value; + } + case 'string': { + let value = ''; + for (const child of node.children) { + if (child.type === 'string_content') { + value += child.text; + } else if (child.isNamed) { + return undefined; + } + } + return DYNAMIC_OPERAND.test(value) ? undefined : value; + } + default: + return undefined; + } +} + +function normalizeCommandName(raw: string): string { + let name = raw; + const separator = Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')); + if (separator >= 0) name = name.slice(separator + 1); + name = name.toLowerCase(); + if (name.endsWith('.exe')) name = name.slice(0, -'.exe'.length); + return name; +} + +const PRIVILEGE_WRAPPERS: ReadonlySet = new Set(['sudo', 'doas']); + +const PRIVILEGE_VALUE_OPTIONS: ReadonlySet = new Set([ + '-u', + '--user', + '-g', + '--group', + '-h', + '--host', + '-p', + '--prompt', + '-C', + '--close-from', + '-T', + '--command-timeout', + '-U', + '--other-user', + '-r', + '--role', + '-t', + '--type', +]); + +const LAUNCH_WRAPPERS: ReadonlySet = new Set([ + 'env', + 'command', + 'exec', + 'nohup', + 'builtin', + 'nice', + 'time', +]); + +const WRAPPER_VALUE_OPTIONS: ReadonlySet = new Set([ + '-u', + '--unset', + '-C', + '--chdir', + '-S', + '--split-string', + '-a', + '-n', + '--adjustment', + '-o', + '--output', + '-f', + '--format', +]); + +const NESTED_SHELLS: ReadonlySet = new Set(['sh', 'bash', 'dash', 'zsh', 'ksh', 'ash']); + +function dropLeadingOptions(args: readonly string[], valueOptions: ReadonlySet): string[] { + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]!; + if (arg === '--') return args.slice(i + 1); + if (arg === '-' || !arg.startsWith('-')) return args.slice(i); + if (!arg.includes('=') && valueOptions.has(arg)) i += 1; + } + return []; +} + +function dropLaunchWrapperOperands(name: string, args: readonly string[]): string[] { + let rest = dropLeadingOptions(args, WRAPPER_VALUE_OPTIONS); + if (name === 'env') { + let i = rest[0] === '-' ? 1 : 0; + while (i < rest.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(rest[i]!)) i += 1; + rest = rest.slice(i); + } + return rest; +} + +const TOWER_GUARD_ESCAPE = + 'to retire the tower workspace deliberately, turn tower mode off and remove .tower/comms manually in a terminal — this guard only intercepts agent Bash calls.'; + +function gitCleanVeto(): string { + return ( + '`git clean` with force and -x/-X is not allowed when its scope covers the tower main checkout — ' + + 'it would delete .tower/comms, the tower protocol state, which git does not track and cannot restore. ' + + 'Run the clean inside your own tower worktree or a temporary `git worktree add` checkout instead; ' + + TOWER_GUARD_ESCAPE + ); +} + +function gitResetVeto(): string { + return ( + '`git reset --hard` is not allowed in the tower main checkout — ' + + 'it would silently discard base-checkout work the tower fleet depends on. ' + + 'Run it inside your own tower worktree or a temporary worktree instead; ' + + TOWER_GUARD_ESCAPE + ); +} + +function rmVeto(operand: string): string { + return ( + `\`rm\` is not allowed on tower protocol paths ("${operand}") — ` + + '.tower/comms holds the tower protocol state and deleting it orphans the fleet. ' + + 'Remove paths inside your own worktree instead; ' + + TOWER_GUARD_ESCAPE + ); +} + +function unanalyzableVeto(): string { + return ( + 'this Bash command mentions git clean / git reset / rm and the tower guard could not parse it safely, ' + + 'so it is blocked conservatively — a misread command could delete .tower/comms, the tower protocol state. ' + + 'Split it into simpler commands or run it inside your tower worktree.' + ); +} 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 202ee1c4e0a..3cf02cef713 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 @@ -1,40 +1,29 @@ -Tower mode is active. You are the control tower for this repository — you plan missions, spawn worker and reviewer agents, route information, merge branches, and keep the human informed. This supersedes any other instructions you have received. - -Tower runs several agents on one repository at the same time without them stepping on each other. Three roles: - -- **The human** — owns the objective. May speak, launch, or redirect work **at any time**; nothing in this mode waits for the human. -- **The tower** — **you**, the main agent. Exactly one. You plan missions, spawn workers and reviewers, route information, merge branches, and keep the human informed. -- **Workers and reviewers** — subagents you spawn with `TowerSpawn`. Each worker owns one mission in its own git worktree; reviewers audit branches. - -**The protocol is enforced by tools, not by instructions.** All comms artifacts — inbox messages, findings, reviews, mission files, `MISSIONS.md`, the activity log — are produced by the `Tower*` tools; never create or edit files under `.tower/` by hand. If a tool refuses, read the error — it tells you the correct next step. When something looks wrong, read `.tower/comms/log/activity.log` first; every action of every participant is there. - -Working principles: - -1. **Clarify up front. Never block on the human mid-run.** Use `AskUserQuestion` to pin down requirements before you plan and spawn, while ambiguity is still cheap. Once the fleet is running, make the reasonable call yourself, record it (it lands in the activity log), inform the human in passing, and proceed — escalations are reported, not asked, and unless every remaining thread is blocked, keep the others moving. Workers and reviewers cannot ask the human at all; they escalate to you with `TowerSend`. -2. **Agents negotiate internally.** Workers talk to each other through `TowerSend` directly — questions, review requests, broadcasts (`to: "all"`). You relay wake-ups, triage findings, untangle conflicts, and merge; you are not a content relay. -3. **Scope isolation is real.** `TowerPlan` rejects overlapping scopes, and `TowerMerge` refuses branches that changed files outside their mission scope. Plan scopes carefully; if a mission legitimately needs more, you widen it with `TowerMission` (scope patch — only you can, and it is logged). - -## Tower workflow - -1. **Init** — `TowerInit` creates `.tower/` and records the base branch (already set up when the human enabled tower mode with `/tower `). If the working directory is not yet a git repo, the engine bootstraps one and commits everything present — warn the human to move secrets or large files out BEFORE you init. Settle carried-over open missions **before planning**: continue the ones that belong to the current objective with fresh workers, and abandon the unrelated ones (`TowerMission status=abandoned`) — open missions keep their scopes reserved, so `TowerPlan` rejects any new mission overlapping them. -2. **Plan** — split the objective by **functional boundary and workload**: one coherent slice per mission, small enough to review in one pass; split anything that smells multi-hour into smaller missions wired with `deps`. Prefer more, smaller missions — there is no fixed count. Then call `TowerPlan` with each mission's title, **disjoint** scope globs (picomatch: `**` crosses directories), tasks, and dependencies. Titles must be **printable ASCII English** — any non-ASCII character is rejected and forces a re-plan. Give every title a unique identifier word (a business code, a module name, a ticket id). Write tasks as **verifiable** items a reviewer can map to the diff, and when the human's own words carry intent your paraphrase could lose, copy the key sentences into the mission's `context` **verbatim** — when in doubt, include it. `context` supplements your paraphrase (never replaces it, never holds the full conversation history) and is the one channel that carries the human's voice to both worker and reviewer. If the human's message carries attachments (images, logs, archives), quote their `kimi-file://` references or absolute paths in the mission's `context` too — workers resolve them with Read/ReadMediaFile from the session's attachment storage. Quote the bare attachment id without the file extension (e.g. `kimi-file://f_7fbdcf8f-8b4f-4653-8141-a44f160e1228`, not `.../image.png`) — the extended form does not resolve. Mark read-only investigation missions `kind: "survey"`: a survey's scope is informational (it reserves nothing, so surveys and builds may overlap the same paths), the worker must not change code, and it closes with a zero-diff `TowerMerge` — no reviewer needed. Shared files (lockfiles, central configs) belong to exactly one build mission or to your own integration work. Post the plan to the human in one compact message and launch immediately — their words are plan changes, never a gate. -3. **Spawn** — one `TowerSpawn` per mission (`kind: "worker"`, background, code-built briefing), and **spawn every dependency-unblocked mission right away**: fire the `TowerSpawn` calls back to back, never trickle them out one at a time and never wait for one worker before launching the next — the fleet exists to run in parallel. The tool refuses duplicate names — resume the existing agent with the `Agent` tool instead, always in the background (`run_in_background=true`). Workers commit on their branch; their completion wakes you. Once the batch is running, **end your turn**: completions and inbox traffic arrive as notifications, so never poll `TowerInbox`/`TowerStatus` in a loop and never sit synchronously waiting on a worker. -4. **Supervise** — on every wake (worker completion, human message): `TowerInbox` and `TowerStatus`, then act: - - Review request → first reconcile the worker's report against the mission tasks **item by item** (a silently dropped task means the mission is not done — send it back), then `TowerSpawn` a reviewer (`kind: "reviewer"`, `review_target` the branch) — the briefing hands the reviewer the mission text and the worker's report, so the review verifies intent, not only code health. Do not review mission code yourself. Survey missions skip review — close them with `TowerMerge` once their summary lands. - - Review verdict not clean → the store flips the mission from 🟢 completed back to 🔵 active on its own (only ✅ merged is final); resume the author pointing at the review file — the author fixes and requests re-review. Round cap: at 5 rounds, or when two consecutive rounds report the same findings, stop the loop, inform the human, and redirect (reassign, split, descope). - - Blocker → answer or reassign if you can; if it genuinely needs the human, inform them and keep the rest moving. - - Finding → triage: assign to a mission, plan a new one, or backlog — the disposition is your call; tell the human. - - Requirement change for a busy worker → `TowerSend` is delivery, not interruption: a worker mid-turn (long build, test run, sleep) cannot see inbox messages until its turn ends. The store refuses `status=completed` while unread messages remain, so the change is caught before delivery — no silent miss is possible. - - Completion report with a suspicious diff (🟢 claimed, zero changed files) → investigate before accepting. - - Dead roster agent → an unintended death (failed, lost, timed out) is a symptom, not a cue to revive: diagnose first — check why it died (the died entry's status/reason, its task state); a blind revive re-kills the worker and destroys the death scene. Resume with `Agent(resume=..., run_in_background=true)` or reassign the mission only when the cause is transient (lost contact, timeout, OOM); a systematic cause (code or environment defect) is fixed or escalated to the human before any revive. A worker or reviewer **the human stopped** is dead by intent — never resume it, never reassign its mission, never spawn a replacement; leave it stopped unless the human says otherwise. -5. **Merge** — `TowerMerge(branch)` in Dependency Flow order; never `git merge` by hand, never merge around a refusal. The gate refuses when there is no clean review for the current tip, dependencies are unmerged, or files escaped the scope. After a merge, the result lists branches that now conflict: tell those workers to rebase onto the new base, resolve, 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, summarize what every worker produced (per mission: what was built, the branch and its merge outcome, anything left undone) and call `TowerTeardown` **right away** — never tear down without that summary. Branches and `.tower/comms/` are kept, and a worktree is protected while its agent still has a running task, while it holds uncommitted changes (overridable with `force` — the live-agent protection is not), or when you name it in `exclude`; `dry_run` previews the exact remove/keep list first. Teardown does **not** exit tower mode — you remain the tower, ready for 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 - -- Exactly one tower. If a worker starts assigning work or merging, correct it on your next resume. -- Never write product code yourself; integration fixes at merge time are yours, everything else goes to a worker. -- Mission tracking lives in the tower protocol (`TowerPlan`/`TowerMission`/`TowerStatus`, `MISSIONS.md`), never in `TodoList` — it is code-denied in tower mode because todo semantics would serialize the fleet. -- Parallel work runs through the tower fleet (`TowerSpawn` per mission), never `AgentSwarm` — it is code-denied while tower mode is active because swarm and tower modes are mutually exclusive. -- Every resume of a roster agent is a background call — `Agent(resume=..., run_in_background=true, prompt="...")`, never foreground: you never need a worker's return value inline (its output flows back through the tower protocol files and its completion wakes you), while a foreground resume blocks your whole turn and jams the fleet. The one exception: if your tool set lacks background execution (the Agent tool rejects `run_in_background=true` when TaskList, TaskOutput, or TaskStop is inactive), a foreground resume is the only form the tool accepts — use it there rather than leaving the agent unrecovered. -- You perform every merge, through `TowerMerge`. +Tower mode is active. You are the control tower for this repository: you decompose work, spawn independent workers and reviewers, route information, integrate branches, and keep the human informed. These tower-specific orchestration choices take precedence over generic agent defaults; they do not override the human, higher-priority safety rules, or explicit task requirements. + +The protocol is enforced by tools, not by prompt text. Protocol artifacts belong under `.tower/comms/` (state, inbox, findings, reviews, mission files, and activity log): never create or edit those files by hand. Workers edit product code only inside their assigned git worktrees under `.tower/worktrees/`. If a tool refuses, follow its structured reason and recovery details instead of guessing or bypassing it. When diagnosis is unclear, inspect the activity log. + +## People, agents, and scope + +- The human owns the objective and may redirect work at any time. When the current interaction mode supports questions, clarify material ambiguity before planning with `AskUserQuestion`. In auto or other non-interactive modes, make a reasonable, recorded decision, report it, and keep independent work moving instead of blocking on the human. Workers and reviewers escalate through `TowerSend` rather than contacting the human directly. +- Exactly one tower exists. Workers own one mission each in a private worktree; reviewers audit a branch independently. You never write mission code yourself, except for merge-time integration fixes. +- Give build missions disjoint scopes where practical. Dependency auto-wiring from scope globs is a heuristic for serializing known overlap, not a proof that all overlap or races are covered; inspect and adjust explicit `deps` when the design requires ordering. The merge gate still enforces scope. +- Mission tracking lives in `TowerPlan` / `TowerMission` / `TowerStatus` and `MISSIONS.md`, not `TodoList`. Parallel delegation belongs to `TowerSpawn`, not `AgentSwarm`. +- Goals and tower mode are mutually exclusive: any active goal was paused on entry, and `CreateGoal` / resuming a goal is code-denied while tower mode is active. + +## Work cycle + +1. **Initialize and plan.** `TowerInit` records the base and reports each open mission as up-to-date, stale, or no-branch; only stale branches require rebase. On a fresh non-git directory, initialization commits what exists, so warn the human first about secrets and large files. Settle carried-over missions before planning. Split the objective by functional boundary into verifiable missions with dependencies and unique printable-ASCII titles. Keep mission tasks observable and intent-rich: the completion report must reconcile every task, and the reviewer must map every task to the diff. Preserve the user's key wording in mission `context` verbatim when paraphrase could lose intent; never paste the whole conversation. When the human's message carries attachments (images, logs, archives), quote their `kimi-file://` references in the mission's `context` so workers can read them. Mark read-only investigations `kind: "survey"`; they reserve no scope and must finish zero-diff. + +2. **Spawn in parallel.** Start every dependency-unblocked worker with code-built briefings, then end your turn instead of waiting or polling. Workers commit in their worktrees; completions and inbox events notify you. Prefer more, smaller missions when that makes intent and review clearer. + +3. **Supervise by evidence.** A wake already carries message references and a compact gate digest. Read those first; open the actual body with `TowerInbox` only when it is needed for a decision. Use `TowerStatus` for a human-facing dashboard or when you genuinely need a full-fleet diagnostic, not mechanically after every wake. + - For a review request, reconcile the worker's account against every mission task, then spawn an independent reviewer with the mission and self-report. A silently dropped task is unfinished work. Normal merge-bound reviews require this independent path. A tower-submitted review is exceptional break-glass only: the review file must explicitly record `reviewer=tower` and the reason, and it must not be used as a convenience bypass. Surveys do not need review; close them with `TowerMerge` after their summary. + - Workers finish with `TowerComplete(report)`, which checks tasks, blockers, committed diff, and unread input, then stores the standard review-request or survey-summary. Reviews automatically store review-result for the right recipients. Do not repeat those notifications with manual `TowerSend`. + - A non-clean review moves an open completed mission back to active and notifies the valid owner and tower. Fix and re-review; the five-round cap is per reviewer, not shared across a whole branch. A clean review is approval only when its merge verdict is `merge`; `hold` and `fix-then-merge` never approve a merge. + - Route blockers and findings without stalling unrelated work. Steered messages for a running worker become visible at its next step boundary; unread input still prevents premature completion. + +4. **Integrate through structured outcomes.** Use `TowerMerge` and `TowerRebase`, never ad-hoc git merges or a way around a refusal. The refusal identifies the exact failed gate and the appropriate recovery; follow that rather than relying on a memorized checklist. Distinguish full merges from partial/advanced results and recovery-required outcomes. A conflict-free tower rebase preserves only the old review's exact-tip binding; approval still requires a clean review with `merge=merge`. When rebase conflicts are real, the mission is blocked for the worker to resolve; other failures are not mislabeled as conflicts. + +5. **Diagnose deaths, then close.** An unintended death is a symptom: inspect its status, reason, and task state before deciding whether to resume. Resume only for a transient cause, and fix or escalate systematic defects first. **A worker or reviewer stopped by the human is dead by intent: never resume, reassign, or replace it unless the human says otherwise.** Once every mission is merged or abandoned and no actionable inbox items remain, summarize each mission's output and unfinished work, then call `TowerTeardown`. Branches and `.tower/comms/` stay as audit history; live-agent protection cannot be forced, while dirty worktrees follow their documented `force` rules. Teardown does not exit tower mode. + +Roster resumes use `run_in_background=true` so the fleet keeps moving. If background execution is unavailable because TaskList, TaskOutput, or TaskStop is inactive, foreground resume is the documented fallback rather than abandoning recovery. diff --git a/packages/agent-core-v2/src/features/tower/injection/tower-mode-sparse-reminder.md b/packages/agent-core-v2/src/features/tower/injection/tower-mode-sparse-reminder.md index 54f90931383..36064277f49 100644 --- a/packages/agent-core-v2/src/features/tower/injection/tower-mode-sparse-reminder.md +++ b/packages/agent-core-v2/src/features/tower/injection/tower-mode-sparse-reminder.md @@ -1 +1 @@ -Tower mode still active (see full instructions earlier). You are the control tower: run the protocol only through the `Tower*` tools — never create or edit files under `.tower/` by hand. Mission tracking lives in `TowerPlan`/`TowerMission`/`TowerStatus` (`MISSIONS.md`); TodoList is code-denied in tower mode. When something looks wrong, read `.tower/comms/log/activity.log` first. Never write product code yourself — workers own missions; you coordinate, review-route, and merge. +Tower mode still active. You are the control tower: run the protocol only through the `Tower*` tools — never create or edit protocol artifacts under `.tower/comms/` by hand. Mission tracking lives in `TowerPlan`/`TowerMission`/`TowerStatus` (`MISSIONS.md`); TodoList is code-denied. Never write product code yourself — workers own missions; you coordinate, review-route, and merge. diff --git a/packages/agent-core-v2/src/features/tower/injection/towerModeInjection.ts b/packages/agent-core-v2/src/features/tower/injection/towerModeInjection.ts index f2272f0dee3..b11a3a2bd9b 100644 --- a/packages/agent-core-v2/src/features/tower/injection/towerModeInjection.ts +++ b/packages/agent-core-v2/src/features/tower/injection/towerModeInjection.ts @@ -60,7 +60,13 @@ function towerModeReminderVariant( assistantTurnsSince += 1; continue; } - if (message.role === 'user' && assistantTurnsSince >= 1) return 'full'; + if ( + message.role === 'user' && + message.origin?.kind !== 'injection' && + assistantTurnsSince >= 1 + ) { + return 'full'; + } } if (assistantTurnsSince >= TOWER_MODE_FULL_REFRESH_TURNS) return 'full'; if (assistantTurnsSince >= TOWER_MODE_DEDUP_MIN_TURNS) return 'sparse'; 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 f47a9acf7d9..15e262c621c 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/git.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/git.ts @@ -8,6 +8,7 @@ export class GitError extends Error { constructor( readonly args: readonly string[], readonly stderr: string, + readonly exitCode?: number, ) { super(`git ${args.join(' ')} failed: ${stderr.trim() || 'unknown error'}`); this.name = 'GitError'; @@ -35,7 +36,13 @@ export async function git( }, (error, stdout, stderr) => { if (error !== null) { - reject(new GitError(args, stderr || error.message)); + reject( + new GitError( + args, + stderr || error.message, + typeof error.code === 'number' ? error.code : undefined, + ), + ); return; } resolve(stdout.trimEnd()); @@ -57,7 +64,7 @@ export async function isInsideRepo(cwd: string): Promise { } export async function hasAnyCommit(cwd: string): Promise { - return (await tryGit(cwd, ['rev-list', '-n', '1', '--all'])) !== null; + return (await git(cwd, ['rev-list', '-n', '1', '--all'])).length > 0; } export async function currentBranch(cwd: string): Promise { @@ -71,9 +78,13 @@ export async function branchTip(cwd: string, ref: string): Promise { } export async function branchExists(cwd: string, branch: string): Promise { - return ( - (await tryGit(cwd, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`])) !== null - ); + try { + await git(cwd, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]); + return true; + } catch (error) { + if (error instanceof GitError && error.exitCode === 1) return false; + throw error; + } } const ADD_PATHS_CHUNK = 100; @@ -119,7 +130,13 @@ export async function commitPaths( } export async function isAncestor(cwd: string, ancestor: string, ref: string): Promise { - return (await tryGit(cwd, ['merge-base', '--is-ancestor', ancestor, ref])) !== null; + try { + await git(cwd, ['merge-base', '--is-ancestor', ancestor, ref]); + return true; + } catch (error) { + if (error instanceof GitError && error.exitCode === 1) return false; + throw error; + } } export async function worktreeAdd(cwd: string, path: string, branch: string): Promise { @@ -154,12 +171,72 @@ export async function isRegisteredWorktree(repoRoot: string, path: string): Prom } export async function isWorktreeDirty(path: string): Promise { - const status = await tryGit(path, ['status', '--porcelain']); - return status !== null && status.trim().length > 0; + return (await git(path, ['status', '--porcelain'])).trim().length > 0; } -export async function mergeNoFf(cwd: string, branch: string): Promise { - await git(cwd, ['merge', '--no-ff', branch]); +async function hasMergeHead(cwd: string): Promise { + try { + await git(cwd, ['rev-parse', '--verify', '--quiet', 'MERGE_HEAD']); + return true; + } catch (error) { + if (error instanceof GitError && error.exitCode === 1) return false; + throw error; + } +} + +export async function mergeNoFf(cwd: string, revision: string, message?: string): Promise { + if (await hasMergeHead(cwd)) { + throw new GitError( + ['merge', '--no-ff', revision], + 'cannot start a tower merge while MERGE_HEAD already exists; finish or abort the existing merge first', + ); + } + const headBefore = await branchTip(cwd, 'HEAD'); + const statusBefore = await git(cwd, ['status', '--porcelain']); + try { + await git(cwd, [ + 'merge', + '--no-ff', + ...(message === undefined ? [] : ['-m', message]), + revision, + ]); + } catch (error) { + if (!(error instanceof GitError)) throw error; + let mergeLeftBehind: boolean; + try { + mergeLeftBehind = await hasMergeHead(cwd); + } catch (inspectionError) { + throw new GitError( + ['merge', '--abort'], + `recovery-required: could not inspect MERGE_HEAD after the merge failed: ${inspectionError instanceof Error ? inspectionError.message : String(inspectionError)}`, + ); + } + if (mergeLeftBehind) { + try { + await git(cwd, ['merge', '--abort']); + } catch (abortError) { + throw new GitError( + ['merge', '--abort'], + `recovery-required: ${abortError instanceof Error ? abortError.message : String(abortError)}`, + ); + } + const problems: string[] = []; + if (await hasMergeHead(cwd)) problems.push('MERGE_HEAD still exists'); + if ((await branchTip(cwd, 'HEAD')) !== headBefore) { + problems.push('HEAD was not restored to the pre-merge commit'); + } + if ((await git(cwd, ['status', '--porcelain'])) !== statusBefore) { + problems.push('the index or worktree differs from its pre-merge state'); + } + if (problems.length > 0) { + throw new GitError( + ['merge', '--abort'], + `recovery-required: merge abort did not restore the checkout (${problems.join('; ')})`, + ); + } + } + throw error; + } return branchTip(cwd, 'HEAD'); } 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 eccb5de4d59..d2d611b2186 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/index.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/index.ts @@ -1,6 +1,7 @@ export * from './baseWip'; export * from './frontmatter'; export * from './git'; +export * from './missionGate'; export * from './paths'; export * from './repoRoot'; export * from './store'; diff --git a/packages/agent-core-v2/src/features/tower/protocol/missionGate.ts b/packages/agent-core-v2/src/features/tower/protocol/missionGate.ts new file mode 100644 index 00000000000..5d4dcf0c621 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/protocol/missionGate.ts @@ -0,0 +1,256 @@ +import picomatch from 'picomatch'; + +import type { TowerMission, TowerReviewInfo, TowerState } from './types'; + +export type TowerWorkerRuntimeStatus = 'idle' | 'busy' | 'unknown'; + +export interface TowerMissionWorkerRuntime { + readonly agentId: string; + readonly name: string; + readonly status: TowerWorkerRuntimeStatus; +} + +export interface TowerMissionBranchObservation { + readonly tip: string; + readonly baseIsAncestor: boolean; + readonly diffBase: string; + readonly changedFiles: readonly string[]; + readonly mergeTouchedFiles: readonly string[]; +} + +export interface TowerMissionGitObservations { + readonly baseTip: string; + readonly headTip: string; + readonly checkedOutBranch?: string; + readonly baseDirtyFiles: readonly string[]; + readonly branch?: TowerMissionBranchObservation; +} + +export type TowerReviewBinding = 'exact-tip' | 'clean-rebase-waived' | 'stale-tip'; + +export type TowerMissionGateReason = + | 'not-completed' + | 'open-tasks' + | 'blockers' + | 'worker-busy' + | 'worker-unknown' + | 'deps-unmerged' + | 'branch-missing' + | 'read-only-survey' + | 'stale-base' + | 'no-review' + | 'review-hold' + | 'not-clean' + | 'review-not-approved' + | 'tip-moved' + | 'review-mission-mismatch' + | 'out-of-scope' + | 'base-mismatch' + | 'base-dirty'; + +export interface TowerMissionGateBlock { + readonly reason: TowerMissionGateReason; + readonly message: string; +} + +export interface TowerMissionGateInput { + readonly state: TowerState; + readonly mission: TowerMission; + readonly reviews: readonly TowerReviewInfo[]; + readonly workers: readonly TowerMissionWorkerRuntime[]; + readonly observations: TowerMissionGitObservations; +} + +export interface TowerMissionGateResult { + readonly ready: boolean; + readonly noop: boolean; + readonly blocks: readonly TowerMissionGateBlock[]; + readonly selectedReview?: TowerReviewInfo; + readonly reviewBinding?: TowerReviewBinding; + readonly observations: TowerMissionGitObservations; +} + +export function selectMissionReview( + state: TowerState, + mission: TowerMission, + reviews: readonly TowerReviewInfo[], +): TowerReviewInfo | undefined { + const siblingMissions = state.missions.filter( + (candidate) => candidate.branch === mission.branch && candidate.id !== mission.id, + ); + const stamped = reviews.filter((review) => review.mission === mission.id); + const candidates = + stamped.length > 0 + ? reviews.filter( + (review) => + review.mission === mission.id || + (review.mission === undefined && siblingMissions.length === 0), + ) + : reviews.filter((review) => review.mission === undefined); + return candidates.at(-1); +} + +export function evaluateMissionGate(input: TowerMissionGateInput): TowerMissionGateResult { + const { state, mission, reviews, workers, observations } = input; + const selectedReview = + mission.kind === 'build' ? selectMissionReview(state, mission, reviews) : undefined; + const reviewBinding = reviewBindingFor(mission, selectedReview, observations.branch?.tip); + const result = (blocks: readonly TowerMissionGateBlock[]): TowerMissionGateResult => ({ + ready: blocks.length === 0, + noop: blocks.length === 0 && mission.kind === 'survey', + blocks, + selectedReview, + reviewBinding, + observations, + }); + const block = (reason: TowerMissionGateReason, message: string): TowerMissionGateResult => + result([{ reason, message: `merge blocked: ${message}` }]); + + const commonBlocks: TowerMissionGateBlock[] = []; + if (mission.status !== 'completed') { + commonBlocks.push({ + reason: 'not-completed', + message: `merge blocked: mission ${mission.id} is ${mission.status} — only a completed mission can merge`, + }); + } + const openTasks = mission.tasks.filter((task) => !task.done && task.dropped !== true); + if (openTasks.length > 0) { + commonBlocks.push({ + reason: 'open-tasks', + message: `merge blocked: mission ${mission.id} has ${String(openTasks.length)} open task(s): ${openTasks.map((task) => `"${task.text}"`).join(', ')}`, + }); + } + if (mission.blockers.length > 0) { + commonBlocks.push({ + reason: 'blockers', + message: `merge blocked: mission ${mission.id} has ${String(mission.blockers.length)} blocker(s): ${mission.blockers.join(' | ')}`, + }); + } + const busyWorkers = workers.filter((worker) => worker.status === 'busy'); + if (busyWorkers.length > 0) { + commonBlocks.push({ + reason: 'worker-busy', + message: `merge blocked: worker(s) ${busyWorkers.map((worker) => worker.name).join(', ')} are mid-turn — wait for every mission worker to become idle before merging`, + }); + } + const unknownWorkers = workers.filter((worker) => worker.status === 'unknown'); + if (unknownWorkers.length > 0) { + commonBlocks.push({ + reason: 'worker-unknown', + message: `merge blocked: worker runtime for ${unknownWorkers.map((worker) => worker.name).join(', ')} cannot be observed authoritatively — unknown is not idle; retry from the main tower agent`, + }); + } + if (commonBlocks.length > 0) return result(commonBlocks); + + const branch = observations.branch; + if (branch === undefined) { + return block( + 'branch-missing', + `mission ${mission.id} branch "${mission.branch}" does not exist — spawn a worker and complete the mission before merging`, + ); + } + + const unmergedDeps = mission.deps.filter((dep) => { + const dependency = state.missions.find((candidate) => candidate.id === dep); + return dependency !== undefined && dependency.status !== 'merged' && dependency.status !== 'abandoned'; + }); + if (unmergedDeps.length > 0) { + return block( + 'deps-unmerged', + `dependencies not merged yet (${unmergedDeps.join(', ')}) — merge in Dependency Flow order`, + ); + } + + if (mission.kind === 'survey') { + if (branch.changedFiles.length > 0) { + return block( + 'read-only-survey', + `survey mission ${mission.id} is read-only but ${mission.branch} has ${String(branch.changedFiles.length)} changed file(s): ${branch.changedFiles.slice(0, 5).join(', ')} — investigate the worker; if the changes are worth keeping, move them onto a build mission's branch`, + ); + } + return result([]); + } + + if (!branch.baseIsAncestor) { + return block( + 'stale-base', + `${mission.branch} is behind base "${state.base}" — the base moved after this branch spawned, so merging now could land code that was never built or reviewed against the current base; run TowerRebase(mission="${mission.id}") first (it rebases in the mission worktree, refuses while the worker is mid-turn or the worktree is dirty, and a conflict-free rebase waives the re-review), then retry the merge`, + ); + } + if (selectedReview === undefined) { + return block('no-review', `${mission.branch} has no review — assign a reviewer first`); + } + if (selectedReview.merge === 'hold') { + return block( + 'review-hold', + `latest applicable review (round ${String(selectedReview.round)} by ${selectedReview.reviewer}) says hold — do not merge until a later clean review with merge approval supersedes it`, + ); + } + if (selectedReview.status !== 'clean') { + return block( + 'not-clean', + `latest review (round ${String(selectedReview.round)} by ${selectedReview.reviewer}) is "${selectedReview.status}" — a clean round is required`, + ); + } + const mergeVerdict = selectedReview.merge || ''; + if (mergeVerdict !== 'merge' && mergeVerdict !== '') { + return block( + 'review-not-approved', + `latest clean review (round ${String(selectedReview.round)} by ${selectedReview.reviewer}) has merge verdict "${mergeVerdict}" — fix-then-merge is not approval; only a clean review with merge verdict "merge" can pass`, + ); + } + if (reviewBinding === 'stale-tip') { + return block( + 'tip-moved', + `${mission.branch} moved since the clean review (reviewed ${selectedReview.reviewedCommit.slice(0, 7)}, tip ${branch.tip.slice(0, 7)}) — re-review required`, + ); + } + const siblingMissions = state.missions.filter( + (candidate) => candidate.branch === mission.branch && candidate.id !== mission.id, + ); + if (selectedReview.mission === undefined && siblingMissions.length > 0) { + return block( + 'review-mission-mismatch', + `"${mission.branch}" is shared with other mission record(s) ${siblingMissions.map((sibling) => `${sibling.id} (${sibling.status})`).join(', ')}, and the latest clean review (round ${String(selectedReview.round)} by ${selectedReview.reviewer}) predates mission-stamped reviews — re-review ${mission.id} so the gate can tell which mission was audited`, + ); + } + const outOfScope = branch.changedFiles.filter( + (file) => !mission.scope.some((glob) => picomatch.isMatch(file, glob)), + ); + if (outOfScope.length > 0) { + return block( + 'out-of-scope', + `${mission.branch} changed files outside mission ${mission.id} scope (${mission.scope.join(', ')}): ${outOfScope.join(', ')} — the tower must widen the mission scope (TowerMission scope patch) or revert those changes`, + ); + } + if (observations.checkedOutBranch !== state.base) { + return block( + 'base-mismatch', + observations.checkedOutBranch === undefined + ? `the main checkout is in a detached HEAD state — check out the recorded base branch "${state.base}" before merging; nothing was merged` + : `the main checkout is on "${observations.checkedOutBranch}", not the recorded base "${state.base}" — switch it back (\`git checkout ${state.base}\`) and retry, or when development has moved to "${observations.checkedOutBranch}", re-anchor the workspace to it first (/tower ${observations.checkedOutBranch}, or TowerInit with base="${observations.checkedOutBranch}"); nothing was merged`, + ); + } + const dirty = new Set(observations.baseDirtyFiles); + const blockedDirty = branch.mergeTouchedFiles.filter((file) => dirty.has(file)); + if (blockedDirty.length > 0) { + return block( + 'base-dirty', + `the main checkout has uncommitted changes in file(s) this merge would overwrite: ${blockedDirty.slice(0, 5).join(', ')} — commit or stash them first, then retry; nothing was merged`, + ); + } + return result([]); +} + +function reviewBindingFor( + mission: TowerMission, + review: TowerReviewInfo | undefined, + tip: string | undefined, +): TowerReviewBinding | undefined { + if (review === undefined || tip === undefined) return undefined; + if (review.reviewedCommit === tip) return 'exact-tip'; + const waiver = mission.lastRebase; + return waiver !== undefined && waiver.fromCommit === review.reviewedCommit && waiver.toCommit === tip + ? 'clean-rebase-waived' + : 'stale-tip'; +} 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 30469eb2b1b..808d9af5d99 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/store.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/store.ts @@ -1,8 +1,7 @@ import { randomUUID } from 'node:crypto'; +import { existsSync } from 'node:fs'; import { appendFile, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; - -import picomatch from 'picomatch'; +import { dirname, join, resolve } from 'node:path'; import { listBaseDirtyEntries, snapshotBaseWip } from './baseWip'; import { parseFrontmatter, renderFrontmatter } from './frontmatter'; @@ -14,6 +13,8 @@ import { commitPaths, currentBranch, diffNameOnly, + git, + GitError, hasAnyCommit, initRepository, isAncestor, @@ -26,6 +27,8 @@ import { worktreeAddNewBranch, worktreeRemove, } from './git'; +import { evaluateMissionGate } from './missionGate'; +import type { TowerMissionGateResult, TowerMissionGitObservations } from './missionGate'; import { ACTIVITY_LOG, BROADCAST_NAME, @@ -37,6 +40,7 @@ import { REVIEWS_DIR, STATE_FILE, TOWER_NAME, + TOWER_ROOT, WORKTREES_DIR, isReservedTowerAgentName, dateDash, @@ -69,12 +73,29 @@ export class TowerProtocolError extends Error { export const MAX_REVIEW_ROUNDS = 5; +export type TowerReanchorRelation = 'no-branch' | 'up-to-date' | 'stale'; + +export interface TowerReanchorMissionDigest { + readonly id: string; + readonly branch: string; + readonly relation: TowerReanchorRelation; + readonly branchTip?: string; +} + +export interface TowerReanchorDigest { + readonly from: string; + readonly to: string; + readonly baseTip: string; + readonly missions: readonly TowerReanchorMissionDigest[]; +} + export interface TowerInitResult { readonly base: string; readonly created: boolean; readonly retiredAgents: readonly string[]; readonly checkout: string; - readonly ignoredBase?: string; + readonly rebasedFrom?: string; + readonly rebaseDigest?: TowerReanchorDigest; readonly openMissions: readonly string[]; } @@ -94,7 +115,21 @@ export interface TowerSendInput { readonly scope?: string; readonly action?: string; readonly consentRef?: string; - readonly tokens?: number; +} + +export interface TowerSendResult { + readonly item: TowerInboxItem; + readonly missionId?: string; + readonly activityLogError?: string; +} + +export interface TowerCompleteResult { + readonly mission?: TowerMission; + readonly recoveredAt?: string; + readonly message?: TowerSendResult; + readonly notificationCreated: boolean; + readonly notificationError?: string; + readonly activityLogError?: string; } export interface TowerFindingInput { @@ -105,7 +140,6 @@ export interface TowerFindingInput { readonly location?: string; readonly details: string; readonly suggestedFix: string; - readonly tokens?: number; } export interface TowerReviewInput { @@ -115,7 +149,13 @@ export interface TowerReviewInput { readonly findings: string; readonly checks?: readonly string[]; readonly decision: string; - readonly tokens?: number; +} + +export interface TowerReviewResult { + readonly review: TowerReviewInfo; + readonly storedMessages: readonly TowerSendResult[]; + readonly notificationError?: string; + readonly activityLogError?: string; } export interface TowerMissionPatch { @@ -140,6 +180,29 @@ export interface TowerStoreOptions { readonly stateLockPollMs?: number; } +export interface TowerMissionRuntimeOptions { + readonly activeAgentIds?: ReadonlySet | (() => ReadonlySet); +} + +export interface TowerMergeResult { + readonly status: 'merged' | 'advanced' | 'noop'; + readonly mergeCommit: string; + readonly evaluatedBranchTip: string; + readonly currentBranchTip: string; + readonly conflictsWith: ReadonlyArray<{ + readonly branch: string; + readonly files: readonly string[]; + }>; + readonly noop?: boolean; +} + +export interface TowerRebaseMissionResult { + readonly status: 'up-to-date' | 'rebased' | 'conflict'; + readonly fromCommit?: string; + readonly toCommit?: string; + readonly files?: readonly string[]; +} + export interface TowerTeardownOptions { readonly force?: boolean; readonly exclude?: readonly string[]; @@ -167,6 +230,63 @@ function missionNumber(id: string): number { return Number.isNaN(n) ? 0 : n; } +function scopeStem(raw: string): string { + return raw.replace(/\/\*\*?$/, '').replace(/\/+$/, '').replace(/\*+$/, ''); +} + +export function scopesOverlap(a: readonly string[], b: readonly string[]): boolean { + for (const rawA of a) { + const stemA = scopeStem(rawA); + if (stemA.length === 0) continue; + for (const rawB of b) { + const stemB = scopeStem(rawB); + if (stemB.length === 0) continue; + if (stemA === stemB || stemA.startsWith(`${stemB}/`) || stemB.startsWith(`${stemA}/`)) { + return true; + } + } + } + return false; +} + +function depPathExists(fromId: string, toId: string, byId: ReadonlyMap): boolean { + const seen = new Set(); + const queue = [fromId]; + while (queue.length > 0) { + const current = queue.shift()!; + if (current === toId) return true; + if (seen.has(current)) continue; + seen.add(current); + const mission = byId.get(current); + if (mission === undefined || !isOpenMission(mission)) continue; + for (const dep of mission.deps) { + if (!seen.has(dep)) queue.push(dep); + } + } + return false; +} + +function autoDepNote(mission: TowerMission, other: TowerMission): string { + return `auto dependency on ${other.id}: scope overlaps ${other.id} ("${other.scope.join(', ')}") — merges are serialized so shared files land in dependency order instead of colliding`; +} + +function addAutoDeps( + mission: TowerMission, + candidates: readonly TowerMission[], + byId: ReadonlyMap, +): void { + if (mission.kind === 'survey') return; + for (const other of candidates) { + if (other.id === mission.id || other.kind === 'survey') continue; + if (!scopesOverlap(mission.scope, other.scope)) continue; + if (depPathExists(mission.id, other.id, byId) || depPathExists(other.id, mission.id, byId)) { + continue; + } + mission.deps.push(other.id); + mission.notes.push(autoDepNote(mission, other)); + } +} + export function resolveMissionByBranch( state: TowerState, branch: string, @@ -261,9 +381,7 @@ export class TowerStore { const code = (error as NodeJS.ErrnoException).code; if (code === 'EEXIST') return false; if (code === 'ENOENT') { - throw new TowerProtocolError( - 'tower is not initialized in this repository — run TowerInit first', - ); + throw await this.stateMissingError(); } throw error; } @@ -278,6 +396,22 @@ export class TowerStore { } } + async isStateLost(): Promise { + if (await this.isInitialized()) return false; + return existsSync(this.abs(TOWER_ROOT)); + } + + private async stateMissingError(): Promise { + if (await this.isStateLost()) { + return new TowerProtocolError( + 'tower state was lost: .tower/comms/state.json is missing but the .tower/ directory still exists — the tower was initialized here and its comms state was deleted (e.g. by git clean), so all recorded mission, roster, and review history is gone. This operation is refused instead of silently running against an empty history; TowerStatus reports the loss, and TowerSend/TowerComplete recover a minimal state (stamped recoveredAt) automatically', + ); + } + return new TowerProtocolError( + 'tower is not initialized in this repository — run TowerInit first', + ); + } + async ensureRepository(base?: string): Promise { if (await isInsideRepo(this.repoRoot)) return; await initRepository(this.repoRoot); @@ -308,14 +442,24 @@ export class TowerStore { await mkdir(dirname(this.abs(STATE_FILE)), { recursive: true }); return this.withStateLock(async () => { if (await this.isInitialized()) { - const state = await this.load(); + const loaded = await this.load(); + let rebasedFrom: string | undefined; + let rebaseDigest: TowerReanchorDigest | undefined; + let state = loaded; + if (base !== undefined && base !== loaded.base) { + const reanchor = await this.reanchorLocked(loaded, base); + rebasedFrom = reanchor.digest.from; + rebaseDigest = reanchor.digest; + state = reanchor.state; + } const retiredAgents = await this.adoptForeignRoster(state, sessionId); return { base: state.base, created: false, retiredAgents, checkout: await this.checkedOutBranch(), - ignoredBase: base !== undefined && base !== state.base ? base : undefined, + rebasedFrom, + rebaseDigest, openMissions: state.missions.filter(isOpenMission).map((m) => m.id), }; } @@ -356,20 +500,54 @@ export class TowerStore { }); } - async rebase(base: string): Promise { - await this.withStateLock(async () => { - const state = await this.load(); - if (state.base === base) return; - const open = state.missions.filter(isOpenMission); - if (open.length > 0) { - throw new TowerProtocolError( - `cannot rebase the tower from "${state.base}" to "${base}" — ${String(open.length)} mission(s) are still open (${open.map((m) => m.id).join(', ')}); merge or abandon them first (or TowerTeardown and start over)`, - ); + private async reanchorLocked( + state: TowerState, + base: string, + ): Promise<{ readonly digest: TowerReanchorDigest; readonly state: TowerState }> { + await assertLocalBaseBranch(this.repoRoot, base); + const baseTip = await branchTip(this.repoRoot, base); + const open = state.missions.filter(isOpenMission); + const missions: TowerReanchorMissionDigest[] = []; + for (const mission of open) { + if (!(await branchExists(this.repoRoot, mission.branch))) { + missions.push({ id: mission.id, branch: mission.branch, relation: 'no-branch' }); + continue; } - await assertLocalBaseBranch(this.repoRoot, base); - const from = state.base; - await this.save({ ...state, base }); - await this.appendLog(TOWER_NAME, 'rebase', { from, to: base }); + const branchTipForMission = await branchTip(this.repoRoot, mission.branch); + missions.push({ + id: mission.id, + branch: mission.branch, + relation: (await isAncestor(this.repoRoot, baseTip, branchTipForMission)) + ? 'up-to-date' + : 'stale', + branchTip: branchTipForMission, + }); + } + const digest: TowerReanchorDigest = { + from: state.base, + to: base, + baseTip, + missions, + }; + const next = { ...state, base }; + await this.save(next); + await this.appendLog(TOWER_NAME, 'rebase', { + from: digest.from, + to: base, + open_missions: open.length > 0 ? open.length : undefined, + relations: + missions.length > 0 + ? missions.map((mission) => `${mission.id}:${mission.relation}`).join(',') + : undefined, + }); + return { digest, state: next }; + } + + async rebase(base: string): Promise { + return this.withStateLock(async () => { + const state = await this.load(); + if (state.base === base) return undefined; + return (await this.reanchorLocked(state, base)).digest; }); } @@ -441,9 +619,7 @@ export class TowerStore { try { raw = await readFile(this.abs(STATE_FILE), 'utf8'); } catch { - throw new TowerProtocolError( - 'tower is not initialized in this repository — run TowerInit first', - ); + throw await this.stateMissingError(); } const state = JSON.parse(raw) as TowerState; for (const mission of state.missions) { @@ -452,6 +628,72 @@ export class TowerStore { return state; } + async loadOrRecover(): Promise { + if (await this.isInitialized()) return this.load(); + if (!(await this.isStateLost())) { + throw new TowerProtocolError( + 'tower is not initialized in this repository — run TowerInit first', + ); + } + return this.recoverMinimalState(); + } + + private async recoverMinimalState(): Promise { + for (const dir of [INBOX_DIR, FINDINGS_DIR, REVIEWS_DIR, MISSIONS_DIR, LOG_DIR, WORKTREES_DIR]) { + await mkdir(this.abs(dir), { recursive: true }); + } + return this.withStateLock(async () => { + if (await this.isInitialized()) return this.load(); + const checkout = await this.checkedOutBranch(); + if (checkout === 'HEAD') { + throw new TowerProtocolError( + 'tower state was lost and the checkout is on a detached HEAD — the recovered minimal state takes the current checkout branch as its base, which a detached HEAD cannot provide; check out the previous base branch and retry, or run TowerInit with an explicit base', + ); + } + const now = new Date().toISOString(); + const state: TowerState = { + version: 1, + base: checkout, + mode: 'branch', + createdAt: now, + recoveredAt: now, + roster: { agents: [] }, + missions: [], + }; + await this.save(state); + await this.renderMissionsIndex(state); + await this.appendLog( + TOWER_NAME, + 'state.recovered', + { base: checkout, recovered_at: now }, + MISSIONS_INDEX, + ); + return state; + }); + } + + async resolveMessagingCaller( + state: TowerState, + agentId: string, + ): Promise<{ readonly caller: string; readonly state: TowerState; readonly placeholder?: boolean }> { + if (agentId === 'main' || state.recoveredAt === undefined) { + return { caller: this.resolveCallerName(state, agentId), state }; + } + const entry = this.resolveAgent(state, agentId); + if (entry !== undefined) return { caller: entry.name, state }; + await this.registerAgent({ + name: agentId, + agentId, + kind: 'worker', + spawnedAt: state.recoveredAt, + }); + await this.appendLog(TOWER_NAME, 'roster.placeholder', { + agent: agentId, + recovered_at: state.recoveredAt, + }); + return { caller: agentId, state: await this.load(), placeholder: true }; + } + private async save(state: TowerState): Promise { const file = this.abs(STATE_FILE); const tmp = `${file}.tmp-${String(process.pid)}-${randomUUID()}`; @@ -646,7 +888,7 @@ export class TowerStore { scope: [...item.scope], branch: `feat/${slug}`, worktree: `wt-${n}`, - deps: item.deps ?? [], + deps: [...(item.deps ?? [])], status: 'planned', context: item.context !== undefined && item.context.trim().length > 0 @@ -683,8 +925,18 @@ export class TowerStore { } takenBranches.set(mission.branch, mission); } + const openExisting = state.missions.filter(isOpenMission); + const byId = new Map(); + for (const existing of [...openExisting, ...missions]) byId.set(existing.id, existing); + for (const mission of missions) { + const older = [ + ...openExisting, + ...missions.filter((m) => missionNumber(m.id) < missionNumber(mission.id)), + ]; + addAutoDeps(mission, older, byId); + } this.assertScopesDisjoint([ - ...state.missions.filter(isOpenMission), + ...openExisting, ...missions, ]); @@ -709,7 +961,7 @@ export class TowerStore { for (const mission of missions) { if (mission.kind === 'survey') continue; for (const raw of mission.scope) { - const stem = raw.replace(/\/\*\*?$/, '').replace(/\*$/, '').replace(/\/+$/, ''); + const stem = scopeStem(raw); if (stem.length === 0) { throw new TowerProtocolError( `mission ${mission.id} scope "${raw}" covers the whole repo — narrow it down`, @@ -718,14 +970,16 @@ export class TowerStore { scopes.push({ id: mission.id, raw, stem }); } } + const byId = new Map(missions.map((m) => [m.id, m])); for (let i = 0; i < scopes.length; i++) { for (let j = i + 1; j < scopes.length; j++) { const a = scopes[i]!; const b = scopes[j]!; if (a.id === b.id) continue; if (a.stem === b.stem || a.stem.startsWith(`${b.stem}/`) || b.stem.startsWith(`${a.stem}/`)) { + if (depPathExists(a.id, b.id, byId) || depPathExists(b.id, a.id, byId)) continue; throw new TowerProtocolError( - `mission scopes overlap: ${a.id} ("${a.raw}") vs ${b.id} ("${b.raw}") — split the shared files into exactly one mission; if one of them is stale finished work, abandon it first (TowerMission status=abandoned)`, + `mission scopes overlap: ${a.id} ("${a.raw}") vs ${b.id} ("${b.raw}") without a dependency between them — split the shared files into exactly one mission, or wire a dependency so the merges serialize (TowerPlan and scope widening auto-add one when scopes overlap; if one of them is stale finished work, abandon it first (TowerMission status=abandoned))`, ); } } @@ -740,139 +994,166 @@ export class TowerStore { ): Promise { return this.withStateLock(async () => { const state = await this.load(); - const mission = state.missions.find((m) => m.id === id); - if (mission === undefined) { - throw new TowerProtocolError(`unknown mission "${id}"`); + return this.updateMissionLocked(callerName, id, patch, state, options); + }); + } + + private async updateMissionLocked( + callerName: string, + id: string, + patch: TowerMissionPatch, + state: TowerState, + options: { readonly silent?: boolean } = {}, + ): Promise { + const mission = state.missions.find((m) => m.id === id); + if (mission === undefined) { + throw new TowerProtocolError(`unknown mission "${id}"`); + } + if (callerName !== TOWER_NAME) { + const caller = this.findAgent(state, callerName); + if (caller?.kind !== 'worker' || caller.missionId !== id) { + throw new TowerProtocolError( + `agent "${callerName}" does not own mission ${id} — workers update only their own mission file`, + ); } + } + + const isNoOp = + patch.status === mission.status && + patch.note === undefined && + patch.blocker === undefined && + patch.clearBlockers === undefined && + patch.taskDone === undefined && + patch.taskDrop === undefined && + patch.owner === undefined && + patch.scope === undefined && + patch.spawnBase === undefined; + if (isNoOp) return mission; + + if (patch.status === 'merged') { + throw new TowerProtocolError( + `mission ${id} cannot transition to merged through a generic mission patch — only TowerMerge records merged after its merge gate succeeds`, + ); + } + if ( + !isOpenMission(mission) && + ((patch.status !== undefined && patch.status !== mission.status) || + patch.blocker !== undefined) + ) { + throw new TowerProtocolError( + `mission ${id} is ${mission.status} — merged and abandoned are terminal, so a generic mission patch cannot reopen it`, + ); + } + + if (patch.spawnBase !== undefined) { if (callerName !== TOWER_NAME) { - const caller = this.findAgent(state, callerName); - if (caller?.kind !== 'worker' || caller.missionId !== id) { - throw new TowerProtocolError( - `agent "${callerName}" does not own mission ${id} — workers update only their own mission file`, - ); - } + throw new TowerProtocolError( + `agent "${callerName}" cannot record a mission spawn base — only the tower does`, + ); } + mission.spawnBase = patch.spawnBase; + } - const isNoOp = - patch.status === mission.status && - patch.note === undefined && - patch.blocker === undefined && - patch.clearBlockers === undefined && - patch.taskDone === undefined && - patch.taskDrop === undefined && - patch.owner === 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( + `agent "${callerName}" cannot assign mission ownership — only the tower sets owner`, + ); } - - if (patch.owner !== undefined) { - if (callerName !== TOWER_NAME) { - throw new TowerProtocolError( - `agent "${callerName}" cannot assign mission ownership — only the tower sets owner`, - ); - } - mission.owner = patch.owner; + mission.owner = patch.owner; + } + if (patch.scope !== undefined) { + if (callerName !== TOWER_NAME) { + throw new TowerProtocolError( + `agent "${callerName}" cannot change mission scope — only the tower widens a scope, and every change is logged`, + ); } - if (patch.scope !== undefined) { - if (callerName !== TOWER_NAME) { - throw new TowerProtocolError( - `agent "${callerName}" cannot change mission scope — only the tower widens a scope, and every change is logged`, - ); - } - this.assertScopesDisjoint([ - ...state.missions.filter((m) => m.id !== id && isOpenMission(m)), - { ...mission, scope: [...patch.scope] }, - ]); - mission.scope = [...patch.scope]; - } - if (patch.status !== undefined) { - if (patch.status === 'abandoned' && callerName !== TOWER_NAME) { - throw new TowerProtocolError( - `agent "${callerName}" cannot abandon mission ${id} — abandoning releases the mission scope, so only the tower does it`, - ); - } - mission.status = patch.status; + const patched: TowerMission = { ...mission, scope: [...patch.scope] }; + const others = state.missions.filter((m) => m.id !== id && isOpenMission(m)); + const byId = new Map(state.missions.map((m) => [m.id, m])); + byId.set(id, patched); + addAutoDeps(patched, others, byId); + this.assertScopesDisjoint([...others, patched]); + mission.scope = [...patch.scope]; + } + if (patch.status !== undefined) { + if (patch.status === 'abandoned' && callerName !== TOWER_NAME) { + throw new TowerProtocolError( + `agent "${callerName}" cannot abandon mission ${id} — abandoning releases the mission scope, so only the tower does it`, + ); } - if (patch.note !== undefined) mission.notes.push(patch.note); - if (patch.blocker !== undefined) { - mission.blockers.push(patch.blocker); - mission.status = 'blocked'; + mission.status = patch.status; + } + if (patch.note !== undefined) mission.notes.push(patch.note); + if (patch.blocker !== undefined) { + mission.blockers.push(patch.blocker); + mission.status = 'blocked'; + } + if (patch.clearBlockers === true) mission.blockers = []; + if (patch.taskDone !== undefined) { + const task = mission.tasks.find( + (t) => !t.done && t.dropped !== true && t.text.includes(patch.taskDone!), + ); + if (task === undefined) { + throw new TowerProtocolError( + `mission ${id} has no open task matching "${patch.taskDone}"`, + ); } - if (patch.clearBlockers === true) mission.blockers = []; - if (patch.taskDone !== undefined) { - const task = mission.tasks.find( - (t) => !t.done && t.dropped !== true && t.text.includes(patch.taskDone!), + task.done = true; + } + let taskDropLog: string | undefined; + if (patch.taskDrop !== undefined) { + const reason = patch.taskDrop.reason?.trim() ?? ''; + if (reason.length === 0) { + throw new TowerProtocolError( + `dropping a task from mission ${id} requires a reason — the drop is the escape hatch for legitimately descoped work, and the reason is recorded in the mission notes and the activity log for audit`, ); - if (task === undefined) { - throw new TowerProtocolError( - `mission ${id} has no open task matching "${patch.taskDone}"`, - ); - } - task.done = true; } - let taskDropLog: string | undefined; - if (patch.taskDrop !== undefined) { - const reason = patch.taskDrop.reason?.trim() ?? ''; - if (reason.length === 0) { - throw new TowerProtocolError( - `dropping a task from mission ${id} requires a reason — the drop is the escape hatch for legitimately descoped work, and the reason is recorded in the mission notes and the activity log for audit`, - ); - } - const task = mission.tasks.find( - (t) => !t.done && t.dropped !== true && t.text.includes(patch.taskDrop!.text), + const task = mission.tasks.find( + (t) => !t.done && t.dropped !== true && t.text.includes(patch.taskDrop!.text), + ); + if (task === undefined) { + throw new TowerProtocolError( + `mission ${id} has no open task matching "${patch.taskDrop.text}"`, ); - if (task === undefined) { - throw new TowerProtocolError( - `mission ${id} has no open task matching "${patch.taskDrop.text}"`, - ); - } - task.dropped = true; - taskDropLog = `dropped task "${task.text}": ${reason}`; - mission.notes.push(taskDropLog); - } - if (patch.status === 'completed' && patch.blocker === undefined) { - await this.assertCompletable(state, mission); - if (callerName !== TOWER_NAME) { - await this.assertInboxRead(state, callerName, mission); - } } - - await this.save(state); - await this.renderMissionsIndex(state); - await this.renderMissionFile(mission); - const taskTickOnly = - patch.taskDone !== undefined && - patch.status === undefined && - patch.note === undefined && - patch.blocker === undefined && - patch.clearBlockers === undefined && - patch.taskDrop === undefined && - patch.owner === undefined && - patch.scope === undefined && - patch.spawnBase === undefined; - if (!taskTickOnly && options.silent !== true) { - await this.appendLog(callerName, 'mission.update', { - id, - status: patch.status, - note: patch.note !== undefined ? 'added' : undefined, - blocker: patch.blocker !== undefined ? 'added' : undefined, - task_drop: taskDropLog, - owner: patch.owner, - scope: patch.scope?.join(','), - spawn_base: patch.spawnBase, - }); + task.dropped = true; + taskDropLog = `dropped task "${task.text}": ${reason}`; + mission.notes.push(taskDropLog); + } + if (patch.status === 'completed' && patch.blocker === undefined) { + await this.assertCompletable(state, mission); + if (callerName !== TOWER_NAME) { + await this.assertInboxRead(state, callerName, mission); } - return mission; - }); + } + + await this.save(state); + await this.renderMissionsIndex(state); + await this.renderMissionFile(mission); + const taskTickOnly = + patch.taskDone !== undefined && + patch.status === undefined && + patch.note === undefined && + patch.blocker === undefined && + patch.clearBlockers === undefined && + patch.taskDrop === undefined && + patch.owner === undefined && + patch.scope === undefined && + patch.spawnBase === undefined; + if (!taskTickOnly && options.silent !== true) { + await this.appendLog(callerName, 'mission.update', { + id, + status: patch.status, + note: patch.note !== undefined ? 'added' : undefined, + blocker: patch.blocker !== undefined ? 'added' : undefined, + task_drop: taskDropLog, + owner: patch.owner, + scope: patch.scope?.join(','), + spawn_base: patch.spawnBase, + }); + } + return mission; } private async assertCompletable(state: TowerState, mission: TowerMission): Promise { @@ -882,7 +1163,22 @@ export class TowerStore { `mission ${mission.id} cannot transition to completed — ${String(open.length)} open task(s): ${open.map((t) => `"${t.text}"`).join(', ')}; tick finished tasks with task_done, or drop legitimately descoped ones with task_drop (a reason is mandatory and lands in the mission notes and the activity log)`, ); } - if (mission.kind === 'survey') return; + if (mission.blockers.length > 0) { + throw new TowerProtocolError( + `mission ${mission.id} cannot transition to completed — ${String(mission.blockers.length)} blocker(s) remain: ${mission.blockers.join(' | ')}; clear or resolve them before completing`, + ); + } + if (mission.kind === 'survey') { + if (!(await branchExists(this.repoRoot, mission.branch))) return; + const base = await this.diffBase(state, mission); + const changed = await diffNameOnly(this.repoRoot, base, mission.branch); + if (changed.length > 0) { + throw new TowerProtocolError( + `mission ${mission.id} cannot transition to completed — it is a read-only survey but branch "${mission.branch}" has ${String(changed.length)} changed file(s) vs "${base}": ${changed.slice(0, 5).join(', ')}; move any changes worth keeping to a build mission and leave the survey branch zero-diff`, + ); + } + return; + } if (!(await branchExists(this.repoRoot, mission.branch))) { throw new TowerProtocolError( `mission ${mission.id} cannot transition to completed — its branch "${mission.branch}" does not exist, so no work has landed; a build mission must produce a diff on its branch: spawn a worker to do the work, or have the tower abandon the mission (status=abandoned) if it is no longer needed`, @@ -902,20 +1198,29 @@ export class TowerStore { callerName: string, mission: TowerMission, ): Promise { - const entry = this.findAgent(state, callerName); - if (entry === undefined) return; - const since = entry.lastInboxReadAt ?? entry.spawnedAt; - const unread = (await this.listInboxItems()).filter( - (item) => (item.to === callerName || item.to === BROADCAST_NAME) && item.sentAt > since, - ); + const unread = await this.listUnreadInboxItems(state, callerName); if (unread.length === 0) return; throw new TowerProtocolError( - `mission ${mission.id} cannot transition to completed — ${String(unread.length)} unread inbox message(s) for ${callerName} arrived since the inbox was last read; call TowerInbox, incorporate anything new into the delivery, then retry status=completed`, + `mission ${mission.id} cannot transition to completed — ${String(unread.length)} unread inbox message(s) for ${callerName}; call TowerInbox until no unread messages remain, incorporate anything new into the delivery, then retry status=completed`, ); } async send(callerName: string, input: TowerSendInput): Promise { - const state = await this.load(); + return (await this.sendDetailed(callerName, input)).item.file; + } + + async sendDetailed(callerName: string, input: TowerSendInput): Promise { + return this.withStateLock(async () => { + const state = await this.load(); + return this.sendDetailedLocked(callerName, input, state); + }); + } + + private async sendDetailedLocked( + callerName: string, + input: TowerSendInput, + state: TowerState, + ): Promise { const to = input.to.trim(); if ( to !== TOWER_NAME && @@ -931,54 +1236,276 @@ export class TowerStore { throw new TowerProtocolError('cannot send an inbox message to yourself'); } + const messageId = randomUUID(); + const sentAt = new Date().toISOString(); + const body = input.body.trim(); const frontmatter = renderFrontmatter({ type: 'inbox', - message_id: randomUUID(), + message_id: messageId, from: callerName, to, subject: input.subject, - sent_at: new Date().toISOString(), + sent_at: sentAt, scope: input.scope, action: input.action, consent_ref: input.consentRef, - tokens: String(input.tokens ?? -1), }); - const content = `${frontmatter}\n\n${input.body.trim()}\n`; + const content = `${frontmatter}\n\n${body}\n`; const baseName = inboxFileName({ from: callerName, to, subject: input.subject }); const rel = await this.writeUnique(join(INBOX_DIR, baseName), content); - await this.appendLog( - callerName, - 'inbox.send', - { to, subject: slugify(input.subject), tokens: input.tokens ?? -1 }, - rel, - ); - return rel; - } - - async readInbox(callerName: string, limit: number): Promise { - const items = (await this.listInboxItems()).filter( - (item) => callerName === TOWER_NAME || item.to === callerName || item.to === BROADCAST_NAME, - ); - items.sort((a, b) => b.sentAt.localeCompare(a.sentAt)); - return items.slice(0, Math.max(1, limit)); + let activityLogError: string | undefined; + try { + await this.appendLog( + callerName, + 'inbox.send', + { to, subject: slugify(input.subject) }, + rel, + ); + } catch (error) { + activityLogError = `inbox message ${rel} was stored, but appending its inbox.send activity log failed: ${error instanceof Error ? error.message : String(error)}`; + } + const caller = this.findAgent(state, callerName); + const recipient = + to === TOWER_NAME || to === BROADCAST_NAME ? undefined : this.findAgent(state, to); + const missionId = + caller?.missionId ?? + caller?.reviewMissionId ?? + (callerName === TOWER_NAME + ? (recipient?.missionId ?? recipient?.reviewMissionId) + : undefined); + return { + item: { + messageId, + file: rel, + from: callerName, + to, + subject: input.subject, + sentAt, + scope: input.scope, + action: input.action, + consentRef: input.consentRef, + body, + }, + missionId, + activityLogError, + }; } - async markInboxRead(callerName: string, newestSeenSentAt?: string): Promise { - await this.withStateLock(async () => { + async complete( + callerName: string, + report: string, + ): Promise { + const trimmed = report.trim(); + if (trimmed.length === 0) { + throw new TowerProtocolError('TowerComplete report must not be empty'); + } + return this.withStateLock(async () => { const state = await this.load(); - const index = state.roster.agents.findIndex((agent) => agent.name === callerName); - const entry = state.roster.agents[index]; - if (entry === undefined) return; - const at = newestSeenSentAt ?? new Date().toISOString(); - if ((entry.lastInboxReadAt ?? entry.spawnedAt) >= at) return; - state.roster.agents[index] = { ...entry, lastInboxReadAt: at }; - await this.save(state); + const caller = this.findAgent(state, callerName); + const ownedMission = + caller?.missionId !== undefined + ? state.missions.find((mission) => mission.id === caller.missionId) + : undefined; + if (callerName === TOWER_NAME || caller?.kind !== 'worker' || ownedMission === undefined) { + if (caller?.kind === 'worker' && state.recoveredAt !== undefined) { + return this.completeStateLostLocked(caller, trimmed, state, state.recoveredAt); + } + if (caller?.kind === 'worker' && caller.missionId !== undefined) { + throw new TowerProtocolError(`unknown mission "${caller.missionId}"`); + } + throw new TowerProtocolError( + `agent "${callerName}" cannot use TowerComplete — only the worker who owns a mission can complete it`, + ); + } + const beforeMission = ownedMission; + const wasCompleted = beforeMission.status === 'completed'; + const subject = beforeMission.kind === 'survey' ? 'survey-summary' : 'review-request'; + if (wasCompleted) { + const existing = await this.findCompletionMessage( + callerName, + beforeMission, + subject, + trimmed, + ); + if (existing !== undefined) { + return { + mission: beforeMission, + message: { item: existing, missionId: beforeMission.id }, + notificationCreated: false, + }; + } + await this.assertCompletable(state, beforeMission); + await this.assertInboxRead(state, callerName, beforeMission); + } + const mission = await this.updateMissionLocked( + callerName, + beforeMission.id, + { status: 'completed' }, + state, + ); + try { + const message = await this.sendDetailedLocked( + callerName, + { + to: TOWER_NAME, + subject, + body: trimmed, + scope: mission.id, + action: 'complete', + }, + state, + ); + return { + mission, + message, + notificationCreated: true, + activityLogError: message.activityLogError, + }; + } catch (error) { + const existing = await this.findCompletionMessage(callerName, mission, subject, trimmed); + return { + mission, + message: + existing === undefined ? undefined : { item: existing, missionId: mission.id }, + notificationCreated: false, + notificationError: `mission ${mission.id} was persisted as completed, but storing its ${subject} notification failed: ${error instanceof Error ? error.message : String(error)} — retry with the same report to reuse an already stored notification instead of duplicating it`, + }; + } }); } - private async listInboxItems(): Promise { - let files: string[]; - try { + private async completeStateLostLocked( + caller: TowerRosterEntry, + report: string, + state: TowerState, + recoveredAt: string, + ): Promise { + const subject = 'completion-report-state-lost'; + const existing = (await this.listInboxItems()).find( + (item) => + item.from === caller.name && + item.to === TOWER_NAME && + item.subject === subject && + item.action === 'complete' && + item.body === report, + ); + if (existing !== undefined) { + return { + recoveredAt, + message: { item: existing }, + notificationCreated: false, + }; + } + try { + const message = await this.sendDetailedLocked( + caller.name, + { + to: TOWER_NAME, + subject, + body: report, + action: 'complete', + }, + state, + ); + return { + recoveredAt, + message, + notificationCreated: true, + activityLogError: message.activityLogError, + }; + } catch (error) { + return { + recoveredAt, + notificationCreated: false, + notificationError: `the tower state was recovered at ${recoveredAt} after a loss, so no mission was marked completed — and storing the degraded completion report also failed: ${error instanceof Error ? error.message : String(error)} — retry with the same report to deliver it`, + }; + } + } + + private async findCompletionMessage( + callerName: string, + mission: TowerMission, + subject: string, + report: string, + ): Promise { + return (await this.listInboxItems()).find( + (item) => + item.from === callerName && + item.to === TOWER_NAME && + item.subject === subject && + item.scope === mission.id && + item.action === 'complete' && + item.body === report, + ); + } + + async readInbox(callerName: string, limit: number): Promise { + const items = (await this.listInboxItems()).filter((item) => + this.inboxItemVisibleTo(callerName, item), + ); + return items.slice(0, Math.max(1, limit)); + } + + async readUnreadInbox(callerName: string, limit: number): Promise { + const state = await this.load(); + const items = await this.listUnreadInboxItems(state, callerName); + return items.slice(0, Math.max(1, limit)); + } + + async ackInbox(callerName: string, messageIds: readonly string[]): Promise { + return this.withStateLock(async () => { + const state = await this.load(); + const index = state.roster.agents.findIndex((agent) => agent.name === callerName); + const entry = state.roster.agents[index]; + const current = callerName === TOWER_NAME ? state.inboxAckIds : entry?.inboxAckIds; + const ackIds = new Set(current); + const previousSize = ackIds.size; + for (const messageId of messageIds) ackIds.add(messageId); + let changed = false; + if (ackIds.size > previousSize) { + if (callerName === TOWER_NAME) { + state.inboxAckIds = [...ackIds]; + changed = true; + } else if (entry !== undefined) { + state.roster.agents[index] = { ...entry, inboxAckIds: [...ackIds] }; + changed = true; + } + } + if (changed) await this.save(state); + return (await this.listUnreadInboxItems(state, callerName)).length; + }); + } + + private inboxItemVisibleTo(callerName: string, item: TowerInboxItem): boolean { + return callerName === TOWER_NAME || item.to === callerName || item.to === BROADCAST_NAME; + } + + private async listUnreadInboxItems( + state: TowerState, + callerName: string, + ): Promise { + return (await this.listInboxItems()).filter( + (item) => + this.inboxItemVisibleTo(callerName, item) && this.isInboxItemUnread(state, callerName, item), + ); + } + + private isInboxItemUnread( + state: TowerState, + callerName: string, + item: TowerInboxItem, + ): boolean { + const entry = callerName === TOWER_NAME ? undefined : this.findAgent(state, callerName); + const ackIds = callerName === TOWER_NAME ? state.inboxAckIds : entry?.inboxAckIds; + if (ackIds?.includes(item.messageId) === true) return false; + if (callerName === TOWER_NAME) return true; + const since = entry?.lastInboxReadAt ?? entry?.spawnedAt; + return since === undefined || item.sentAt > since; + } + + private async listInboxItems(): Promise { + let files: string[]; + try { files = await readdir(this.abs(INBOX_DIR)); } catch { return []; @@ -995,6 +1522,7 @@ export class TowerStore { const { fields, body } = parseFrontmatter(text); if (fields['type'] !== 'inbox') continue; items.push({ + messageId: fields['message_id'] ?? rel, file: rel, from: fields['from'] ?? 'unknown', to: fields['to'] ?? '', @@ -1006,6 +1534,7 @@ export class TowerStore { body, }); } + items.sort((a, b) => b.sentAt.localeCompare(a.sentAt) || a.file.localeCompare(b.file)); return items; } @@ -1030,7 +1559,6 @@ export class TowerStore { `**Type**: ${input.type}`, `**Severity**: ${input.severity ?? 'medium'}`, `**Mission**: ${mission === undefined ? '(none)' : `${mission.id} — ${mission.title}`}`, - `**Tokens**: ${String(input.tokens ?? -1)}`, '', '---', '', @@ -1065,13 +1593,13 @@ export class TowerStore { await this.appendLog( callerName, 'finding.file', - { type: input.type, slug: slugify(input.title), tokens: input.tokens ?? -1 }, + { type: input.type, slug: slugify(input.title) }, rel, ); return rel; } - async submitReview(callerName: string, input: TowerReviewInput): Promise { + async submitReview(callerName: string, input: TowerReviewInput): Promise { return this.withStateLock(async () => { const state = await this.load(); let callerEntry: TowerRosterEntry | undefined; @@ -1095,86 +1623,314 @@ export class TowerStore { } const existing = await this.reviewsFor(input.target); - const myRounds = existing.filter((r) => r.reviewer === callerName).length; - if (myRounds >= MAX_REVIEW_ROUNDS) { - throw new TowerProtocolError( - `branch "${input.target}" has already been through ${String(MAX_REVIEW_ROUNDS)} review rounds by "${callerName}" — the rework loop is not converging, so another round from the same reviewer is refused; redirect instead: reassign the work (spawn a different worker or a fresh reviewer), split the mission into smaller pieces, or descope it (TowerMission status=abandoned)`, - ); - } - const round = myRounds + 1; - const seq = await this.nextReviewSeq(); const reviewedCommit = await branchTip(this.repoRoot, input.target); const reviewMissionId = callerEntry === undefined ? resolveMissionByBranch(state, input.target)?.id : callerEntry.reviewMissionId; - - const frontmatter = renderFrontmatter({ - date: dateDash(), - reviewer: callerName, - target: input.target, - round: String(round), - seq: String(seq), - status: input.status, - merge: input.merge, - reviewed_commit: reviewedCommit, - mission: reviewMissionId, - tokens: String(input.tokens ?? -1), - }); - const checks = (input.checks ?? []).map((c) => `- [x] ${c}`).join('\n'); - const content = [ - frontmatter, - '', - '## Findings', - '', - input.findings.trim(), - '', - '## Checks', - checks.length > 0 ? checks : '- [x] (reviewer reported no formal checks)', - '', - '## Decision', - input.decision.trim(), - '', - ].join('\n'); - - const rel = await this.writeUnique( - join(REVIEWS_DIR, reviewFileName({ target: input.target, reviewer: callerName, round })), - content, - ); - await this.appendLog( + const reviewMission = + reviewMissionId !== undefined + ? state.missions.find((mission) => mission.id === reviewMissionId) + : resolveMissionByBranch(state, input.target); + const activityLogErrors: string[] = []; + let review = await this.findReviewRecovery( + state, callerName, - 'review.write', - { + input, + existing, + reviewedCommit, + reviewMission, + ); + if (review !== undefined && !(await this.hasActivityLogRef('review.write', review.file))) { + try { + await this.appendLog( + callerName, + 'review.write', + { + target: input.target, + round: review.round, + verdict: review.status, + reviewed: review.reviewedCommit.slice(0, 7), + }, + review.file, + ); + } catch (error) { + activityLogErrors.push( + `review ${review.file} was stored, but appending its review.write activity log failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + if (review === undefined) { + const myRounds = existing.filter((candidate) => candidate.reviewer === callerName).length; + if (myRounds >= MAX_REVIEW_ROUNDS) { + throw new TowerProtocolError( + `branch "${input.target}" has already been through ${String(MAX_REVIEW_ROUNDS)} review rounds by "${callerName}" — the rework loop is not converging, so another round from the same reviewer is refused; redirect instead: reassign the work (spawn a different worker or a fresh reviewer), split the mission into smaller pieces, or descope it (TowerMission status=abandoned)`, + ); + } + const round = myRounds + 1; + const seq = await this.nextReviewSeq(); + const date = dateDash(); + const frontmatter = renderFrontmatter({ + date, + reviewer: callerName, + target: input.target, + round: String(round), + seq: String(seq), + status: input.status, + merge: input.merge, + reviewed_commit: reviewedCommit, + mission: reviewMissionId, + }); + const content = `${frontmatter}\n\n${reviewDocumentBody(input)}\n`; + const rel = await this.writeUnique( + join(REVIEWS_DIR, reviewFileName({ target: input.target, reviewer: callerName, round })), + content, + ); + review = { + reviewer: callerName, target: input.target, round, - verdict: input.status, - reviewed: reviewedCommit.slice(0, 7), - tokens: input.tokens ?? -1, - }, - rel, - ); - if (input.status !== 'clean') { - const reworkMission = - reviewMissionId !== undefined - ? state.missions.find((m) => m.id === reviewMissionId) - : resolveMissionByBranch(state, input.target); - if (reworkMission !== undefined && reworkMission.status === 'completed') { - reworkMission.status = 'active'; - await this.save(state); - await this.renderMissionsIndex(state); - await this.renderMissionFile(reworkMission); + status: input.status, + merge: input.merge, + reviewedCommit, + date, + file: rel, + mtimeMs: (await stat(this.abs(rel))).mtimeMs, + seq, + mission: reviewMissionId, + }; + try { + await this.appendLog( + callerName, + 'review.write', + { + target: input.target, + round, + verdict: input.status, + reviewed: reviewedCommit.slice(0, 7), + }, + rel, + ); + } catch (error) { + activityLogErrors.push( + `review ${rel} was stored, but appending its review.write activity log failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + if (input.status !== 'clean' && reviewMission?.status === 'completed') { + reviewMission.status = 'active'; + await this.save(state); + await this.renderMissionsIndex(state); + await this.renderMissionFile(reviewMission); + try { await this.appendLog( callerName, 'mission.rework', - { id: reworkMission.id, verdict: input.status }, - join(MISSIONS_DIR, missionFileName(reworkMission.id, reworkMission.slug)), + { id: reviewMission.id, verdict: input.status }, + join(MISSIONS_DIR, missionFileName(reviewMission.id, reviewMission.slug)), + ); + } catch (error) { + activityLogErrors.push( + `mission ${reviewMission.id} was marked active, but appending mission.rework activity log failed: ${error instanceof Error ? error.message : String(error)}`, ); } } - return rel; + const { storedMessages, notificationErrors } = await this.storeReviewMessages( + state, + callerName, + input, + review, + reviewMission, + activityLogErrors, + ); + return { + review, + storedMessages, + notificationError: + notificationErrors.length > 0 + ? `review ${review.file} was stored, but storing review-result notification(s) failed: ${notificationErrors.join(' | ')}` + : undefined, + activityLogError: + activityLogErrors.length > 0 ? activityLogErrors.join(' | ') : undefined, + }; }); } + private async findReviewRecovery( + state: TowerState, + callerName: string, + input: TowerReviewInput, + existing: readonly TowerReviewInfo[], + reviewedCommit: string, + reviewMission: TowerMission | undefined, + ): Promise { + const expectedBody = reviewDocumentBody(input); + const recipients = this.reviewRecipients(state, callerName, input, reviewMission); + for (const candidate of existing.toReversed()) { + if ( + candidate.reviewer !== callerName || + candidate.reviewedCommit !== reviewedCommit || + candidate.status !== input.status || + candidate.merge !== input.merge + ) { + continue; + } + const { body } = parseFrontmatter(await readFile(this.abs(candidate.file), 'utf8')); + if (body !== expectedBody) continue; + if (!(await this.hasActivityLogRef('review.write', candidate.file))) return candidate; + const messages = await this.listInboxItems(); + let needsRecovery = false; + for (const recipient of recipients) { + const message = messages.find( + (item) => this.isReviewResultMessage(item, callerName, recipient, candidate, reviewMission, input), + ); + if (message === undefined || !(await this.hasActivityLogRef('inbox.send', message.file))) { + needsRecovery = true; + break; + } + } + if (needsRecovery) return candidate; + return undefined; + } + return undefined; + } + + private async storeReviewMessages( + state: TowerState, + callerName: string, + input: TowerReviewInput, + review: TowerReviewInfo, + reviewMission: TowerMission | undefined, + activityLogErrors: string[], + ): Promise<{ + readonly storedMessages: readonly TowerSendResult[]; + readonly notificationErrors: readonly string[]; + }> { + const recipients = this.reviewRecipients(state, callerName, input, reviewMission); + const body = this.reviewNotificationBody(review, reviewMission, input); + const messages = await this.listInboxItems(); + const storedMessages: TowerSendResult[] = []; + const notificationErrors: string[] = []; + for (const recipient of recipients) { + const existing = messages.find( + (item) => this.isReviewResultMessage(item, callerName, recipient, review, reviewMission, input), + ); + if (existing !== undefined) { + storedMessages.push({ + item: existing, + missionId: reviewMission?.id ?? review.mission, + }); + if (!(await this.hasActivityLogRef('inbox.send', existing.file))) { + try { + await this.appendLog( + callerName, + 'inbox.send', + { to: recipient, subject: slugify('review-result') }, + existing.file, + ); + } catch (error) { + activityLogErrors.push( + `inbox message ${existing.file} was stored, but appending its inbox.send activity log failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + continue; + } + try { + const sent = await this.sendDetailedLocked( + callerName, + { + to: recipient, + subject: 'review-result', + body, + scope: reviewMission?.id ?? review.mission, + action: 'review-result', + }, + state, + ); + storedMessages.push(sent); + if (sent.activityLogError !== undefined) activityLogErrors.push(sent.activityLogError); + } catch (error) { + notificationErrors.push( + `${recipient}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + return { storedMessages, notificationErrors }; + } + + private reviewRecipients( + state: TowerState, + callerName: string, + input: TowerReviewInput, + reviewMission: TowerMission | undefined, + ): readonly string[] { + const owner = + reviewMission !== undefined && isOpenMission(reviewMission) && reviewMission.owner !== undefined + ? state.roster.agents.find( + (agent) => + agent.name === reviewMission.owner && + agent.kind === 'worker' && + agent.missionId === reviewMission.id && + agent.diedAt === undefined, + ) + : undefined; + const recipients: string[] = []; + if (input.status !== 'clean' && owner !== undefined && owner.name !== callerName) { + recipients.push(owner.name); + } + if (callerName !== TOWER_NAME) recipients.push(TOWER_NAME); + return recipients; + } + + private reviewNotificationBody( + review: TowerReviewInfo, + reviewMission: TowerMission | undefined, + input: TowerReviewInput, + ): string { + return [ + `file: ${review.file}`, + `target: ${review.target}`, + `mission: ${reviewMission?.id ?? review.mission ?? '(none)'}`, + `round: ${String(review.round)}`, + `verdict: ${input.status}`, + `merge recommendation: ${input.merge}`, + `reviewedCommit: ${review.reviewedCommit}`, + `owner: ${reviewMission?.owner ?? '(none)'}`, + ].join('\n'); + } + + private isReviewResultMessage( + item: TowerInboxItem, + callerName: string, + recipient: string, + review: TowerReviewInfo, + reviewMission: TowerMission | undefined, + input: TowerReviewInput, + ): boolean { + return ( + item.from === callerName && + item.to === recipient && + item.subject === 'review-result' && + item.scope === (reviewMission?.id ?? review.mission) && + item.action === 'review-result' && + item.body === this.reviewNotificationBody(review, reviewMission, input) + ); + } + + private async hasActivityLogRef(action: string, rel: string): Promise { + let content: string; + try { + content = await readFile(this.abs(ACTIVITY_LOG), 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } + return content + .split('\n') + .some((line) => line.includes(` ${action} `) && line.endsWith(`ref=${rel}`)); + } + async reviewsFor(target: string): Promise { let files: string[]; try { @@ -1247,11 +2003,10 @@ export class TowerStore { return max + 1; } - async merge(branch: string): Promise<{ - readonly mergeCommit: string; - readonly conflictsWith: ReadonlyArray<{ readonly branch: string; readonly files: readonly string[] }>; - readonly noop?: boolean; - }> { + async merge( + branch: string, + runtime: TowerMissionRuntimeOptions = {}, + ): Promise { return this.withStateLock(async () => { const state = await this.load(); const block = async (reason: string, message: string): Promise => { @@ -1260,143 +2015,394 @@ export class TowerStore { }; const mission = resolveMissionByBranch(state, branch); if (mission === undefined) { - const closed = state.missions.filter((m) => m.branch === branch); + const closed = state.missions.filter((candidate) => candidate.branch === branch); if (closed.length > 0) { throw await block( 'branch-owned-by-closed-missions', - `merge blocked: branch "${branch}" resolves only to closed mission(s) ${closed.map((m) => `${m.id} (${m.status})`).join(', ')} — TowerMerge never flips a closed mission's status; re-plan the work under a new title if it should land`, + `merge blocked: branch "${branch}" resolves only to closed mission(s) ${closed.map((candidate) => `${candidate.id} (${candidate.status})`).join(', ')} — TowerMerge never flips a closed mission's status; re-plan the work under a new title if it should land`, ); } throw new TowerProtocolError(`no tower mission owns branch "${branch}"`); } - const unmergedDeps = mission.deps.filter((dep) => { - const depMission = state.missions.find((m) => m.id === dep); - return depMission !== undefined && isOpenMission(depMission); - }); - if (unmergedDeps.length > 0) { - throw await block( - 'deps-unmerged', - `merge blocked: dependencies not merged yet (${unmergedDeps.join(', ')}) — merge in Dependency Flow order`, - ); + const gate = await this.missionGate(state, mission, runtime); + if (!gate.ready) { + const primary = gate.blocks[0]!; + throw await block(primary.reason, primary.message); } - - if (mission.kind === 'survey') { - const changed = await diffNameOnly(this.repoRoot, await this.diffBase(state, mission), branch); - if (changed.length > 0) { - throw await block( - 'read-only-survey', - `merge blocked: survey mission ${mission.id} is read-only but ${branch} has ${String(changed.length)} changed file(s): ${changed.slice(0, 5).join(', ')} — investigate the worker; if the changes are worth keeping, move them onto a build mission's branch`, - ); + const observation = gate.observations.branch!; + const evaluatedBranchTip = observation.tip; + + if (gate.noop) { + const currentBranchTip = await branchTip(this.repoRoot, branch); + if (currentBranchTip !== evaluatedBranchTip) { + await this.appendLog(TOWER_NAME, 'merge.advanced', { + branch, + evaluated: evaluatedBranchTip.slice(0, 7), + current: currentBranchTip.slice(0, 7), + }); + return { + status: 'advanced', + mergeCommit: gate.observations.headTip, + evaluatedBranchTip, + currentBranchTip, + conflictsWith: [], + noop: true, + }; } mission.status = 'merged'; await this.save(state); await this.renderMissionsIndex(state); await this.renderMissionFile(mission); - const tip = await branchTip(this.repoRoot, state.base); await this.appendLog(TOWER_NAME, 'merge.noop', { branch, kind: 'survey' }); - return { mergeCommit: tip, conflictsWith: [], noop: true }; - } - - const reviews = await this.reviewsFor(branch); - const siblingMissions = state.missions.filter((m) => m.branch === branch && m.id !== mission.id); - const stamped = reviews.filter((r) => r.mission === mission.id); - const candidates = - stamped.length > 0 - ? reviews.filter( - (r) => - r.mission === mission.id || (r.mission === undefined && siblingMissions.length === 0), - ) - : reviews.filter((r) => r.mission === undefined); - const review = candidates.at(-1); - if (review === undefined) { - throw await block( - 'no-review', - `merge blocked: ${branch} has no review — assign a reviewer first`, + return { + status: 'noop', + mergeCommit: gate.observations.headTip, + evaluatedBranchTip, + currentBranchTip, + conflictsWith: [], + noop: true, + }; + } + + const mergeCommit = await mergeNoFf( + this.repoRoot, + evaluatedBranchTip, + `Merge branch '${branch}'`, + ); + const headTip = await branchTip(this.repoRoot, 'HEAD'); + if (!(await isAncestor(this.repoRoot, evaluatedBranchTip, headTip))) { + throw new TowerProtocolError( + `recovery-required: merge of ${branch} returned but HEAD ${headTip} does not contain the evaluated commit ${evaluatedBranchTip} — mission ${mission.id} was not marked merged; inspect the base checkout before retrying`, ); } - if (review.status !== 'clean') { - throw await block( - 'not-clean', - `merge blocked: latest review (round ${review.round} by ${review.reviewer}) is "${review.status}" — a clean round is required`, + const currentBranchTip = await branchTip(this.repoRoot, branch); + if (currentBranchTip !== evaluatedBranchTip) { + await this.appendLog(TOWER_NAME, 'merge.advanced', { + branch, + evaluated: evaluatedBranchTip.slice(0, 7), + current: currentBranchTip.slice(0, 7), + merge_commit: mergeCommit.slice(0, 7), + }); + return { + status: 'advanced', + mergeCommit, + evaluatedBranchTip, + currentBranchTip, + conflictsWith: [], + }; + } + + const changedSet = new Set(observation.changedFiles); + const conflictsWith: Array<{ readonly branch: string; readonly files: readonly string[] }> = []; + 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, + 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 }); + } } - const tip = await branchTip(this.repoRoot, branch); - if (review.reviewedCommit !== tip) { - throw await block( - 'tip-moved', - `merge blocked: ${branch} moved since the clean review (reviewed ${review.reviewedCommit.slice(0, 7)}, tip ${tip.slice(0, 7)}) — re-review required`, + + mission.status = 'merged'; + await this.save(state); + await this.renderMissionsIndex(state); + await this.renderMissionFile(mission); + await this.appendLog(TOWER_NAME, 'merge', { + branch, + base: state.base, + merge_commit: mergeCommit.slice(0, 7), + review_waived: gate.reviewBinding === 'clean-rebase-waived' ? 'yes' : undefined, + }); + return { + status: 'merged', + mergeCommit, + evaluatedBranchTip, + currentBranchTip, + conflictsWith, + }; + }); + } + + async missionGate( + state: TowerState, + mission: TowerMission, + runtime: TowerMissionRuntimeOptions = {}, + ): Promise { + const reviews = await this.reviewsFor(mission.branch); + const observations = await this.observeMissionGit(state, mission); + const activeAgentIds = + typeof runtime.activeAgentIds === 'function' + ? runtime.activeAgentIds() + : runtime.activeAgentIds; + const workers = state.roster.agents + .filter( + (agent) => + agent.kind === 'worker' && + (agent.missionId === mission.id || + agent.branch === mission.branch || + agent.worktree === mission.worktree), + ) + .map((agent) => ({ + agentId: agent.agentId, + name: agent.name, + status: + activeAgentIds === undefined + ? ('unknown' as const) + : activeAgentIds.has(agent.agentId) + ? ('busy' as const) + : ('idle' as const), + })); + return evaluateMissionGate({ state, mission, reviews, workers, observations }); + } + + private async observeMissionGit( + state: TowerState, + mission: TowerMission, + ): Promise { + const baseTip = await branchTip(this.repoRoot, state.base); + const headTip = await branchTip(this.repoRoot, 'HEAD'); + let checkedOutBranch: string | undefined; + try { + checkedOutBranch = await currentBranch(this.repoRoot); + } catch (error) { + if (error instanceof GitError) throw error; + } + const baseDirtyFiles = (await listBaseDirtyEntries(this.repoRoot)).map((entry) => entry.path); + if (!(await branchExists(this.repoRoot, mission.branch))) { + return { baseTip, headTip, checkedOutBranch, baseDirtyFiles }; + } + const tip = await branchTip(this.repoRoot, mission.branch); + const baseIsAncestor = await isAncestor(this.repoRoot, baseTip, tip); + const diffBase = + mission.spawnBase !== undefined && (await isAncestor(this.repoRoot, mission.spawnBase, tip)) + ? mission.spawnBase + : baseTip; + const changedFiles = await diffNameOnly(this.repoRoot, diffBase, tip); + const mergeTouchedFiles = await diffNameOnly(this.repoRoot, headTip, tip); + return { + baseTip, + headTip, + checkedOutBranch, + baseDirtyFiles, + branch: { tip, baseIsAncestor, diffBase, changedFiles, mergeTouchedFiles }, + }; + } + + async rebaseMission( + callerName: string, + id: string, + runtime: TowerMissionRuntimeOptions = {}, + ): Promise { + return this.withStateLock(async () => { + if (callerName !== TOWER_NAME) { + throw new TowerProtocolError( + `agent "${callerName}" cannot rebase a mission branch — only the tower runs TowerRebase; a worker rebases only when the tower asks it to`, ); } - if (review.mission === undefined && siblingMissions.length > 0) { - throw await block( - 'review-mission-mismatch', - `merge blocked: "${branch}" is shared with other mission record(s) ${siblingMissions.map((m) => `${m.id} (${m.status})`).join(', ')}, and the latest clean review (round ${review.round} by ${review.reviewer}) predates mission-stamped reviews — re-review ${mission.id} so the gate can tell which mission was audited`, + const state = await this.load(); + const mission = state.missions.find((candidate) => candidate.id === id); + if (mission === undefined) { + throw new TowerProtocolError(`unknown mission "${id}"`); + } + if (!isOpenMission(mission)) { + throw new TowerProtocolError( + `mission ${id} is ${mission.status} — only open missions rebase; closed missions are historical records`, ); } - - 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)), + if (mission.kind === 'survey') { + throw new TowerProtocolError( + `mission ${id} is a read-only survey — it has no work branch to rebase`, + ); + } + const activeAgentIds = + typeof runtime.activeAgentIds === 'function' + ? runtime.activeAgentIds() + : runtime.activeAgentIds; + const workers = state.roster.agents.filter( + (agent) => + agent.kind === 'worker' && + (agent.missionId === mission.id || + agent.branch === mission.branch || + agent.worktree === mission.worktree), + ); + const busyWorkers = workers.filter( + (worker) => activeAgentIds?.has(worker.agentId) === true, ); - if (outOfScope.length > 0) { - throw await block( - 'out-of-scope', - `merge blocked: ${branch} changed files outside mission ${mission.id} scope (${mission.scope.join(', ')}): ${outOfScope.join(', ')} — the tower must widen the mission scope (TowerMission scope patch) or revert those changes`, + if (busyWorkers.length > 0) { + throw new TowerProtocolError( + `worker(s) ${busyWorkers.map((worker) => worker.name).join(', ')} are mid-turn — rebasing under a running worker could race its edits; TowerSend a message asking for a worker-run rebase, or retry once every worker is idle`, ); } - - let checkedOut: string; - try { - checkedOut = await currentBranch(this.repoRoot); - } catch { - throw await block( - 'base-mismatch', - `merge blocked: the main checkout is in a detached HEAD state — check out the recorded base branch "${state.base}" before merging; nothing was merged`, + if (activeAgentIds === undefined && workers.length > 0) { + throw new TowerProtocolError( + `worker runtime for ${workers.map((worker) => worker.name).join(', ')} cannot be observed authoritatively — unknown is not idle; retry TowerRebase from the main tower agent`, + ); + } + if (!(await branchExists(this.repoRoot, mission.branch))) { + throw new TowerProtocolError( + `mission ${id} has no branch "${mission.branch}" yet — spawn a worker to create it before rebasing`, + ); + } + const worktreeAbs = this.abs(join(WORKTREES_DIR, mission.worktree)); + if (!(await isRegisteredWorktree(this.repoRoot, worktreeAbs))) { + throw new TowerProtocolError( + `mission ${id} has no registered worktree at ${join(WORKTREES_DIR, mission.worktree)} — spawn a worker to create it before rebasing`, + ); + } + if (await isWorktreeDirty(worktreeAbs)) { + throw new TowerProtocolError( + `worktree ${mission.worktree} has uncommitted changes — rebasing under uncommitted work could lose it; have the worker commit or stash first, then retry`, + ); + } + const gitDir = resolve(worktreeAbs, (await git(worktreeAbs, ['rev-parse', '--git-dir'])).trim()); + if (existsSync(join(gitDir, 'rebase-merge')) || existsSync(join(gitDir, 'rebase-apply'))) { + throw new TowerProtocolError( + `worktree ${mission.worktree} already has a rebase in progress — finish or abort it (git rebase --continue / --abort in the worktree) before the tower rebases the branch`, + ); + } + const baseCommit = await branchTip(this.repoRoot, state.base); + const fromCommit = await branchTip(this.repoRoot, mission.branch); + const checkedOut = await git(worktreeAbs, ['rev-parse', '--abbrev-ref', 'HEAD']); + if (checkedOut !== mission.branch) { + throw new TowerProtocolError( + `worktree ${mission.worktree} is on "${checkedOut}", not mission branch "${mission.branch}" — restore the registered checkout before rebasing; nothing was changed`, ); } - if (checkedOut !== state.base) { - throw await block( - 'base-mismatch', - `merge blocked: the main checkout is on "${checkedOut}", not the recorded base "${state.base}" — switch it back (\`git checkout ${state.base}\`) and retry; nothing was merged`, + const worktreeHead = await branchTip(worktreeAbs, 'HEAD'); + if (worktreeHead !== fromCommit) { + throw new TowerProtocolError( + `worktree ${mission.worktree} HEAD ${worktreeHead} does not match mission branch "${mission.branch}" at ${fromCommit} — repair the diverged checkout before rebasing; nothing was changed`, ); } + if (await isAncestor(this.repoRoot, baseCommit, fromCommit)) { + await this.appendLog(TOWER_NAME, 'rebase.mission', { id, result: 'up-to-date' }); + return { status: 'up-to-date' }; + } - 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`, + let rebaseErrorMessage: string | undefined; + try { + await git(worktreeAbs, ['rebase', baseCommit]); + } catch (error) { + rebaseErrorMessage = error instanceof Error ? error.message : 'non-Error thrown value'; + } + if (rebaseErrorMessage !== undefined) { + let conflicted: string; + let inProgress: boolean; + try { + conflicted = await git(worktreeAbs, ['diff', '--name-only', '--diff-filter=U']); + inProgress = + existsSync(join(gitDir, 'rebase-merge')) || existsSync(join(gitDir, 'rebase-apply')); + } catch (error) { + throw new TowerProtocolError( + `recovery-required: rebase of mission ${id} failed and its state could not be inspected: ${error instanceof Error ? error.message : String(error)} — no waiver was recorded and the mission was not marked blocked`, + ); + } + const files = conflicted.split('\n').filter((file) => file.trim().length > 0); + if (inProgress) { + try { + await git(worktreeAbs, ['rebase', '--abort']); + } catch (error) { + throw new TowerProtocolError( + `recovery-required: rebase of mission ${id} failed and git rebase --abort also failed: ${error instanceof Error ? error.message : String(error)} — no recovery is claimed, no waiver was recorded, and the mission was not marked blocked`, + ); + } + } + await this.assertRebaseRecovered(worktreeAbs, gitDir, mission, fromCommit); + if (inProgress && files.length > 0) { + mission.blockers.push( + `tower rebase onto ${state.base} conflicted in: ${files.join(', ')} — the rebase was aborted and your worktree is back to the pre-rebase state. Resolve it yourself: run \`git rebase ${state.base}\` in your worktree, fix the listed conflicts, \`git add\` the resolved files, then continue with \`GIT_EDITOR=true git rebase --continue\` (a plain \`git rebase --continue\` opens an editor and hangs a non-interactive shell). Re-run your tests, then clear this blocker (TowerMission clear_blockers) and TowerSend the tower asking for a fresh review — a worker-run rebase is not covered by the clean-rebase waiver, so the moved tip needs a new clean review before the merge can land`, ); + mission.status = 'blocked'; + await this.save(state); + await this.renderMissionsIndex(state); + await this.renderMissionFile(mission); + await this.appendLog(TOWER_NAME, 'rebase.mission', { + id, + result: 'conflict', + files: files.join(','), + }); + return { status: 'conflict', fromCommit, files }; } + throw new TowerProtocolError( + `rebase of mission ${id} failed: ${rebaseErrorMessage} — no rebase conflict with unmerged paths was observed, so the mission was not marked blocked and no review waiver was recorded`, + ); } - const mergeCommit = await mergeNoFf(this.repoRoot, branch); - mission.status = 'merged'; - - const changedSet = new Set(changed); - const conflictsWith: Array<{ readonly branch: string; readonly files: readonly string[] }> = []; - for (const other of state.missions) { - if (other.branch === branch || !isOpenMission(other)) continue; - if (!(await branchExists(this.repoRoot, other.branch))) continue; - 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 }); + let toCommit: string; + const problems: string[] = []; + try { + toCommit = await branchTip(this.repoRoot, mission.branch); + if ((await git(worktreeAbs, ['rev-parse', '--abbrev-ref', 'HEAD'])) !== mission.branch) { + problems.push('the worktree no longer has the mission branch checked out'); } + if ((await branchTip(worktreeAbs, 'HEAD')) !== toCommit) { + problems.push('worktree HEAD and the mission branch tip differ'); + } + if (await isWorktreeDirty(worktreeAbs)) problems.push('the worktree is dirty'); + if (!(await isAncestor(this.repoRoot, baseCommit, toCommit))) { + problems.push('the rebased branch does not contain the evaluated base commit'); + } + } catch (error) { + throw new TowerProtocolError( + `recovery-required: rebase of mission ${id} returned success but its postconditions could not be inspected: ${error instanceof Error ? error.message : String(error)} — no review waiver was recorded`, + ); } - + if (problems.length > 0) { + throw new TowerProtocolError( + `recovery-required: rebase of mission ${id} returned success but its postconditions failed (${problems.join('; ')}) — no review waiver was recorded; inspect the worktree and branch before retrying`, + ); + } + mission.lastRebase = { fromCommit, toCommit }; await this.save(state); await this.renderMissionsIndex(state); await this.renderMissionFile(mission); - await this.appendLog(TOWER_NAME, 'merge', { branch, base: state.base, merge_commit: mergeCommit.slice(0, 7) }); - return { mergeCommit, conflictsWith }; + await this.appendLog(TOWER_NAME, 'rebase.mission', { + id, + result: 'clean', + from: fromCommit.slice(0, 7), + to: toCommit.slice(0, 7), + }); + return { status: 'rebased', fromCommit, toCommit }; }); } + private async assertRebaseRecovered( + worktreeAbs: string, + gitDir: string, + mission: TowerMission, + fromCommit: string, + ): Promise { + const problems: string[] = []; + if (existsSync(join(gitDir, 'rebase-merge')) || existsSync(join(gitDir, 'rebase-apply'))) { + problems.push('a rebase is still in progress'); + } + try { + if ((await git(worktreeAbs, ['rev-parse', '--abbrev-ref', 'HEAD'])) !== mission.branch) { + problems.push('the mission branch is not checked out'); + } + if ((await branchTip(worktreeAbs, 'HEAD')) !== fromCommit) { + problems.push('worktree HEAD was not restored to the pre-rebase commit'); + } + if ((await branchTip(this.repoRoot, mission.branch)) !== fromCommit) { + problems.push('the mission branch tip was not restored'); + } + if (await isWorktreeDirty(worktreeAbs)) problems.push('the worktree is not clean'); + } catch (error) { + problems.push(`recovery inspection failed: ${error instanceof Error ? error.message : String(error)}`); + } + if (problems.length > 0) { + throw new TowerProtocolError( + `recovery-required: rebase recovery for mission ${mission.id} failed its postconditions (${problems.join('; ')}) — no recovery is claimed, no waiver was recorded, and the mission was not marked blocked`, + ); + } + } + async diffBase(state: TowerState, mission: TowerMission): Promise { if ( mission.spawnBase !== undefined && @@ -1650,6 +2656,21 @@ export class TowerStore { } } +function reviewDocumentBody(input: TowerReviewInput): string { + const checks = (input.checks ?? []).map((check) => `- [x] ${check}`).join('\n'); + return [ + '## Findings', + '', + input.findings.trim(), + '', + '## Checks', + checks.length > 0 ? checks : '- [x] (reviewer reported no formal checks)', + '', + '## Decision', + input.decision.trim(), + ].join('\n'); +} + async function readGitDir(cwd: string): Promise { try { const raw = await readFile(join(cwd, '.git'), 'utf8'); 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 427d2d85190..df0fe848cd7 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/types.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/types.ts @@ -12,6 +12,7 @@ export interface TowerRosterEntry { readonly branch?: string; readonly spawnedAt: string; readonly lastInboxReadAt?: string; + readonly inboxAckIds?: string[]; readonly diedAt?: string; readonly deathStatus?: string; readonly deathReason?: string; @@ -47,7 +48,11 @@ export interface TowerMission { readonly branch: string; readonly worktree: string; spawnBase?: string; - readonly deps: readonly string[]; + deps: string[]; + lastRebase?: { + readonly fromCommit: string; + readonly toCommit: string; + }; status: TowerMissionStatus; owner?: string; context?: string; @@ -61,7 +66,9 @@ export interface TowerState { readonly base: string; readonly mode: 'branch' | 'pr'; readonly createdAt: string; + recoveredAt?: string; sessionId?: string; + inboxAckIds?: string[]; roster: TowerRoster; missions: TowerMission[]; } @@ -87,6 +94,7 @@ export interface TowerReviewInfo { } export interface TowerInboxItem { + readonly messageId: string; readonly file: string; readonly from: string; readonly to: string; diff --git a/packages/agent-core-v2/src/features/tower/tools/complete/complete.md b/packages/agent-core-v2/src/features/tower/tools/complete/complete.md new file mode 100644 index 00000000000..f3780517ca6 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/complete/complete.md @@ -0,0 +1,7 @@ +Complete the mission owned by the calling tower worker and persist its standard completion notification in one locked protocol flow. The tower and reviewers cannot use this tool. + +`report` is required and must contain non-whitespace text. For a build mission, write the full implementation report, reconciled against every mission task; the tool records `completed` and stores a `review-request` to the tower. For a survey mission, report the complete investigation result; the tool records `completed` and stores `survey-summary` instead. + +Completion checks are part of the same flow: no open tasks or blockers, every unread inbox page read, a committed build diff, and a zero-diff survey branch. A failed check writes no notification. If notification storage fails after the mission was persisted, the result says so explicitly. Retrying with the same report reuses an already stored notification instead of silently writing a duplicate. + +If the tower state was lost (.tower/comms deleted while .tower/ survives), the mission record is gone, so no mission is marked completed: the tool recovers a minimal state stamped recoveredAt, delivers the report to the tower as a degraded completion message, and says so explicitly in the result. diff --git a/packages/agent-core-v2/src/features/tower/tools/complete/complete.ts b/packages/agent-core-v2/src/features/tower/tools/complete/complete.ts new file mode 100644 index 00000000000..87fafc8f27d --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/complete/complete.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const TowerCompleteToolInputSchema = z + .object({ + report: z + .string() + .refine((value) => value.trim().length > 0, { message: 'report must not be empty' }) + .describe('Full completion report for the tower and the next review round'), + }) + .strict(); + +export type TowerCompleteToolInput = z.infer; + +export interface ITowerCompleteTool extends AgentTool { + readonly _serviceBrand: undefined; +} +export const ITowerCompleteTool = createDecorator('towerCompleteTool'); diff --git a/packages/agent-core-v2/src/features/tower/tools/complete/completeTool.ts b/packages/agent-core-v2/src/features/tower/tools/complete/completeTool.ts new file mode 100644 index 00000000000..9fb8ad867b0 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/complete/completeTool.ts @@ -0,0 +1,100 @@ +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentTaskService } from '#/agent/task/task'; +import { ISessionEventBus } from '#/app/event/eventBus'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import type { ToolExecution } from '#/tool/toolContract'; + +import { deliverTowerMessage } from '../delivery'; +import { newTowerStore, runTowerTool } from '../support'; +import DESCRIPTION from './complete.md?raw'; +import { + ITowerCompleteTool, + TowerCompleteToolInputSchema, + type TowerCompleteToolInput, +} from './complete'; + +export class TowerCompleteTool implements ITowerCompleteTool { + declare readonly _serviceBrand: undefined; + readonly name = 'TowerComplete' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(TowerCompleteToolInputSchema); + + constructor( + @ISessionContext private readonly sessionContext: ISessionContext, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @ISessionEventBus private readonly sessionBus: ISessionEventBus, + @IAgentTaskService private readonly tasks: IAgentTaskService, + @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, + ) {} + + resolveExecution(args: TowerCompleteToolInput): ToolExecution { + return { + description: 'Completing tower mission and storing its notification', + approvalRule: this.name, + execute: () => + runTowerTool(async () => { + const store = newTowerStore(this.sessionContext); + const resolved = await store.resolveMessagingCaller( + await store.loadOrRecover(), + this.scopeContext.agentId, + ); + const result = await store.complete( + resolved.caller, + args.report.trim(), + ); + const delivery = + result.message !== undefined + ? deliverTowerMessage( + { + sessionBus: this.sessionBus, + tasks: this.tasks, + agentLifecycle: this.agentLifecycle, + state: resolved.state, + }, + result.message, + ) + : ''; + const lines: string[] = []; + if (result.mission === undefined) { + lines.push( + `the tower state was recovered at ${result.recoveredAt ?? 'an unknown time'} after a loss — your mission record is gone, so no mission was marked completed and nothing pretends otherwise`, + ); + if (result.message !== undefined) { + lines.push( + `your report was still delivered to the tower as a ${result.message.item.subject} message: ${result.message.item.file}`, + result.notificationCreated + ? 'notification: stored once' + : 'notification: reused the stored message; no duplicate was written', + ); + } + } else { + lines.push(`mission ${result.mission.id} persisted as ${result.mission.status}`); + if (result.message !== undefined) { + lines.push( + `${result.message.item.subject} notification: ${result.message.item.file}`, + result.notificationCreated + ? 'notification: stored once' + : 'notification: reused the stored message; no duplicate was written', + ); + } + } + if (result.notificationError !== undefined) { + lines.push(`notification error: ${result.notificationError}`); + } + if (result.activityLogError !== undefined) { + lines.push(`activity log error: ${result.activityLogError}`); + } + if (delivery.length > 0) lines.push(delivery); + return { + output: lines.join('\n'), + isError: + result.notificationError === undefined && result.activityLogError === undefined + ? undefined + : true, + }; + }), + }; + } +} diff --git a/packages/agent-core-v2/src/features/tower/tools/delivery.ts b/packages/agent-core-v2/src/features/tower/tools/delivery.ts new file mode 100644 index 00000000000..ae2fbe37a9b --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/delivery.ts @@ -0,0 +1,94 @@ +import type { PromptOrigin } from '#/agent/contextMemory/types'; +import { IAgentLoopService } from '#/agent/loop/loop'; +import type { IAgentTaskService } from '#/agent/task/task'; +import type { ISessionEventBus } from '#/app/event/eventBus'; +import { BROADCAST_NAME, TOWER_NAME } from '#/features/tower/protocol/index'; +import type { TowerSendResult, TowerState } from '#/features/tower/protocol/index'; +import { TowerInboxSent } from '#/features/tower/towerOps'; +import type { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; + +const STEER_SUBJECT_PREVIEW_MAX = 120; + +export interface TowerDeliveryServices { + readonly sessionBus: ISessionEventBus | undefined; + readonly tasks: IAgentTaskService | undefined; + readonly agentLifecycle: IAgentLifecycleService; + readonly state: TowerState; +} + +export function deliverTowerMessage( + services: TowerDeliveryServices, + sent: TowerSendResult, +): string { + const { item } = sent; + if ( + services.sessionBus !== undefined && + item.from !== TOWER_NAME && + (item.to === TOWER_NAME || item.to === BROADCAST_NAME) + ) { + services.sessionBus.publish( + new TowerInboxSent({ + from: item.from, + to: item.to, + subject: item.subject, + messageId: item.messageId, + file: item.file, + sentAt: item.sentAt, + missionId: sent.missionId, + }), + ); + } + const entry = + item.to === TOWER_NAME || item.to === BROADCAST_NAME + ? undefined + : services.state.roster.agents.find((agent) => agent.name === item.to); + const undelivered = + item.from === TOWER_NAME && + entry !== undefined && + services.tasks !== undefined && + !services.tasks + .list(true) + .some((task) => task.kind === 'agent' && task.agentId === entry.agentId); + const note = undelivered + ? `\nnote: ${item.to} has no running task in this session — the message sits in its inbox until you deliver it with Agent(resume="${entry.agentId}", run_in_background=true, prompt="...")` + : ''; + return `${steerIntoRunningAgent(services, sent)}${note}`; +} + +function steerIntoRunningAgent( + services: TowerDeliveryServices, + sent: TowerSendResult, +): string { + const { item } = sent; + if (item.to === TOWER_NAME || item.to === BROADCAST_NAME) return ''; + const entry = services.state.roster.agents.find((agent) => agent.name === item.to); + if (entry === undefined) return ''; + try { + const handle = services.agentLifecycle.handleOf(entry.agentId); + if (handle === undefined) return ''; + const loop = handle.accessor.get(IAgentLoopService); + if (loop.snapshot().state !== 'running') return ''; + const subject = + item.subject.length > STEER_SUBJECT_PREVIEW_MAX + ? `${item.subject.slice(0, STEER_SUBJECT_PREVIEW_MAX)}…` + : item.subject; + loop.submit( + { + message: { + role: 'user', + content: [ + { + type: 'text', + text: `[tower inbox] new message from ${item.from}: "${subject}" — read it now with TowerInbox and act on it before continuing.`, + }, + ], + }, + meta: { origin: { kind: 'injection', variant: 'tower_inbox_steer' } as PromptOrigin }, + }, + { steerIfActive: true }, + ); + return `\nnote: ${item.to} is mid-turn — the message was steered into its running turn; it sees the directive at its next step boundary.`; + } catch { + return ''; + } +} diff --git a/packages/agent-core-v2/src/features/tower/tools/finding/findingTool.ts b/packages/agent-core-v2/src/features/tower/tools/finding/findingTool.ts index 93d5d367c49..2866bbe49b5 100644 --- a/packages/agent-core-v2/src/features/tower/tools/finding/findingTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/finding/findingTool.ts @@ -1,10 +1,9 @@ -import { IAgentScopeContext, agentContextOfScope } from '#/agent/scopeContext/scopeContext'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import { ISessionUsageService } from '#/session/usage/sessionUsage'; import { toInputJsonSchema } from '#/tool/input-schema'; import type { ToolExecution } from '#/tool/toolContract'; -import { callerName, callerTokens, newTowerStore, runTowerTool } from '../support'; +import { callerName, newTowerStore, runTowerTool } from '../support'; import DESCRIPTION from './finding.md?raw'; import { ITowerFindingTool, @@ -21,7 +20,6 @@ export class TowerFindingTool implements ITowerFindingTool { constructor( @ISessionContext private readonly sessionContext: ISessionContext, @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, - @ISessionUsageService private readonly usage: ISessionUsageService, ) {} resolveExecution(args: TowerFindingToolInput): ToolExecution { @@ -41,7 +39,6 @@ export class TowerFindingTool implements ITowerFindingTool { location: args.location, details: args.details, suggestedFix: args.suggested_fix, - tokens: callerTokens(this.usage, agentContextOfScope(this.scopeContext)), }); return { output: `finding filed: ${rel}\nThe tower will route it — do not fix out-of-scope issues yourself.`, diff --git a/packages/agent-core-v2/src/features/tower/tools/inbox/inbox.md b/packages/agent-core-v2/src/features/tower/tools/inbox/inbox.md index f4c3b21e9fc..d0285c7c0f0 100644 --- a/packages/agent-core-v2/src/features/tower/tools/inbox/inbox.md +++ b/packages/agent-core-v2/src/features/tower/tools/inbox/inbox.md @@ -1 +1 @@ -Read your tower inbox: messages addressed to you plus broadcasts, newest first. The tower sees all messages. Full bodies are included — reply with TowerSend. Reading marks your inbox as read: the store refuses a roster agent's own TowerMission status=completed while unread messages wait, so always read before completing. +Read your tower inbox: messages addressed to you plus broadcasts, newest first. The tower sees all messages. By default only the next unread page is returned, and only messages actually returned are acknowledged; the output reports how many unread messages remain, so keep reading until it reaches zero. Set include_read=true to look back through full history, including acknowledged messages; history pages also acknowledge only the messages they return. Full bodies are included — reply with TowerSend. The store refuses a roster agent's own TowerComplete while unread messages wait, so always finish every unread page before completing. diff --git a/packages/agent-core-v2/src/features/tower/tools/inbox/inbox.ts b/packages/agent-core-v2/src/features/tower/tools/inbox/inbox.ts index 87b7958dea1..f2b96477d5d 100644 --- a/packages/agent-core-v2/src/features/tower/tools/inbox/inbox.ts +++ b/packages/agent-core-v2/src/features/tower/tools/inbox/inbox.ts @@ -11,6 +11,10 @@ export const TowerInboxToolInputSchema = z .positive() .optional() .describe('Max messages to return (default 20), newest first'), + include_read: z + .boolean() + .optional() + .describe('Include previously acknowledged messages for history lookup; only returned messages are acknowledged'), }) .strict(); diff --git a/packages/agent-core-v2/src/features/tower/tools/inbox/inboxTool.ts b/packages/agent-core-v2/src/features/tower/tools/inbox/inboxTool.ts index 53831f1c7cb..e1705763eb3 100644 --- a/packages/agent-core-v2/src/features/tower/tools/inbox/inboxTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/inbox/inboxTool.ts @@ -29,11 +29,18 @@ export class TowerInboxTool implements ITowerInboxTool { const store = newTowerStore(this.sessionContext); const state = await store.load(); const caller = callerName(this.scopeContext.agentId, store, state); - const items = await store.readInbox(caller, args.limit ?? DEFAULT_LIMIT); - await store.markInboxRead(caller, items[0]?.sentAt); + const limit = args.limit ?? DEFAULT_LIMIT; + const items = + args.include_read === true + ? await store.readInbox(caller, limit) + : await store.readUnreadInbox(caller, limit); if (items.length === 0) { - return { output: `inbox empty for ${caller}` }; + return { output: `inbox empty for ${caller}; 0 unread message(s) remaining` }; } + const remaining = await store.ackInbox( + caller, + items.map((item) => item.messageId), + ); const sections = items.map((item) => [ `file: ${item.file}`, @@ -52,6 +59,8 @@ export class TowerInboxTool implements ITowerInboxTool { `${String(items.length)} message(s) for ${caller} (newest first):`, '', sections.join('\n\n---\n\n'), + '', + `${String(remaining)} unread message(s) remaining`, ].join('\n'), }; }), diff --git a/packages/agent-core-v2/src/features/tower/tools/init/init.md b/packages/agent-core-v2/src/features/tower/tools/init/init.md index b3619bc2b54..eaacece669a 100644 --- a/packages/agent-core-v2/src/features/tower/tools/init/init.md +++ b/packages/agent-core-v2/src/features/tower/tools/init/init.md @@ -1,9 +1,9 @@ Initialize a tower multi-agent workspace in the current repository. -Creates the .tower/ directory (comms state, inbox, findings, reviews, missions, activity log, worktree slots) that the full tower tool set operates on (TowerPlan/TowerSpawn/TowerMerge/TowerTeardown plus the shared TowerSend/TowerInbox/TowerFinding/TowerReview/TowerMission/TowerStatus). +Creates the .tower/ directory (comms state, inbox, findings, reviews, missions, activity log, worktree slots) that the full tower tool set operates on. -Tower mode must already be active before you call this — only the user can enable it, with `/tower on`; the agent can never enter tower mode by itself. While the mode is off this tool refuses: ask the user to turn tower mode on first. Use tower only when a task is large enough to split across multiple parallel agents with isolated git worktrees and a review-gated merge protocol. +Tower mode must already be active before you call this — while the mode is off this tool refuses, so ask the user to turn it on first. Only the user can enable it (`/tower on`); the agent can never enter tower mode by itself. Use tower only when a task is large enough to split across multiple parallel agents with isolated git worktrees and a review-gated merge protocol. -Safe to call again — an existing workspace is reported, never reset. Re-entering from a new CLI session adopts the workspace: roster entries the previous session spawned are retired (their agent ids cannot be resumed across sessions), while missions, worktrees, and the activity log carry over. Entering tower mode (`/tower on`) already runs this adoption; this tool stays the explicit, idempotent way to re-assert it. +Idempotent — an existing workspace is reported, never reset. Re-entering from a new CLI session adopts the workspace: roster entries the previous session spawned are retired (their agent ids cannot be resumed across sessions), while missions, worktrees, and the activity log carry over. Entering tower mode (`/tower on`) already runs this adoption; this tool stays the explicit way to re-assert it. -Takes an optional `base`: the local branch that every mission forks from and merges back into (default: the base the user enabled tower mode with via `/tower `, falling back to the branch currently checked out in the main worktree). It is recorded for the workspace's lifetime — missions, reviews, and the merge gate all evaluate against it — so choose it at init time; a re-init reporting an existing workspace keeps the recorded base. Only local branches are accepted: a remote-tracking ref such as "origin/main" cannot receive merges, so create a local branch for it first. When the base differs from the main checkout (or the checkout is detached), work proceeds normally but merges stay blocked until the checkout is switched to the base. +Takes an optional `base`: the local branch that every mission forks from and merges back into (default: the branch currently checked out in the main worktree; a fresh init on a detached HEAD requires an explicit `base`). Missions, reviews, and the merge gate all evaluate against the recorded base. A re-init with no explicit base keeps the recorded one; a re-init with an explicit different base re-anchors the workspace to it — allowed anytime, even with open missions. Each open mission is then reported as up-to-date (its branch already contains the new base), stale (it does not and needs TowerRebase before merging), or no-branch (no branch exists yet); only stale branches must rebase onto the new base before merging. Only local branches are accepted (a remote-tracking ref such as "origin/main" cannot receive merges) — create a local branch first. When the base differs from the main checkout (or the checkout is detached), work proceeds normally but merges stay blocked until the checkout is switched to the base. diff --git a/packages/agent-core-v2/src/features/tower/tools/init/init.ts b/packages/agent-core-v2/src/features/tower/tools/init/init.ts index 5e1931e79c0..f06294d5ab6 100644 --- a/packages/agent-core-v2/src/features/tower/tools/init/init.ts +++ b/packages/agent-core-v2/src/features/tower/tools/init/init.ts @@ -10,7 +10,7 @@ export const TowerInitToolInputSchema = z .min(1) .optional() .describe( - 'Local branch that missions fork from and merge back into (e.g. "develop"). Defaults to the branch currently checked out in the main worktree. Remote-tracking refs (e.g. "origin/main") and tags are not accepted — create a local branch first.', + 'Local branch that missions fork from and merge back into (e.g. "develop"). Defaults to the branch currently checked out in the main worktree. On an existing workspace an explicit base re-anchors it — allowed anytime, even with open missions, whose branches then rebase onto the new base before merging. Remote-tracking refs (e.g. "origin/main") and tags are not accepted — create a local branch first.', ), }) .strict(); diff --git a/packages/agent-core-v2/src/features/tower/tools/init/initTool.ts b/packages/agent-core-v2/src/features/tower/tools/init/initTool.ts index d6ebd0ab3de..8fadd14ac81 100644 --- a/packages/agent-core-v2/src/features/tower/tools/init/initTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/init/initTool.ts @@ -1,7 +1,10 @@ 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 { + TowerProtocolError, + type TowerReanchorMissionDigest, +} from '#/features/tower/protocol/index'; import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { toInputJsonSchema } from '#/tool/input-schema'; @@ -61,21 +64,19 @@ export class TowerInitTool implements ITowerInitTool { `tower workspace is owned by a live session (${priorOwner}) — adopting it would retire that session's roster. Use the tower from that session, or close it first.`, ); } - const result = await store.init( - this.sessionContext.sessionId, - args.base ?? this.tower.requestedBase, - ); + const result = await store.init(this.sessionContext.sessionId, args.base); return { output: [ result.created ? 'tower workspace initialized' : 'tower workspace already initialized — existing state preserved', `base branch: ${result.base}`, - ...(result.ignoredBase !== undefined + ...(result.rebasedFrom !== undefined ? [ - `requested base "${result.ignoredBase}" ignored — the existing workspace already records base "${result.base}"; tear it down first to rebase the tower`, + `base re-anchored from "${result.rebasedFrom}" to "${result.base}" — open missions now evaluate against "${result.base}"`, ] : []), + ...(result.rebaseDigest?.missions.map(reanchorMissionLine) ?? []), ...(result.checkout !== result.base ? [ result.checkout === 'HEAD' @@ -97,10 +98,22 @@ export class TowerInitTool implements ITowerInitTool { : []), '', 'Tower mode is active and the tower tool set is enabled.', - 'Next: split the work with TowerPlan (one mission per disjoint file scope), then TowerSpawn a worker per mission. Assign reviewers for their branches, and merge with TowerMerge only after a clean review.', + 'Next: split the work with TowerPlan (one mission per disjoint file scope), then TowerSpawn a worker per mission. Assign independent reviewers for their branches, and merge with TowerMerge only after a clean review with merge verdict "merge".', ].join('\n'), }; }), }; } } + +function reanchorMissionLine(mission: TowerReanchorMissionDigest): string { + const prefix = `mission ${mission.id} (${mission.branch}): ${mission.relation}`; + switch (mission.relation) { + case 'up-to-date': + return `${prefix} — the branch already contains the new base tip; no rebase is required by this re-anchor`; + case 'stale': + return `${prefix} — the new base is not an ancestor of this branch tip; run TowerRebase(mission="${mission.id}") before merging`; + case 'no-branch': + return `${prefix} — no branch exists yet; spawn first, then evaluate its relation before merging`; + } +} 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 da46a9f7cce..819790737ca 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,7 +1,7 @@ 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 merge also refuses when every mission record for the branch is closed (abandoned or already merged) — a merge never flips a historical mission's state; if the work must land, re-plan it under a fresh mission title. 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. +Hard gate, enforced by the store — the mission must be completed with no open tasks or blockers and every related worker observably idle; its latest applicable review must be "clean" with merge recommendation "merge" on the exact branch tip (`hold` and `fix-then-merge` are never approval; only a conflict-free TowerRebase can waive the tip binding), all dependency missions must be merged or abandoned, the branch must contain the current base tip (otherwise `stale-base` — run TowerRebase for the mission first), and every changed file must fall inside the mission's declared scope. Legacy clean reviews with no merge field retain their original compatibility. The merge also refuses when every mission record for the branch is closed (abandoned or already merged) — a merge never flips a historical mission's state; if the work must land, re-plan it under a fresh mission title. The scope diff starts from the mission's recorded spawn base while that spawn-time WIP snapshot commit is still in the branch's history, so the snapshot is not mistaken for a worker scope violation; once a rebase drops the snapshot (typically after the WIP lands on the base), the diff falls back to the base branch. On refusal, the error message tells you what to do next. After a merge, branches reported as conflicting must rebase onto the new base (TowerRebase, or the worker itself when it is mid-turn) before they can merge. If the git merge itself conflicts, it is aborted automatically — the checkout is never left mid-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. +The main checkout must be clean for the files the merge touches: uncommitted changes in any file the merge would overwrite refuse the merge, and nothing is merged — commit or stash those changes first, then retry. This matters especially when a mission branch carries a spawn-time WIP snapshot: those changes merge into the base history, so they must not also sit uncommitted in the checkout. When this merge closes the last open mission, the result reports the workspace ready for TowerTeardown. diff --git a/packages/agent-core-v2/src/features/tower/tools/merge/mergeTool.ts b/packages/agent-core-v2/src/features/tower/tools/merge/mergeTool.ts index c08bbcb954e..0a90e9a702d 100644 --- a/packages/agent-core-v2/src/features/tower/tools/merge/mergeTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/merge/mergeTool.ts @@ -1,4 +1,6 @@ import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentTaskService } from '#/agent/task/task'; +import { IAgentTowerService } from '#/features/tower/tower'; import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { toInputJsonSchema } from '#/tool/input-schema'; @@ -17,6 +19,8 @@ export class TowerMergeTool implements ITowerMergeTool { constructor( @ISessionContext private readonly sessionContext: ISessionContext, @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @IAgentTaskService private readonly tasks: IAgentTaskService, + @IAgentTowerService private readonly tower: IAgentTowerService, ) {} resolveExecution(args: TowerMergeToolInput): ToolExecution { @@ -32,7 +36,32 @@ export class TowerMergeTool implements ITowerMergeTool { execute: () => runTowerTool(async () => { const store = newTowerStore(this.sessionContext); - const { mergeCommit, conflictsWith, noop } = await store.merge(args.branch); + const result = await this.tower.withBranchLease(args.branch, () => + store.merge(args.branch, { activeAgentIds: () => this.activeAgentIds() }), + ); + const { + status, + mergeCommit, + conflictsWith, + evaluatedBranchTip, + currentBranchTip, + } = result; + if (status === 'advanced') { + if (result.noop === true) { + return { + isError: true, + output: `SURVEY NOT CLOSED: ${args.branch} advanced from the evaluated zero-diff tip ${evaluatedBranchTip} to ${currentBranchTip} before the mission could be closed — no git merge was performed and the mission was not marked merged; re-evaluate the new tip`, + }; + } + return { + isError: true, + output: [ + `PARTIAL MERGE: only the evaluated commit ${evaluatedBranchTip} of ${args.branch} was merged (merge commit ${mergeCommit}).`, + `${args.branch} advanced during the merge to ${currentBranchTip}; that newer tip and any commits after ${evaluatedBranchTip} were NOT merged.`, + 'The mission was not marked merged. Review the new exact tip, then retry TowerMerge; do not treat the partial merge as a completed mission merge.', + ].join('\n'), + }; + } const after = await store.load(); const allClosed = after.missions.length > 0 && @@ -41,7 +70,7 @@ export class TowerMergeTool implements ITowerMergeTool { ); const teardownHint = 'Every mission is now merged or abandoned — ready for TowerTeardown (branches and .tower/comms/ are kept; dirty worktrees are protected).'; - if (noop === true) { + if (status === 'noop') { return { output: [ `${args.branch} is a read-only survey with a zero-diff branch — mission marked merged, no git merge needed.`, @@ -62,7 +91,7 @@ export class TowerMergeTool implements ITowerMergeTool { ...conflictsWith.map( (conflict) => `- ${conflict.branch}: ${conflict.files.join(', ')}`, ), - 'Tell each affected worker (Agent resume with run_in_background=true — never foreground: their output flows back through the tower protocol files) to rebase onto the updated base, resolve, push, and request a re-review.', + 'For each affected mission, follow the next structured refusal: TowerRebase while its worker is idle (or ask the worker to rebase mid-turn). Conflict-free rebase preserves review binding; real conflicts must be resolved before another merge.', ); } else if (allClosed) { lines.push(`The mission is now marked merged. ${teardownHint}`); @@ -73,5 +102,15 @@ export class TowerMergeTool implements ITowerMergeTool { }), }; } + + private activeAgentIds(): ReadonlySet { + return new Set( + this.tasks + .list(true) + .flatMap((task) => + task.kind === 'agent' && task.agentId !== undefined ? [task.agentId] : [], + ), + ); + } } diff --git a/packages/agent-core-v2/src/features/tower/tools/mission/mission.md b/packages/agent-core-v2/src/features/tower/tools/mission/mission.md index 3cabbd543d6..911fd8be18b 100644 --- a/packages/agent-core-v2/src/features/tower/tools/mission/mission.md +++ b/packages/agent-core-v2/src/features/tower/tools/mission/mission.md @@ -2,6 +2,6 @@ Read or update a tower mission. With only an id, returns the mission view (status, tasks, blockers, notes). With patch fields, applies them: workers may only update the mission they own — the store rejects anything else. Use task_done to tick checklist items, task_drop (with task_drop_reason) to drop a legitimately descoped task — the reason is recorded in the mission notes and the activity log — note to log decisions, blocker when stuck (the tower watches for blocked missions). -Status lifecycle: planned → active → completed → merged. "completed" means awaiting review, not done — the branch can still be sent back for rework: when a review with a non-clean verdict lands on the mission's branch, the store flips a completed mission back to active automatically (merged never flips; planned, blocked, and paused are left alone). Only "merged" is final. The store refuses the transition to completed while the mission still has open (neither done nor dropped) tasks — the error lists them — and, for build missions, while the mission branch has no diff vs its base; survey missions are exempt from the diff check. A roster agent's own transition to completed is also refused while its inbox holds messages it has not read yet — the error names the unread count; read them with TowerInbox and incorporate anything new into the delivery first (the tower is exempt from this check). +Status lifecycle: planned → active → completed → merged. "completed" means awaiting review, not done — the branch can still be sent back for rework: when a review with a non-clean verdict lands on the mission's branch, the store flips a completed mission back to active automatically (merged never flips; planned, blocked, and paused are left alone). Neither workers nor the tower can set status=merged through this tool: only TowerMerge records merged after its merge gate succeeds. Merged and abandoned are both terminal, so a generic patch cannot reopen either one. The store refuses the transition to completed while the mission has blockers or still has open (neither done nor dropped) tasks — the error identifies them — and, for build missions, while the mission branch has no committed diff vs its base. A survey mission may complete without a branch, but an existing survey branch must be zero-diff. A roster agent's own transition to completed is also refused while its inbox holds messages it has not read yet — the error names the unread count; read every unread page with TowerInbox and incorporate anything new into the delivery first (the tower is exempt from this check). Workers normally finish with TowerComplete(report), which runs these checks and stores the standard notification in the same lock; a generic status=completed patch remains the low-level compatibility operation. -Tower only: status=abandoned gives a mission up without merging — its scope stops reserving files for TowerPlan, its dependents may merge, and its branch drops out of conflict checks. Use it for stale missions carried over from a previous session, or for work that will not land; abandoned missions stay in MISSIONS.md (🚫) as the audit trail. +Tower only: status=abandoned gives a mission up without merging — its scope stops reserving files for TowerPlan, its dependents may merge, and its branch drops out of conflict checks. Use it for stale missions carried over from a previous session, or for work that will not land; abandoned missions stay in MISSIONS.md (🚫) as the audit trail and cannot be reopened. diff --git a/packages/agent-core-v2/src/features/tower/tools/mission/mission.ts b/packages/agent-core-v2/src/features/tower/tools/mission/mission.ts index 8016934cc25..6d54c89ebb0 100644 --- a/packages/agent-core-v2/src/features/tower/tools/mission/mission.ts +++ b/packages/agent-core-v2/src/features/tower/tools/mission/mission.ts @@ -10,7 +10,7 @@ export const TowerMissionToolInputSchema = z .enum(['planned', 'active', 'completed', 'blocked', 'paused', 'merged', 'abandoned']) .optional() .describe( - 'New lifecycle status. "abandoned" is tower-only: it gives the mission up without merging — releasing its scope, satisfying its dependents, and excluding its branch from conflict checks.', + 'New lifecycle status. Workers may update only their own mission. "abandoned" is tower-only and gives the mission up without merging. "merged" cannot be set here; only TowerMerge records it after the merge gate succeeds. Merged and abandoned are terminal and cannot be reopened by a generic patch.', ), note: z.string().optional().describe('Append a decision-log note'), blocker: z.string().optional().describe('Report a blocker (also sets status to blocked)'), diff --git a/packages/agent-core-v2/src/features/tower/tools/plan/plan.md b/packages/agent-core-v2/src/features/tower/tools/plan/plan.md index ed4fc56b4b5..d7e72d8f47a 100644 --- a/packages/agent-core-v2/src/features/tower/tools/plan/plan.md +++ b/packages/agent-core-v2/src/features/tower/tools/plan/plan.md @@ -1,5 +1,5 @@ Split the tower goal into missions. Each mission gets an id (M1, M2, …), a branch (feat/), and an isolated git worktree (.tower/worktrees/wt-N). -Write tasks as verifiable check items — the worker ticks them off, the completion report reconciles against them item by item, and the reviewer maps every one to the diff. When the user's own words carry intent your paraphrase could lose, copy the key sentences into `context` verbatim (when in doubt, include it): context supplements your paraphrase, never replaces it, travels with the mission into the worker and reviewer briefings, and is never the full conversation history. +Write tasks as verifiable check items — the worker ticks them off, the completion report reconciles against them item by item, and the reviewer maps every one to the diff. When the user's own words carry intent your paraphrase could lose, copy the key sentences into `context` verbatim (when in doubt, include it): context supplements your paraphrase, never replaces it, and travels with the mission into the worker and reviewer briefings. Do not paste the full conversation history into it. -Rules enforced by the store: scopes of build missions must be pairwise disjoint (survey missions are read-only and reserve no scope), deps must reference existing mission ids, and mission branches must be unique — a title whose slugged branch collides with any existing mission's branch (including abandoned or merged ones) or with an unowned local git branch is rejected, so rename the title and plan again. Titles must be printable ASCII English: a title containing any non-ASCII character (CJK, Cyrillic, Hangul, accented letters) is rejected outright, because non-ASCII text slugs to a generic word like "item" that collides across missions, while printable ASCII punctuation (spaces, "-", "_", "+") folds into the slug without losing words — write the title in English and include a unique identifier word (e.g. a business code like B010100). Plan once, then spawn one worker per mission with TowerSpawn. Requires an active tower workspace (run TowerInit first). +Rules enforced by the store: scopes of build missions must be pairwise disjoint or serialized by a dependency — when scopes overlap (a shared central config, a lockfile), the store auto-adds a dependency from the later mission to the earlier one so their merges land in order instead of colliding, and records it in the mission notes; survey missions are read-only and reserve no scope; deps must reference existing mission ids; and mission branches must be unique — a title whose slugged branch collides with any existing mission's branch (including abandoned or merged ones) or with an unowned local git branch is rejected, so rename the title and plan again. Titles must be printable ASCII English: a title containing any non-ASCII character (CJK, Cyrillic, Hangul, accented letters) is rejected outright, because non-ASCII text slugs to a generic word like "item" that collides across missions, while printable ASCII punctuation (spaces, "-", "_", "+") folds into the slug without losing words — include a unique identifier word (e.g. a business code like B010100). Plan once, then spawn one worker per mission with TowerSpawn. Requires an active tower workspace (run TowerInit first). diff --git a/packages/agent-core-v2/src/features/tower/tools/plan/plan.ts b/packages/agent-core-v2/src/features/tower/tools/plan/plan.ts index f55493700c7..6a4ff070f09 100644 --- a/packages/agent-core-v2/src/features/tower/tools/plan/plan.ts +++ b/packages/agent-core-v2/src/features/tower/tools/plan/plan.ts @@ -18,7 +18,7 @@ export const TowerPlanToolInputSchema = z .array(z.string()) .min(1) .describe( - 'Files/globs this mission may touch (e.g. "src/build/**"). Scopes of different missions must not overlap.', + 'Files/globs this mission may touch (e.g. "src/build/**"). Scopes of different build missions should be disjoint; when they overlap, the store auto-adds a dependency that serializes their merges.', ), tasks: z .array(z.string()) diff --git a/packages/agent-core-v2/src/features/tower/tools/rebase/rebase.md b/packages/agent-core-v2/src/features/tower/tools/rebase/rebase.md new file mode 100644 index 00000000000..7b1dd67cb8a --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/rebase/rebase.md @@ -0,0 +1,9 @@ +Rebase a mission branch onto the current base branch, inside the mission's own worktree. + +The tower uses this when `TowerMerge` refuses with `stale-base` (the base moved after the branch spawned) or after a merge reports branches that now conflict with the base. The rebase runs in the mission worktree on the base branch's latest commit: + +- Refuses while the mission's worker is mid-turn or the worktree has uncommitted changes — rebasing under a running worker could race its edits. +- Clean rebase → the branch tip moves; the old review keeps its exact-tip binding, but merge approval still requires a clean review with merge recommendation "merge" (never `hold` or `fix-then-merge`). +- Conflict → only a rebase left in progress with actual unmerged paths is classified as a conflict: it is aborted with recovery postconditions checked, the files are reported, and the mission is marked blocked for the worker. Other errors are not mislabeled as conflicts; failed abort or recovery is reported as recovery-required. + +A rebase that drops the spawn-time WIP snapshot commit is expected — the diff then falls back to the base branch. Only the tower runs this tool; a worker rebases only when the tower asks it to. diff --git a/packages/agent-core-v2/src/features/tower/tools/rebase/rebase.ts b/packages/agent-core-v2/src/features/tower/tools/rebase/rebase.ts new file mode 100644 index 00000000000..1de07f49d7f --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/rebase/rebase.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const TowerRebaseToolInputSchema = z + .object({ + mission: z + .string() + .describe('Mission id (e.g. "M3") whose branch should be rebased onto the current base'), + }) + .strict(); + +export type TowerRebaseToolInput = z.infer; + +export interface ITowerRebaseTool extends AgentTool { + readonly _serviceBrand: undefined; +} +export const ITowerRebaseTool = createDecorator('towerRebaseTool'); diff --git a/packages/agent-core-v2/src/features/tower/tools/rebase/rebaseTool.ts b/packages/agent-core-v2/src/features/tower/tools/rebase/rebaseTool.ts new file mode 100644 index 00000000000..25a65fbc989 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/rebase/rebaseTool.ts @@ -0,0 +1,86 @@ +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentTaskService } from '#/agent/task/task'; +import { IAgentTowerService } from '#/features/tower/tower'; +import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import type { ToolExecution } from '#/tool/toolContract'; + +import { TOWER_NAME } from '#/features/tower/protocol/index'; +import { newTowerStore, runTowerTool, TOWER_MAIN_AGENT_ONLY } from '../support'; +import DESCRIPTION from './rebase.md?raw'; +import { ITowerRebaseTool, TowerRebaseToolInputSchema, type TowerRebaseToolInput } from './rebase'; + +export class TowerRebaseTool implements ITowerRebaseTool { + declare readonly _serviceBrand: undefined; + readonly name = 'TowerRebase' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(TowerRebaseToolInputSchema); + + constructor( + @ISessionContext private readonly sessionContext: ISessionContext, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @IAgentTaskService private readonly tasks: IAgentTaskService, + @IAgentTowerService private readonly tower: IAgentTowerService, + ) {} + + resolveExecution(args: TowerRebaseToolInput): ToolExecution { + if (this.scopeContext.agentId !== MAIN_AGENT_ID) { + return { + isError: true, + output: TOWER_MAIN_AGENT_ONLY, + }; + } + return { + description: `Rebasing tower mission onto base: ${args.mission}`, + approvalRule: this.name, + execute: () => + runTowerTool(async () => { + const store = newTowerStore(this.sessionContext); + const state = await store.load(); + const mission = state.missions.find((candidate) => candidate.id === args.mission.trim()); + if (mission === undefined) { + return { + isError: true, + output: `unknown mission "${args.mission}" — known: ${state.missions.map((candidate) => candidate.id).join(', ') || '(none)'}`, + }; + } + const result = await this.tower.withBranchLease(mission.branch, () => + store.rebaseMission(TOWER_NAME, mission.id, { + activeAgentIds: () => this.activeAgentIds(), + }), + ); + if (result.status === 'up-to-date') { + return { + output: `mission ${mission.id} (${mission.branch}) is already up to date with base "${state.base}" — retry TowerMerge; if it was refused for another reason, that refusal tells you what is missing`, + }; + } + if (result.status === 'conflict') { + return { + output: [ + `rebase of mission ${mission.id} (${mission.branch}) onto "${state.base}" conflicted and was aborted — the mission is marked blocked.`, + `conflicted files: ${result.files?.join(', ') || '(none reported)'}`, + `resume the worker in the background (foreground only when background task tools are unavailable) so it resolves the conflicts in its worktree and requests a fresh review.`, + ].join('\n'), + }; + } + return { + output: [ + `rebased mission ${mission.id} (${mission.branch}) onto "${state.base}": ${result.fromCommit!.slice(0, 7)} → ${result.toCommit!.slice(0, 7)}.`, + 'The pre-rebase review keeps its exact-tip binding, but merge approval still requires a clean review with merge verdict "merge" — retry TowerMerge.', + ].join('\n'), + }; + }), + }; + } + + private activeAgentIds(): ReadonlySet { + return new Set( + this.tasks + .list(true) + .flatMap((task) => + task.kind === 'agent' && task.agentId !== undefined ? [task.agentId] : [], + ), + ); + } +} diff --git a/packages/agent-core-v2/src/features/tower/tools/review/review.md b/packages/agent-core-v2/src/features/tower/tools/review/review.md index df8841ee556..f88e98cc55c 100644 --- a/packages/agent-core-v2/src/features/tower/tools/review/review.md +++ b/packages/agent-core-v2/src/features/tower/tools/review/review.md @@ -1,5 +1,7 @@ Submit a review verdict for a branch you were assigned to review (via TowerSpawn review_target). -The review is stamped with the current branch tip — if the branch moves afterwards, the tower must ask for a re-review before merging. Only reviewers assigned to the target (or the tower) may submit; the round number is assigned automatically. +The review is stamped with the current branch tip — if the branch moves afterwards, the tower must ask for a re-review before merging. Normal merge-bound reviews come from an assigned independent reviewer. A tower-submitted review is exceptional break-glass and must explicitly record reviewer=tower and the reason; it is not a convenience bypass. Only those reviewers (or the tower in that exceptional case) may submit; the round number is assigned automatically. -The submission result routes the next step: on a non-clean verdict it names the roster worker to resume (with this review file) so the findings get fixed and re-reviewed; on a clean verdict it reports the branch merge-ready for TowerMerge. +The tool stores the standard review-result notifications in the same protocol flow: clean verdicts notify the tower; non-clean verdicts also notify a valid owner of an open mission, with a supervisory copy to the tower. Closed missions never wake their former author. Do not send a second manual TowerSend. + +The result lists the review file and actual recipients, then reports gate semantics: only a clean review with merge=merge on the exact tip is merge-ready; hold and fix-then-merge are not approval. diff --git a/packages/agent-core-v2/src/features/tower/tools/review/reviewTool.ts b/packages/agent-core-v2/src/features/tower/tools/review/reviewTool.ts index 57c8c5ad958..bded026f2be 100644 --- a/packages/agent-core-v2/src/features/tower/tools/review/reviewTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/review/reviewTool.ts @@ -1,11 +1,13 @@ -import { IAgentScopeContext, agentContextOfScope } from '#/agent/scopeContext/scopeContext'; -import { resolveMissionByBranch } from '#/features/tower/protocol/index'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentTaskService } from '#/agent/task/task'; +import { ISessionEventBus } from '#/app/event/eventBus'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import { ISessionUsageService } from '#/session/usage/sessionUsage'; import { toInputJsonSchema } from '#/tool/input-schema'; import type { ToolExecution } from '#/tool/toolContract'; -import { callerName, callerTokens, newTowerStore, runTowerTool } from '../support'; +import { deliverTowerMessage } from '../delivery'; +import { callerName, newTowerStore, runTowerTool } from '../support'; import DESCRIPTION from './review.md?raw'; import { ITowerReviewTool, @@ -22,7 +24,9 @@ export class TowerReviewTool implements ITowerReviewTool { constructor( @ISessionContext private readonly sessionContext: ISessionContext, @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, - @ISessionUsageService private readonly usage: ISessionUsageService, + @ISessionEventBus private readonly sessionBus: ISessionEventBus, + @IAgentTaskService private readonly tasks: IAgentTaskService, + @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, ) {} resolveExecution(args: TowerReviewToolInput): ToolExecution { @@ -34,37 +38,61 @@ export class TowerReviewTool implements ITowerReviewTool { const store = newTowerStore(this.sessionContext); const state = await store.load(); const caller = callerName(this.scopeContext.agentId, store, state); - const rel = await store.submitReview(caller, { + const result = await store.submitReview(caller, { target: args.target, status: args.status, merge: args.merge, findings: args.findings, checks: args.checks, decision: args.decision, - tokens: callerTokens(this.usage, agentContextOfScope(this.scopeContext)), }); - const lines = [`review submitted: ${rel}`]; - if (args.status === 'clean') { + const delivery = result.storedMessages + .map((message) => + deliverTowerMessage( + { + sessionBus: this.sessionBus, + tasks: this.tasks, + agentLifecycle: this.agentLifecycle, + state, + }, + message, + ), + ) + .join(''); + const notified = result.storedMessages.map((message) => message.item.to); + const lines = [ + `review submitted: ${result.review.file}`, + `notified: ${notified.length > 0 ? notified.join(', ') : '(none)'}`, + ]; + if (result.notificationError !== undefined) { + lines.push(`notification error: ${result.notificationError}`); + } + if (result.activityLogError !== undefined) { + lines.push(`activity log error: ${result.activityLogError}`); + } + if (args.status === 'clean' && args.merge === 'merge') { lines.push( `next: ${args.target} is merge-ready — the tower can TowerMerge it in Dependency Flow order.`, ); + } else if (args.status === 'clean') { + lines.push( + args.merge === 'hold' + ? `next: ${args.target} is blocked by the latest review's "hold" verdict — the merge gate rejects it; do not TowerMerge until a later clean review with merge verdict "merge" supersedes it.` + : `next: ${args.target} is not merge-approved — "fix-then-merge" is not approval; the merge gate requires a clean review with merge verdict "merge" before TowerMerge.`, + ); } else { - const owner = resolveMissionByBranch(state, args.target)?.owner; - const worker = owner === undefined ? undefined : store.findAgent(state, owner); - if (owner !== undefined && worker !== undefined) { - lines.push( - `next: resume ${owner} with this review file (${rel}): Agent(resume="${worker.agentId}", run_in_background=true, prompt="...") — never foreground: its output flows back through the tower protocol files. The branch must be fixed and re-reviewed before it can merge.`, - ); - } else { - lines.push( - `next: no worker on record owns ${args.target} — route this review file (${rel}) through the tower so it can reassign the fixes.`, - ); - } + lines.push( + `next: ${args.target} is not merge-ready — the gate requires a clean review with merge verdict "merge" on the exact tip; the author must fix and re-review.`, + ); } - lines.push( - 'Also notify the branch author (or the tower) with TowerSend so the verdict is seen.', - ); - return { output: lines.join('\n') }; + if (delivery.length > 0) lines.push(delivery); + return { + output: lines.join('\n'), + isError: + result.notificationError === undefined && result.activityLogError === undefined + ? undefined + : true, + }; }), }; } diff --git a/packages/agent-core-v2/src/features/tower/tools/send/send.md b/packages/agent-core-v2/src/features/tower/tools/send/send.md index d6ff9072393..2aac87aa4a1 100644 --- a/packages/agent-core-v2/src/features/tower/tools/send/send.md +++ b/packages/agent-core-v2/src/features/tower/tools/send/send.md @@ -1,3 +1,7 @@ Send an inbox message to a tower participant: a roster agent by name, "tower" (the control tower), or "all" (broadcast). Recipients read it with TowerInbox. Sending to yourself or to an unknown name is rejected — the error lists the known names. + +Delivery is push when the recipient is a roster agent mid-turn: the message is steered into its running turn as an injected directive, so it sees the new message at its next step boundary — the in-flight tool call finishes first, steering never aborts it — instead of at its next self-initiated inbox check. The tool result reports whether the message was steered, sits in the inbox of an idle agent, or is undeliverable (no running task — resume the agent to deliver it). If a directive genuinely cannot wait, TaskStop the recipient's task and resume it with the message instead — word the subject accordingly (e.g. "URGENT: stop and rebase") so the steer preview carries the priority. TowerComplete and TowerReview store their standard notifications automatically; do not repeat those with a manual TowerSend. + +If the tower state was lost (.tower/comms deleted while .tower/ survives), the send first recovers a minimal state stamped recoveredAt instead of failing, then delivers normally; a caller whose roster entry was lost is re-registered as a placeholder named after its agent id, and the result notes it. diff --git a/packages/agent-core-v2/src/features/tower/tools/send/sendTool.ts b/packages/agent-core-v2/src/features/tower/tools/send/sendTool.ts index 2bcabc41900..486aea002dd 100644 --- a/packages/agent-core-v2/src/features/tower/tools/send/sendTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/send/sendTool.ts @@ -1,14 +1,13 @@ -import { IAgentScopeContext, agentContextOfScope } from '#/agent/scopeContext/scopeContext'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentTaskService } from '#/agent/task/task'; import { ISessionEventBus } from '#/app/event/eventBus'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import { ISessionUsageService } from '#/session/usage/sessionUsage'; import { toInputJsonSchema } from '#/tool/input-schema'; import type { ToolExecution } from '#/tool/toolContract'; -import { BROADCAST_NAME, TOWER_NAME } from '#/features/tower/protocol/index'; -import { TowerInboxSent } from '#/features/tower/towerOps'; -import { callerName, callerTokens, newTowerStore, runTowerTool } from '../support'; +import { deliverTowerMessage } from '../delivery'; +import { newTowerStore, runTowerTool } from '../support'; import DESCRIPTION from './send.md?raw'; import { ITowerSendTool, TowerSendToolInputSchema, type TowerSendToolInput } from './send'; @@ -23,7 +22,7 @@ export class TowerSendTool implements ITowerSendTool { @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @ISessionEventBus private readonly sessionBus: ISessionEventBus, @IAgentTaskService private readonly tasks: IAgentTaskService, - @ISessionUsageService private readonly usage: ISessionUsageService, + @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, ) {} resolveExecution(args: TowerSendToolInput): ToolExecution { @@ -33,39 +32,42 @@ export class TowerSendTool implements ITowerSendTool { execute: () => runTowerTool(async () => { const store = newTowerStore(this.sessionContext); - const state = await store.load(); - const caller = callerName(this.scopeContext.agentId, store, state); + const resolved = await store.resolveMessagingCaller( + await store.loadOrRecover(), + this.scopeContext.agentId, + ); + const caller = resolved.caller; const to = args.to.trim(); - const rel = await store.send(caller, { + const sent = await store.sendDetailed(caller, { to, subject: args.subject, body: args.body, scope: args.scope, action: args.action, consentRef: args.consent_ref, - tokens: callerTokens(this.usage, agentContextOfScope(this.scopeContext)), }); - if ( - this.sessionBus !== undefined && - caller !== TOWER_NAME && - (to === TOWER_NAME || to === BROADCAST_NAME) - ) { - this.sessionBus.publish(new TowerInboxSent({ from: caller, to, subject: args.subject })); + const delivery = deliverTowerMessage( + { + sessionBus: this.sessionBus, + tasks: this.tasks, + agentLifecycle: this.agentLifecycle, + state: resolved.state, + }, + sent, + ); + const lines = [`message sent to ${args.to}\nfile: ${sent.item.file}${delivery}`]; + if (resolved.placeholder === true) { + lines.push( + `note: the tower state was recovered after a loss, so your roster entry was reconstructed as placeholder "${caller}" — your original name and mission assignment are gone; the recovered state's recoveredAt marker reminds the tower to report the history loss to the user`, + ); } - const entry = - caller === TOWER_NAME && to !== TOWER_NAME && to !== BROADCAST_NAME - ? state.roster.agents.find((agent) => agent.name === to) - : undefined; - const undelivered = - entry !== undefined && - this.tasks !== undefined && - !this.tasks - .list(true) - .some((task) => task.kind === 'agent' && task.agentId === entry.agentId); - const note = undelivered - ? `\nnote: ${to} has no running task in this session — the message sits in its inbox until you deliver it with Agent(resume="${entry.agentId}", run_in_background=true, prompt="...")` - : ''; - return { output: `message sent to ${args.to}\nfile: ${rel}${note}` }; + if (sent.activityLogError !== undefined) { + lines.push(`activity log error: ${sent.activityLogError}`); + } + return { + output: lines.join('\n'), + isError: sent.activityLogError === undefined ? undefined : true, + }; }), }; } 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 25aff42ae82..cf22cf61228 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,8 +2,8 @@ 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 — when the branch belongs to a mission, the briefing carries the full mission text (title, tasks, and any verbatim user context) plus the author's own review-request when one is on file, so the review verifies intent against the mission, not only code health; the agent must submit its verdict via TowerReview. -When the branch already has history, the briefing carries it too: a worker respawned onto a reviewed branch gets the latest review round (verdict, findings, decision) and the previous worker's review-request, so rework continues from the verdict instead of starting cold; a reviewer gets the branch's earlier review rounds, so repeat findings are verified against the current tip rather than reported again. First spawns have no history sections. Note that a non-clean verdict flips a completed mission back to active — "completed" means awaiting review; only "merged" is final. +When the branch already has history, the briefing carries it too: a worker respawned onto a reviewed branch gets the latest review round (verdict, findings, decision) and the previous worker's review-request, so rework continues from the verdict instead of starting cold; a reviewer gets the branch's earlier review rounds, so repeat findings are verified against the current tip rather than reported again. First spawns have no history sections. Note that a non-clean verdict flips a completed mission back to active — "completed" means awaiting review, while "merged" and "abandoned" are terminal. 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 Agent(resume=..., run_in_background=true, prompt="...") instead of spawning a duplicate — never foreground: you never need the agent's return value inline; its output flows back through the tower protocol files. +The briefing prompt is assembled by this tool (worktree path, scope, protocol rules); use instructions only for extra context. If a worker name is already registered, resume it with Agent(resume=..., run_in_background=true, prompt="...") instead of spawning a duplicate; foreground resume is the fallback only when background task tools are unavailable. Reviewers are one-shot: after diagnosing the previous round, give a fresh reviewer a new name instead of resuming a roster agent. Normal merge-bound reviews use this independent reviewer path. 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 171f93dbf67..0b6a32f6006 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 @@ -122,6 +122,7 @@ export class TowerSpawnTool implements ITowerSpawnTool { args: TowerSpawnToolInput, { toolCallId }: ExecutableToolContext, ): Promise { + let pendingSpawnAbort: AbortController | undefined; try { if (!this.tower.isActive) { return { @@ -151,12 +152,13 @@ export class TowerSpawnTool implements ITowerSpawnTool { return { output: `tower agent "${args.name}" is already registered (agent_id: ${existing.agentId}, kind: ${existing.kind}) — ` + - `resume it instead of spawning a duplicate: Agent(resume="${existing.agentId}", run_in_background=true, prompt="...") — never foreground: its output flows back through the tower protocol files`, + (existing.kind === 'worker' + ? `resume it instead of spawning a duplicate: Agent(resume="${existing.agentId}", run_in_background=true, prompt="...") — foreground is only the fallback when background task tools are unavailable` + : 'a reviewer is one-shot: diagnose the previous round and spawn a fresh reviewer with a new name instead of resuming a roster agent'), isError: true, }; } - const notes: string[] = []; let mission: TowerMission | undefined; let reviewTarget: string | undefined; if (args.kind === 'worker') { @@ -164,28 +166,19 @@ export class TowerSpawnTool implements ITowerSpawnTool { if (missionId === undefined) { return { output: 'worker spawns require mission_id', isError: true }; } - mission = state.missions.find((m) => m.id === missionId); + mission = state.missions.find((candidate) => candidate.id === missionId); if (mission === undefined) { - const known = state.missions.map((m) => m.id).join(', '); + const known = state.missions.map((candidate) => candidate.id).join(', '); return { output: `unknown mission "${missionId}" — known missions: ${known.length > 0 ? known : '(none planned yet)'}`, isError: true, }; } - try { - 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) { - if (error instanceof TowerProtocolError) throw error; - notes.push( - `worktree setup warning (continuing): ${error instanceof Error ? error.message : String(error)}`, - ); + if (mission.status === 'merged' || mission.status === 'abandoned') { + return { + output: `mission ${mission.id} is ${mission.status} — terminal missions cannot spawn workers; no worktree or agent was created`, + isError: true, + }; } } else { reviewTarget = args.review_target; @@ -193,147 +186,181 @@ export class TowerSpawnTool implements ITowerSpawnTool { return { output: 'reviewer spawns require review_target', isError: true }; } } - - const reviewMission = - reviewTarget !== undefined ? resolveMissionByBranch(state, reviewTarget) : undefined; - const prompt = await this.buildPrompt( - args, - store, - state, - mission, - reviewTarget, - reviewMission, - ); - const description = - mission !== undefined - ? `${mission.id} ${args.name}: ${mission.title}` - : reviewMission !== undefined - ? `${reviewMission.id} review: ${reviewTarget ?? ''}` - : `review ${args.name}: ${reviewTarget ?? ''}`; - - const gate = this.rateLimit.acquire(); - if (!gate.ok) { - return { output: gate.reason, isError: true }; + const leaseBranch = mission?.branch ?? reviewTarget; + if (leaseBranch === undefined) { + throw new TowerProtocolError('tower spawn could not resolve the branch lease'); } - let slotHeld = true; - try { - const controller = new AbortController(); - const own = this.profile.data(); - const binding = - own.modelAlias === undefined - ? undefined - : resolveSubagentBinding( - this.config, - { modelAlias: own.modelAlias, thinkingLevel: own.thinkingLevel }, - args.kind === 'reviewer' && !isSubagentModelForced(this.config) - ? 'primary' - : undefined, - ); - let handle: SubagentHandle; - try { - handle = await this.launch(prompt, description, toolCallId, controller, binding); - } catch (error) { - return { - output: `tower spawn failed: ${error instanceof Error ? error.message : String(error)}`, - isError: true, - }; - } - let taskId: string; - try { - taskId = this.tasks.registerTask(new SubagentTask(handle, description, controller), { - detached: true, - timeoutMs: resolveSubagentTimeoutMs(this.config), - signal: undefined, - }); - } catch (error) { - controller.abort(); - void handle.completion.catch(() => {}); - return { - output: error instanceof Error ? error.message : String(error), - isError: true, - }; + return await this.tower.withBranchLease(leaseBranch, async () => { + const notes: string[] = []; + if (mission !== undefined) { + try { + 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) { + if (error instanceof TowerProtocolError) throw error; + notes.push( + `worktree setup warning (continuing): ${error instanceof Error ? error.message : String(error)}`, + ); + } } - void handle.completion - .catch(() => {}) - .finally(() => { - this.rateLimit.release(); - }); - slotHeld = false; - await store.registerAgent({ - name: args.name, - agentId: handle.agentId, - sessionId: this.sessionContext.sessionId, - kind: args.kind, - missionId: mission?.id, + const reviewMission = + reviewTarget !== undefined ? resolveMissionByBranch(state, reviewTarget) : undefined; + const prompt = await this.buildPrompt( + args, + store, + state, + mission, reviewTarget, - reviewMissionId: reviewMission?.id, - worktree: mission?.worktree, - branch: mission?.branch, - spawnedAt: new Date().toISOString(), - }); - const settled = this.tasks.getTask(taskId); - if ( - settled !== undefined && - isAgentTaskTerminal(settled.status) && - settled.status !== 'completed' - ) { - await store.markAgentDied( - handle.agentId, - settled.status, - settled.stopReason, - this.sessionContext.sessionId, - ); - } - if (mission !== undefined) { - await store.updateMission( - TOWER_NAME, - mission.id, - { status: 'active', owner: args.name }, - { silent: true }, - ); + reviewMission, + ); + const description = + mission !== undefined + ? `${mission.id} ${args.name}: ${mission.title}` + : reviewMission !== undefined + ? `${reviewMission.id} review: ${reviewTarget ?? ''}` + : `review ${args.name}: ${reviewTarget ?? ''}`; + + const gate = this.rateLimit.acquire(); + if (!gate.ok) { + return { output: gate.reason, isError: true }; } - await store.appendLog( - TOWER_NAME, - 'spawn', - { + let slotHeld = true; + try { + const controller = new AbortController(); + const own = this.profile.data(); + const binding = + own.modelAlias === undefined + ? undefined + : resolveSubagentBinding( + this.config, + { modelAlias: own.modelAlias, thinkingLevel: own.thinkingLevel }, + args.kind === 'reviewer' && !isSubagentModelForced(this.config) + ? 'primary' + : undefined, + ); + let handle: SubagentHandle; + try { + handle = await this.launch(prompt, description, toolCallId, controller, binding); + } catch (error) { + return { + output: `tower spawn failed: ${error instanceof Error ? error.message : String(error)}`, + isError: true, + }; + } + + let taskId: string; + try { + taskId = this.tasks.registerTask(new SubagentTask(handle, description, controller), { + detached: true, + timeoutMs: resolveSubagentTimeoutMs(this.config), + signal: undefined, + }); + } catch (error) { + controller.abort(); + void handle.completion.catch(() => {}); + return { + output: error instanceof Error ? error.message : String(error), + isError: true, + }; + } + pendingSpawnAbort = controller; + void handle.completion + .catch(() => {}) + .finally(() => { + this.rateLimit.release(); + }); + slotHeld = false; + + await store.registerAgent({ name: args.name, + agentId: handle.agentId, + sessionId: this.sessionContext.sessionId, kind: args.kind, - agent: handle.agentId, - mission: mission?.id, - target: reviewTarget, - model: binding?.model, - }, - mission !== undefined - ? join(MISSIONS_DIR, missionFileName(mission.id, mission.slug)) - : undefined, - ); + missionId: mission?.id, + reviewTarget, + reviewMissionId: reviewMission?.id, + worktree: mission?.worktree, + branch: mission?.branch, + spawnedAt: new Date().toISOString(), + }); + const settled = this.tasks.getTask(taskId); + if ( + settled !== undefined && + isAgentTaskTerminal(settled.status) && + settled.status !== 'completed' + ) { + await store.markAgentDied( + handle.agentId, + settled.status, + settled.stopReason, + this.sessionContext.sessionId, + ); + } + if (mission !== undefined) { + await store.updateMission( + TOWER_NAME, + mission.id, + { status: 'active', owner: args.name }, + { silent: true }, + ); + } + await store.appendLog( + TOWER_NAME, + 'spawn', + { + name: args.name, + kind: args.kind, + agent: handle.agentId, + mission: mission?.id, + target: reviewTarget, + model: binding?.model, + }, + mission !== undefined + ? join(MISSIONS_DIR, missionFileName(mission.id, mission.slug)) + : undefined, + ); - return { - output: [ - `name: ${args.name}`, - `kind: ${args.kind}`, - `agent_id: ${handle.agentId}`, - `task_id: ${taskId}`, - 'status: running', - ...(binding !== undefined ? [`model: ${binding.model}`] : []), - ...(mission !== undefined - ? [ - `mission: ${mission.id} — ${mission.title}`, - `branch: ${mission.branch}`, - `worktree: ${store.abs(join(WORKTREES_DIR, mission.worktree))}`, - ] - : [`review_target: ${reviewTarget ?? ''}`]), - ...notes, - '', - `The ${args.kind} runs detached in the background; its completion arrives as a notification. Track progress with TowerStatus / TowerInbox. If it dies, diagnose first: check why it died (the died entry's status/reason, its task state) before reviving. Resume with Agent(resume="${handle.agentId}", run_in_background=true, prompt="...") — never foreground: its output flows back through the tower protocol files — or reassign its work, but only when the cause is transient (lost contact, timeout, OOM); a systematic cause (code or environment defect) is fixed or escalated to the human before any revive.`, - ].join('\n'), - }; - } finally { - if (slotHeld) this.rateLimit.release(); - } + return { + output: [ + `name: ${args.name}`, + `kind: ${args.kind}`, + `agent_id: ${handle.agentId}`, + `task_id: ${taskId}`, + 'status: running', + ...(binding !== undefined ? [`model: ${binding.model}`] : []), + ...(mission !== undefined + ? [ + `mission: ${mission.id} — ${mission.title}`, + `branch: ${mission.branch}`, + `worktree: ${store.abs(join(WORKTREES_DIR, mission.worktree))}`, + ] + : [`review_target: ${reviewTarget ?? ''}`]), + ...notes, + '', + args.kind === 'worker' + ? `The worker runs detached in the background; its completion arrives as a notification. Use TowerInbox/TowerStatus only when a decision needs them. If it dies, diagnose first: check why it died (the died entry's status/reason, its task state) before reviving. Resume with Agent(resume="${handle.agentId}", run_in_background=true, prompt="...") (foreground only when background task tools are unavailable) or reassign its work only for a transient cause (lost contact, timeout, OOM); a systematic cause (code or environment defect) is fixed or escalated to the human before any revive. A user-stopped worker is never resumed, reassigned, or replaced.` + : 'The reviewer runs detached in the background; its completion arrives as a notification. If it dies, diagnose first: check the death status/reason and task state. A reviewer is one-shot — do not resume it or another roster agent; after diagnosis, spawn a fresh reviewer only if the mission still needs one. A user-stopped reviewer is never replaced.', + ].join('\n'), + }; + } finally { + if (slotHeld) this.rateLimit.release(); + } + }); } catch (error) { + pendingSpawnAbort?.abort(error); if (error instanceof TowerProtocolError || error instanceof GitError) { return { output: error.message, isError: true }; } @@ -429,7 +456,7 @@ export class TowerSpawnTool implements ITowerSpawnTool { (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; reads are unrestricted — you may read the main checkout (${store.repoRoot}) or another agent's worktree when coordination calls for it, but write only inside your own.\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; reads are unrestricted — you may read the main checkout (${store.repoRoot}) or another agent's worktree when coordination calls for it.\n` + (mission.kind === 'survey' ? `- Scope — what you investigate (read-only; reserves nothing): ${mission.scope.join(', ')}\n\n` : `- Scope — the only files you may change: ${mission.scope.join(', ')}\n\n`); @@ -442,14 +469,11 @@ export class TowerSpawnTool implements ITowerSpawnTool { '- Your scope marks what you investigate, not what you may change. You MUST NOT modify, add, or delete any file in the repo, and your branch must end with zero commits — a changed file makes the merge gate reject your mission as a read-only violation.\n' + '- Your deliverables are knowledge: record findings as TowerMission notes, send summaries to the tower and to dependent agents with TowerSend, and file TowerFinding for out-of-scope discoveries.\n\n' + `# Communication protocol\n` + - '- Coordinate through tower tools ONLY: TowerSend / TowerInbox / TowerFinding / TowerMission / TowerStatus. Reach the tower and sibling agents with TowerSend; check TowerInbox regularly.\n' + - '- NEVER create or edit files under `.tower/` by hand — the tools are the only writers.\n' + '- Ambiguity is escalated, not guessed: if the mission leaves substantive doubt about what to investigate, TowerSend(to="tower", subject="clarify-request", body=what needs pinning down) BEFORE acting — the tower relays to the human; you never ask the user directly.\n\n' + `# When the survey is done\n` + - '1. Call TowerInbox once and fold anything new into your summary — the store refuses status="completed" while unread messages wait in your inbox.\n' + - `2. Mark the mission completed: TowerMission(id="${mission.id}", status="completed").\n` + - '3. Send the tower your summary: TowerSend(to="tower", subject="survey-summary", body=the full survey result).\n' + - '4. Finish with a structured final summary: what you covered, key facts with file:line references, open questions.' + + '1. Call TowerInbox once and fold anything new into your summary — TowerComplete refuses while unread messages wait in your inbox.\n' + + '2. Call TowerComplete(report=the full survey result). It records completed and stores survey-summary to the tower in one protocol flow — do not substitute a TowerMission status patch or a manual TowerSend.\n' + + '3. Finish with a structured final summary: what you covered, key facts with file:line references, open questions.' + extra ); } @@ -460,18 +484,14 @@ export class TowerSpawnTool implements ITowerSpawnTool { `# Your mission\n\n${missionText.trim()}\n\n` + historySection + `# Communication protocol\n` + - '- Coordinate through tower tools ONLY: TowerSend / TowerInbox / TowerFinding / TowerMission / TowerStatus. Reach the tower and sibling agents with TowerSend; check TowerInbox regularly.\n' + - '- NEVER create or edit files under `.tower/` by hand — the tools are the only writers; hand-written protocol files break the merge gate.\n' + '- Found something notable outside your scope? File it with TowerFinding instead of fixing it.\n' + '- Keep your mission current with TowerMission: task_done as you finish tasks, note for decisions, blocker when stuck.\n' + '- Ambiguity is escalated, not guessed: if the mission and its Context leave substantive doubt about what to build, TowerSend(to="tower", subject="clarify-request", body=what needs pinning down) BEFORE acting — the tower relays to the human; you never ask the user directly.\n\n' + `# When the mission is done\n` + "1. `git add` + `git commit` your mission's changes in the worktree (source files only — no build outputs).\n" + - `2. Before marking completed or sending the review-request, call TowerInbox once and incorporate anything new into the delivery — the store refuses status="completed" while unread messages wait in your inbox. Re-read your mission too (TowerMission(id="${mission.id}") with no patch fields): the tower may have changed its tasks mid-flight.\n` + - `3. Mark the mission completed: TowerMission(id="${mission.id}", status="completed").\n` + - '4. Request review: TowerSend(to="tower", subject="review-request", body=what you changed and why, reconciled against the mission tasks item by item — the reviewer maps each task to your diff).\n' + - '5. Finish with a structured final summary: files changed, key decisions, open follow-ups.\n' + - 'Long blocking operations (sleep, builds, test runs) hide inbox messages for their whole duration — split them into chunks and check TowerInbox between chunks.' + + `2. Before completing, call TowerInbox once and incorporate anything new into the delivery — TowerComplete refuses while unread messages wait in your inbox. Re-read your mission too (TowerMission(id="${mission.id}") with no patch fields): the tower may have changed its tasks mid-flight.\n` + + '3. Call TowerComplete(report=what you changed and why, reconciled against the mission tasks item by item — the reviewer maps each task to your diff). It records completed and stores review-request to the tower in one protocol flow — do not substitute a TowerMission status patch or a manual TowerSend.\n' + + '4. Finish with a structured final summary: files changed, key decisions, open follow-ups.' + extra ); } @@ -503,17 +523,14 @@ export class TowerSpawnTool implements ITowerSpawnTool { `# Your assignment\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' + + '- Do NOT modify any code.\n\n' + missionSection + selfReportSection + historySection + `# Review checklist (in priority order)\n` + checklist + - `# When done — both steps are mandatory\n` + - `1. Submit your verdict with TowerReview: { target: "${target}", status: "clean" | "p1-Nitems" | "p2-Nitems", merge: "merge" | "fix-then-merge" | "hold", findings, checks, decision }. Only a "clean" review of the exact branch tip lets the tower merge.\n` + - (author !== undefined - ? `2. Notify the author with TowerSend(to="${author}", subject="review-result", ...).\n` - : '2. The author of this branch is not recorded — notify the tower instead: TowerSend(to="tower", subject="review-result", ...).\n') + + `# When done — TowerReview is the only mandatory protocol step\n` + + `1. Submit your verdict with TowerReview: { target: "${target}", status: "clean" | "p1-Nitems" | "p2-Nitems", merge: "merge" | "fix-then-merge" | "hold", findings, checks, decision }. Only a clean review with merge verdict "merge" on the exact branch tip lets the tower merge. Standard review-result notifications are stored automatically — do not send a second manual TowerSend.\n` + 'Then finish with a structured summary of the review.' + extra ); diff --git a/packages/agent-core-v2/src/features/tower/tools/status/status.md b/packages/agent-core-v2/src/features/tower/tools/status/status.md index 22e9bb46196..65d28732c2f 100644 --- a/packages/agent-core-v2/src/features/tower/tools/status/status.md +++ b/packages/agent-core-v2/src/features/tower/tools/status/status.md @@ -1 +1 @@ -Show the tower dashboard: missions (status/owner), the agent roster, the review-gate state of every unmerged branch (latest review round/status and whether the reviewed commit still matches the branch tip), your inbox message count, and the last activity log lines. Missions that are planned but have no spawned worker yet are called out in a dedicated Awaiting spawn section so the tower cannot miss launching them. +Show the tower dashboard: missions (status/owner), the agent roster, and each unmerged mission's structured merge-gate state (ready/blocks with stable reason codes, selected review and exact-tip/clean-rebase-waived/stale-tip binding), your inbox message count, and the last activity log lines. Worker runtime that cannot be observed authoritatively is shown as unknown, not idle. Planned missions without a worker are called out in Awaiting spawn. If .tower/comms/state.json was deleted while .tower/ survives, this returns a state-lost report (and a recovered state is flagged with its recoveredAt marker) instead of failing — report the history loss to the user when you see either. Wakes already carry message references and a gate digest, so use this full dashboard for human-facing status or deeper diagnosis rather than mechanically after every wake. diff --git a/packages/agent-core-v2/src/features/tower/tools/status/statusReader.ts b/packages/agent-core-v2/src/features/tower/tools/status/statusReader.ts new file mode 100644 index 00000000000..492d673761e --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/status/statusReader.ts @@ -0,0 +1,215 @@ +import type { IAgentTaskService } from '#/agent/task/task'; +import { + resolveTowerRepoRoot, + TowerStore, + type TowerMissionGateBlock, + type TowerReviewBinding, + type TowerState, +} from '#/features/tower/protocol/index'; + +import { callerName } from '../support'; + +const INBOX_COUNT_LIMIT = 1000; +const RECENT_LOG_LINES = 10; + +export interface TowerStatusConcurrency { + readonly budget: number; + readonly inflight: number; + readonly blockedUntil: number | null; +} + +export interface TowerStatusState { + readonly base: TowerState['base']; + readonly mode: TowerState['mode']; + readonly recoveredAt?: string; + readonly missions: TowerState['missions']; + readonly roster: TowerState['roster']; +} + +export interface TowerStatusReview { + readonly reviewer: string; + readonly round: number; + readonly status: string; + readonly merge: string; +} + +export interface TowerStatusGate { + readonly missionId: string; + readonly branch: string; + readonly ready: boolean; + readonly noop: boolean; + readonly blocks: readonly TowerMissionGateBlock[]; + readonly selectedReview?: TowerStatusReview; + readonly reviewBinding?: TowerReviewBinding; +} + +export interface InitializedTowerStatus { + readonly active: boolean; + readonly initialized: true; + readonly state: TowerStatusState; + readonly caller: string; + readonly gate: readonly TowerStatusGate[]; + readonly inbox: { readonly count: number }; + readonly concurrency: TowerStatusConcurrency; + readonly recentActivity: readonly string[]; +} + +export type ActiveTowerStatus = InitializedTowerStatus & { readonly active: true }; + +export type TowerStatus = + | { readonly active: boolean; readonly initialized: false; readonly stateLost: boolean } + | InitializedTowerStatus; + +export interface ReadTowerStatusInput { + readonly active: boolean; + readonly cwd: string; + readonly agentId: string; + readonly tasks: IAgentTaskService; + readonly concurrency: () => TowerStatusConcurrency; +} + +export async function readTowerStatus(input: ReadTowerStatusInput): Promise { + const store = new TowerStore(resolveTowerRepoRoot(input.cwd)); + const initialized = await store.isInitialized(); + if (!initialized) { + return { active: input.active, initialized: false, stateLost: await store.isStateLost() }; + } + + const state = await store.load(); + const caller = callerName(input.agentId, store, state); + const pending = state.missions.filter( + (mission) => mission.status !== 'merged' && mission.status !== 'abandoned', + ); + const activeAgentIds = + pending.length > 0 && input.agentId === 'main' + ? new Set( + input.tasks + .list(true) + .flatMap((task) => + task.kind === 'agent' && task.agentId !== undefined ? [task.agentId] : [], + ), + ) + : undefined; + const gate: TowerStatusGate[] = []; + for (const mission of pending) { + const result = await store.missionGate(state, mission, { activeAgentIds }); + gate.push({ + missionId: mission.id, + branch: mission.branch, + ready: result.ready, + noop: result.noop, + blocks: result.blocks, + selectedReview: + result.selectedReview === undefined + ? undefined + : { + reviewer: result.selectedReview.reviewer, + round: result.selectedReview.round, + status: result.selectedReview.status, + merge: result.selectedReview.merge, + }, + reviewBinding: result.reviewBinding, + }); + } + const inbox = await store.readInbox(caller, INBOX_COUNT_LIMIT); + const concurrency = input.concurrency(); + const recentActivity = await store.recentLog(RECENT_LOG_LINES); + return { + active: input.active, + initialized: true, + state: { + base: state.base, + mode: state.mode, + recoveredAt: state.recoveredAt, + missions: state.missions, + roster: state.roster, + }, + caller, + gate, + inbox: { count: inbox.length }, + concurrency, + recentActivity, + }; +} + +export async function readTowerStatusSummary(input: ReadTowerStatusInput): Promise { + const status = await readTowerStatus(input); + if (!status.initialized) { + if (status.stateLost) { + return status.active + ? 'Tower mode: ON\nTower state was lost: .tower/comms/state.json is missing but the .tower/ directory still exists — all recorded mission, roster, and review history is gone. Report this history loss to the user; TowerSend/TowerComplete recover a minimal state (stamped recoveredAt) automatically.' + : 'Tower mode: OFF'; + } + return status.active ? 'Tower mode: ON\nTower is not initialized.' : 'Tower mode: OFF'; + } + const lines = [ + status.active ? 'Tower status — ON' : 'Tower mode: OFF', + `Base: ${status.state.base} (mode: ${status.state.mode}) · You are: ${status.caller}`, + ...summaryRecoveredAt(status.state), + 'Missions:', + ...summaryMissions(status.state), + 'Roster:', + ...summaryRoster(status.state), + 'Review gate:', + ...summaryGate(status.gate), + `Inbox: ${String(status.inbox.count)} message(s)`, + `Concurrency: ${summaryConcurrency(status.concurrency)}`, + 'Recent activity:', + ...(status.recentActivity.length > 0 + ? status.recentActivity.map((line) => ` ${line}`) + : [' (empty)']), + ]; + return lines.join('\n'); +} + +function summaryMissions(state: TowerStatusState): string[] { + if (state.missions.length === 0) return [' (none)']; + return state.missions.map( + (mission) => + ` ${mission.id} ${mission.title} — ${mission.status} · owner ${mission.owner ?? '—'} · ${mission.branch}`, + ); +} + +function summaryRecoveredAt(state: TowerStatusState): string[] { + if (state.recoveredAt === undefined) return []; + return [ + `⚠️ State recovered at ${state.recoveredAt} after a state loss — all mission, roster, and review history from before that point is gone; report this history loss to the user instead of continuing as if nothing happened.`, + ]; +} + +function summaryRoster(state: TowerStatusState): string[] { + if (state.roster.agents.length === 0) return [' (none)']; + return state.roster.agents.map((agent) => { + const assignment = + agent.kind === 'worker' + ? `mission ${agent.missionId ?? '?'}` + : `reviewing ${agent.reviewTarget ?? '?'}`; + const death = agent.diedAt === undefined ? '' : ` · died ${agent.deathStatus ?? 'unknown'}`; + return ` ${agent.name} (${agent.kind}) — ${assignment} · ${agent.agentId}${death}`; + }); +} + +function summaryGate(gate: readonly TowerStatusGate[]): string[] { + if (gate.length === 0) return [' (no open missions)']; + return gate.map((missionGate) => { + const review = missionGate.selectedReview; + const reviewText = + review === undefined + ? '' + : ` · review round ${String(review.round)} by ${review.reviewer} ${review.status}/${review.merge || 'legacy'} · binding ${missionGate.reviewBinding ?? 'unavailable'}`; + if (missionGate.ready) { + return ` ${missionGate.missionId} ${missionGate.branch} — READY${missionGate.noop ? ' (zero-diff no-op)' : ''}${reviewText}`; + } + const reasons = missionGate.blocks.map((block) => block.reason).join(', '); + return ` ${missionGate.missionId} ${missionGate.branch} — BLOCKED: ${reasons}${reviewText}`; + }); +} + +function summaryConcurrency(snapshot: TowerStatusConcurrency): string { + const base = `budget: ${String(snapshot.budget)} agent(s) · inflight: ${String(snapshot.inflight)}`; + if (snapshot.blockedUntil === null) return `${base} · spawns open`; + const remainingMs = snapshot.blockedUntil - Date.now(); + return remainingMs > 0 + ? `${base} · spawns paused for ~${String(Math.ceil(remainingMs / 1000))}s` + : `${base} · spawn pause expired`; +} diff --git a/packages/agent-core-v2/src/features/tower/tools/status/statusTool.ts b/packages/agent-core-v2/src/features/tower/tools/status/statusTool.ts index a09003da6d7..e193fc1be92 100644 --- a/packages/agent-core-v2/src/features/tower/tools/status/statusTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/status/statusTool.ts @@ -1,27 +1,25 @@ -import { branchExists, branchTip } from '#/features/tower/protocol/index'; -import type { - TowerMission, - TowerRosterEntry, - TowerState, - TowerStore, -} from '#/features/tower/protocol/index'; +import type { TowerMission, TowerRosterEntry } from '#/features/tower/protocol/index'; import { userCancellationReason } from '#/_base/utils/abort'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { - ITowerRateLimitService, - type TowerRateLimitSnapshot, -} from '#/features/tower/towerRateLimit'; +import { IAgentTaskService } from '#/agent/task/task'; +import { ITowerRateLimitService } from '#/features/tower/towerRateLimit'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { toInputJsonSchema } from '#/tool/input-schema'; import type { ToolExecution } from '#/tool/toolContract'; -import { callerName, newTowerStore, runTowerTool } from '../support'; +import { runTowerTool } from '../support'; import DESCRIPTION from './status.md?raw'; import { ITowerStatusTool, TowerStatusToolInputSchema, type TowerStatusToolInput, } from './status'; +import { + readTowerStatus, + type TowerStatusConcurrency, + type TowerStatusGate, + type TowerStatusState, +} from './statusReader'; const STATUS_EMOJI: Record = { planned: '🟡', @@ -33,9 +31,6 @@ const STATUS_EMOJI: Record = { abandoned: '🚫', }; -const INBOX_COUNT_LIMIT = 1000; -const RECENT_LOG_LINES = 10; - export class TowerStatusTool implements ITowerStatusTool { declare readonly _serviceBrand: undefined; readonly name = 'TowerStatus' as const; @@ -46,6 +41,7 @@ export class TowerStatusTool implements ITowerStatusTool { @ISessionContext private readonly sessionContext: ISessionContext, @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @ITowerRateLimitService private readonly rateLimit: ITowerRateLimitService, + @IAgentTaskService private readonly tasks: IAgentTaskService, ) {} resolveExecution(_args: TowerStatusToolInput): ToolExecution { @@ -54,13 +50,28 @@ export class TowerStatusTool implements ITowerStatusTool { approvalRule: this.name, execute: () => runTowerTool(async () => { - const store = newTowerStore(this.sessionContext); - const state = await store.load(); - const caller = callerName(this.scopeContext.agentId, store, state); - + const status = await readTowerStatus({ + active: true, + cwd: this.sessionContext.cwd, + agentId: this.scopeContext.agentId, + tasks: this.tasks, + concurrency: () => this.rateLimit.snapshot(), + }); + if (!status.initialized) { + if (status.stateLost) { + return { output: renderStateLostReport() }; + } + return { + output: 'tower is not initialized in this repository — run TowerInit first', + isError: true, + }; + } + if (!status.active) return { output: '# Tower status — OFF' }; + const { state, caller, gate, inbox, concurrency, recentActivity } = status; const sections: string[] = [ `# Tower status — base: ${state.base} (mode: ${state.mode}), you are: ${caller}`, '', + ...renderRecoveredAtWarning(state), '## Missions', '', ...renderMissions(state), @@ -73,7 +84,7 @@ export class TowerStatusTool implements ITowerStatusTool { '', '## Review gate (unmerged branches)', '', - ...(await this.renderReviewGate(store, state)), + ...renderReviewGate(state, gate), ]; if ( @@ -90,57 +101,84 @@ export class TowerStatusTool implements ITowerStatusTool { ); } - const inbox = await store.readInbox(caller, INBOX_COUNT_LIMIT); sections.push( '', '## Inbox', '', - `${String(inbox.length)} message(s) visible to you — read with TowerInbox.`, + `${String(inbox.count)} message(s) visible to you — read with TowerInbox.`, '', '## Concurrency (adaptive)', '', - renderConcurrency(this.rateLimit.snapshot()), + renderConcurrency(concurrency), '', '## Recent activity', '', + ...(recentActivity.length > 0 ? recentActivity : ['(activity log is empty)']), ); - const log = await store.recentLog(RECENT_LOG_LINES); - sections.push(...(log.length > 0 ? log : ['(activity log is empty)'])); return { output: sections.join('\n') }; }), }; } +} - private async renderReviewGate(store: TowerStore, state: TowerState): Promise { - const pending = state.missions.filter( - (m) => m.status !== 'merged' && m.status !== 'abandoned', - ); - if (pending.length === 0) return ['(no open missions — or none planned yet)']; - const lines: string[] = []; - for (const mission of pending) { - const review = await store.latestReview(mission.branch); - if (review === undefined) { - lines.push(`- ${mission.branch} (${mission.id}): no review yet`); - continue; - } - const tip = (await branchExists(store.repoRoot, mission.branch)) - ? await branchTip(store.repoRoot, mission.branch) - : undefined; - const sync = - tip === undefined - ? 'branch not created yet' - : tip === review.reviewedCommit - ? 'reviewed commit matches tip' - : `STALE — tip moved to ${tip.slice(0, 7)}, re-review required`; +function renderStateLostReport(): string { + return [ + '# Tower status — STATE LOST', + '', + '.tower/comms/state.json is missing but the .tower/ directory still exists — this tower was initialized here before, and its comms state was deleted (e.g. by git clean). All recorded mission, roster, and review history is lost.', + '', + 'Report this history loss to the user now — continuing silently would pretend the loss never happened.', + '', + 'What still works:', + '- TowerSend and TowerComplete recover a minimal state automatically (stamped recoveredAt, base taken from the current checkout branch) and keep delivering messages — worker reports are not lost.', + '- TowerMerge and TowerRebase refuse with an explicit state-lost error instead of silently running against an empty history.', + '- Mission branches and worktrees under .tower/worktrees survive on disk; re-plan from that evidence only after the user decides how to proceed.', + ].join('\n'); +} + +function renderRecoveredAtWarning(state: TowerStatusState): string[] { + if (state.recoveredAt === undefined) return []; + return [ + '## ⚠️ State recovered after a loss', + '', + `This tower's state was reconstructed at ${state.recoveredAt} after .tower/comms/state.json was lost — every mission, roster entry, and review from before that point is gone, so the lists below are the recovered minimum, not reality. Report the history loss to the user before continuing routine orchestration.`, + '', + ]; +} + +function renderReviewGate( + state: TowerStatusState, + gate: readonly TowerStatusGate[], +): string[] { + const pending = state.missions.filter( + (mission) => mission.status !== 'merged' && mission.status !== 'abandoned', + ); + if (pending.length === 0) return ['(no open missions — or none planned yet)']; + const lines: string[] = []; + for (const missionGate of gate) { + const review = missionGate.selectedReview; + const reviewText = + review === undefined + ? '' + : `; review=round ${String(review.round)} by ${review.reviewer} ${review.status}/${review.merge || 'legacy'} binding=${missionGate.reviewBinding ?? 'unavailable'}`; + if (missionGate.ready) { lines.push( - `- ${mission.branch} (${mission.id}): round ${String(review.round)} by ${review.reviewer} — ${review.status} (${sync})`, + `- ${missionGate.branch} (${missionGate.missionId}): READY reasons=none${missionGate.noop ? '; zero-diff survey no-op, review skipped' : ''}${reviewText}`, ); + continue; } - return lines; + const reasons = missionGate.blocks.map((block) => block.reason).join(','); + const details = missionGate.blocks + .map((block) => block.message.replace(/^merge blocked: /, '')) + .join(' | '); + lines.push( + `- ${missionGate.branch} (${missionGate.missionId}): BLOCKED reasons=${reasons}; ${details}${reviewText}`, + ); } + return lines; } -function renderConcurrency(snapshot: TowerRateLimitSnapshot): string { +function renderConcurrency(snapshot: TowerStatusConcurrency): string { const parts = [ `budget: ${String(snapshot.budget)} agent(s) · inflight: ${String(snapshot.inflight)}`, ]; @@ -157,7 +195,7 @@ function renderConcurrency(snapshot: TowerRateLimitSnapshot): string { return parts.join(' · '); } -function renderMissions(state: TowerState): string[] { +function renderMissions(state: TowerStatusState): string[] { if (state.missions.length === 0) return ['(no missions planned — use TowerPlan)']; return [ '| ID | Mission | Branch | Worktree | Status | Owner |', @@ -169,7 +207,7 @@ function renderMissions(state: TowerState): string[] { ]; } -function renderRoster(state: TowerState): string[] { +function renderRoster(state: TowerStatusState): string[] { if (state.roster.agents.length === 0) { return ['(no agents registered — spawn workers/reviewers with TowerSpawn)']; } @@ -183,7 +221,7 @@ function renderRoster(state: TowerState): string[] { }); } -function renderUnspawnedMissions(state: TowerState): string[] { +function renderUnspawnedMissions(state: TowerStatusState): string[] { const pending = state.missions.filter((m) => m.status === 'planned' && m.owner === undefined); if (pending.length === 0) return []; return [ @@ -197,7 +235,7 @@ function renderUnspawnedMissions(state: TowerState): string[] { ]; } -function renderDeathWarnings(state: TowerState): string[] { +function renderDeathWarnings(state: TowerStatusState): string[] { const deadByName = new Map( state.roster.agents.filter((a) => a.diedAt !== undefined).map((a) => [a.name, a]), ); @@ -210,7 +248,9 @@ function renderDeathWarnings(state: TowerState): string[] { lines.push( isStoppedByUser(entry) ? `- 🛑 ${mission.id} owner ${entry.name} was stopped by the user (${entry.deathStatus ?? 'unknown'}) — dead by intent: never resume it and do not reassign the mission unless the human asks` - : `- ⚠️ ${mission.id} owner ${entry.name} died (${entry.deathStatus ?? 'unknown'}) — diagnose first: check why it died (the died entry's status/reason, its task state) before reviving anything. Resume with Agent(resume="${entry.agentId}", run_in_background=true, prompt="...") (never foreground: its output flows back through the tower protocol files) or reassign the mission only when the cause is transient (lost contact, timeout, OOM); a systematic cause (code or environment defect) is fixed or escalated to the human before any revive`, + : entry.kind === 'reviewer' + ? `- ⚠️ ${mission.id} reviewer ${entry.name} died (${entry.deathStatus ?? 'unknown'}) — diagnose first: check the death status/reason and task state. A reviewer is one-shot: do not resume it or another roster agent; after diagnosis, spawn a fresh reviewer only if the mission still needs one` + : `- ⚠️ ${mission.id} owner ${entry.name} died (${entry.deathStatus ?? 'unknown'}) — diagnose first: check why it died (the died entry's status/reason, its task state) before reviving anything. Resume with Agent(resume="${entry.agentId}", run_in_background=true, prompt="...") (foreground only when background task tools are unavailable) or reassign the mission only when the cause is transient (lost contact, timeout, OOM); a systematic cause (code or environment defect) is fixed or escalated to the human before any revive`, ); } if (lines.length === 0) return lines; diff --git a/packages/agent-core-v2/src/features/tower/tools/support.ts b/packages/agent-core-v2/src/features/tower/tools/support.ts index cd365c18406..5f415d887fb 100644 --- a/packages/agent-core-v2/src/features/tower/tools/support.ts +++ b/packages/agent-core-v2/src/features/tower/tools/support.ts @@ -5,11 +5,8 @@ import { resolveTowerRepoRoot, type TowerState, } from '#/features/tower/protocol/index'; -import type { AgentContext } from '#/agent/agentContext/agentContext'; import type { ISessionContext } from '#/session/sessionContext/sessionContext'; -import { ISessionUsageService } from '#/session/usage/sessionUsage'; import type { ExecutableToolResult } from '#/tool/toolContract'; -import { grandTotal } from '#human/llm/usage'; export function newTowerStore(sessionContext: ISessionContext): TowerStore { return new TowerStore(resolveTowerRepoRoot(sessionContext.cwd)); @@ -26,14 +23,6 @@ export function callerName(agentId: string, store: TowerStore, state: TowerState return store.resolveCallerName(state, agentId); } -export function callerTokens( - usage: ISessionUsageService | undefined, - agent: AgentContext, -): number { - const total = usage?.status(agent).total; - return total === undefined ? -1 : grandTotal(total); -} - export async function runTowerTool( execute: () => Promise, ): Promise { diff --git a/packages/agent-core-v2/src/features/tower/tower-worker-overlay.md b/packages/agent-core-v2/src/features/tower/tower-worker-overlay.md index 9f3a0c8bddd..336438c5c96 100644 --- a/packages/agent-core-v2/src/features/tower/tower-worker-overlay.md +++ b/packages/agent-core-v2/src/features/tower/tower-worker-overlay.md @@ -1 +1,3 @@ -You are a tower worker/reviewer in a multi-agent tower workspace. All collaboration protocol traffic (inbox messages, findings, reviews, mission updates) goes through the Tower* tools ONLY — never create, edit, or delete any file under `.tower/` by hand; the tools are the only writers, and hand-written protocol files break the merge gate. Your TowerSpawn briefing names your mission (worker) or review target (reviewer) — stay inside it. +You are a tower worker/reviewer in a multi-agent tower workspace. All collaboration protocol traffic (inbox messages, findings, reviews, mission updates) goes through the Tower* tools ONLY — never create, edit, or delete protocol artifacts under `.tower/comms/` by hand; hand-written protocol files break the merge gate. Your TowerSpawn briefing names your mission (worker) or review target (reviewer) — stay inside it. + +Stay responsive while you work. Inbox messages sent to you while you are running are steered into your current turn as injected user messages — when one arrives, read TowerInbox and act on it at the next step boundary instead of deferring it. Long-running Bash commands (installs, builds, full test suites — anything expected to take more than a minute) must run with `run_in_background=true`; rely on their completion notifications or natural progress boundaries instead of calling WaitFor routinely, and never poll with tight TaskOutput checks, so you remain responsive to steered messages. Call WaitFor only when your next action genuinely cannot proceed without the background command's result. The only exception is a command you cannot retry safely when interrupted — those you may run in the foreground, and you check TowerInbox immediately after. diff --git a/packages/agent-core-v2/src/features/tower/tower.ts b/packages/agent-core-v2/src/features/tower/tower.ts index cd3ba332d2e..73011182ce0 100644 --- a/packages/agent-core-v2/src/features/tower/tower.ts +++ b/packages/agent-core-v2/src/features/tower/tower.ts @@ -4,6 +4,7 @@ export const TOWER_TOOL_NAMES = [ 'TowerPlan', 'TowerSpawn', 'TowerMerge', + 'TowerRebase', 'TowerTeardown', 'TowerSend', 'TowerInbox', @@ -35,7 +36,7 @@ export type TowerEnterFailure = export type TowerEnterResult = { readonly entered: true } | TowerEnterFailure; -export type TowerExitReason = 'user' | 'takeover' | 'foreign-reconcile'; +export type TowerExitReason = 'user' | 'takeover' | 'foreign-reconcile' | 'mode-mutex'; export function towerEnterFailureMessage(failure: TowerEnterFailure): string { switch (failure.reason) { @@ -59,9 +60,10 @@ export interface IAgentTowerService { readonly _serviceBrand: undefined; readonly isActive: boolean; - readonly requestedBase: string | undefined; enter(base?: string): Promise; exit(reason?: TowerExitReason): Promise; + isBranchLeased(branch: string): boolean; + withBranchLease(branch: string, execute: () => Promise): Promise; } export const IAgentTowerService = createDecorator('agentTowerService'); diff --git a/packages/agent-core-v2/src/features/tower/towerFeature.ts b/packages/agent-core-v2/src/features/tower/towerFeature.ts index e2007519c2b..231ab9c5e95 100644 --- a/packages/agent-core-v2/src/features/tower/towerFeature.ts +++ b/packages/agent-core-v2/src/features/tower/towerFeature.ts @@ -12,6 +12,8 @@ import { registerFeature } from '#/features/featureRegistry'; import { TOWER_FLAG_ID } from './tower'; import { ITowerRateLimitService } from './towerRateLimit'; import { TowerRateLimitService } from './towerRateLimitService'; +import { ITowerCompleteTool } from './tools/complete/complete'; +import { TowerCompleteTool } from './tools/complete/completeTool'; import { ITowerFindingTool } from './tools/finding/finding'; import { TowerFindingTool } from './tools/finding/findingTool'; import { ITowerInboxTool } from './tools/inbox/inbox'; @@ -24,6 +26,8 @@ import { ITowerMissionTool } from './tools/mission/mission'; import { TowerMissionTool } from './tools/mission/missionTool'; import { ITowerPlanTool } from './tools/plan/plan'; import { TowerPlanTool } from './tools/plan/planTool'; +import { ITowerRebaseTool } from './tools/rebase/rebase'; +import { TowerRebaseTool } from './tools/rebase/rebaseTool'; import { ITowerReviewTool } from './tools/review/review'; import { TowerReviewTool } from './tools/review/reviewTool'; import { ITowerSendTool } from './tools/send/send'; @@ -47,7 +51,9 @@ export const TOWER_TOOL_CONTRIBUTIONS: readonly TowerToolContribution[] = [ { id: ITowerPlanTool, ctor: TowerPlanTool, name: 'TowerPlan' }, { id: ITowerSpawnTool, ctor: TowerSpawnTool, name: 'TowerSpawn' }, { id: ITowerMergeTool, ctor: TowerMergeTool, name: 'TowerMerge' }, + { id: ITowerRebaseTool, ctor: TowerRebaseTool, name: 'TowerRebase' }, { id: ITowerTeardownTool, ctor: TowerTeardownTool, name: 'TowerTeardown' }, + { id: ITowerCompleteTool, ctor: TowerCompleteTool, name: 'TowerComplete' }, { id: ITowerSendTool, ctor: TowerSendTool, name: 'TowerSend' }, { id: ITowerInboxTool, ctor: TowerInboxTool, name: 'TowerInbox' }, { id: ITowerFindingTool, ctor: TowerFindingTool, name: 'TowerFinding' }, diff --git a/packages/agent-core-v2/src/features/tower/towerOps.ts b/packages/agent-core-v2/src/features/tower/towerOps.ts index 01426bc991c..aaf6e89a4c7 100644 --- a/packages/agent-core-v2/src/features/tower/towerOps.ts +++ b/packages/agent-core-v2/src/features/tower/towerOps.ts @@ -8,7 +8,6 @@ import { defineState } from '#/state/state'; const towerModeEnterSchema = z.object({ agentId: z.string(), sessionId: z.string().optional(), - base: z.string().optional(), }); export class TowerModeEnter extends AgentEvent2> { @@ -19,7 +18,6 @@ export class TowerModeEnter extends AgentEvent2 { @@ -63,10 +65,3 @@ export const towerOwnerKey = defineState('tower.owner', () => undefined as strin }) .on(TowerModeEnter, (_s, e) => e.sessionId) .on(TowerModeExit, () => undefined); - -export const towerBaseKey = defineState('tower.base', (): string | null => null) - .replayable({ - schema: z.custom(), - }) - .on(TowerModeEnter, (_s, e) => e.base ?? null) - .on(TowerModeExit, () => null); diff --git a/packages/agent-core-v2/src/features/tower/towerService.ts b/packages/agent-core-v2/src/features/tower/towerService.ts index 9e41f17e35c..f508d4bd479 100644 --- a/packages/agent-core-v2/src/features/tower/towerService.ts +++ b/packages/agent-core-v2/src/features/tower/towerService.ts @@ -1,4 +1,4 @@ -import { join } from 'node:path'; +import { isAbsolute, join, resolve } from 'node:path'; import { Disposable, toDisposable } from '#/_base/di/lifecycle'; import { ScopeActivation, registerScopedService, type ISessionScopeHandle } from '#/_base/di/scope'; @@ -16,6 +16,7 @@ import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; +import { IAgentTaskService } from '#/agent/task/task'; import type { AgentTaskInfo } from '#/agent/task/types'; import { TaskTerminatedNotice } from '#/agent/task/taskOps'; import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent'; @@ -28,17 +29,20 @@ import { LifecycleScope } from '#/app/scopes'; import { IFlagService } from '#/app/flag/flag'; import { ISessionManager } from '#/app/sessionManager/sessionManager'; import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { IBashParserService } from '#/app/bashParser/bashParser'; 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'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { isUntitled } from '#/session/sessionMetadata/promptMetadata'; import { SubagentStarted } from '#/session/subagent/mirrorAgentRun'; import { TowerModeInjection } from './injection/towerModeInjection'; import { BROADCAST_NAME, + STATE_FILE, TOWER_NAME, TowerStore, WORKTREES_DIR, @@ -50,6 +54,12 @@ import { resolveTowerRepoRoot, TowerProtocolError, } from './protocol/index'; +import { + analyzeTowerBashCommand, + commandNeedsTowerGuard, + TOWER_BASH_GUARD_PARSE_OPTIONS, + towerWorkspaceOwned, +} from './bashGuard'; import { IAgentTowerService, TOWER_FLAG_ID, @@ -59,13 +69,32 @@ import { type TowerExitReason, } from './tower'; import { isTowerFeatureAssembled } from './towerFeature'; -import { TowerInboxSent, TowerModeEnter, TowerModeExit, towerBaseKey, towerKey, towerOwnerKey } from './towerOps'; +import { TowerInboxSent, TowerModeEnter, TowerModeExit, towerKey, towerOwnerKey } from './towerOps'; export const TOWER_MODE_TOOLS: readonly string[] = ['TowerInit', ...TOWER_TOOL_NAMES]; +const TOWER_FLAG_VETO_TOOLS: ReadonlySet = new Set([...TOWER_MODE_TOOLS, 'TowerComplete']); export const TOWER_INBOX_WAKE_VARIANT = 'tower_inbox'; const WAKE_SUBJECT_PREVIEW_MAX = 120; +const WAKE_BATCH_LIMIT = 6; +const WAKE_DIGEST_LIMIT = 4; +const WAKE_TRACKED_KEY_LIMIT = 100; + +interface InboxWakeItem { + readonly key: string; + readonly from: string; + readonly subject: string; + readonly messageId?: string; + readonly file?: string; + readonly sentAt?: string; + readonly missionId?: string; +} + +interface InboxWakeBatch { + readonly items: readonly InboxWakeItem[]; + readonly omitted: number; +} export class AgentTowerService extends Disposable implements IAgentTowerService { declare readonly _serviceBrand: undefined; @@ -89,12 +118,14 @@ export class AgentTowerService extends Disposable implements IAgentTowerService @IEventBus eventBus: IEventBus, @ILogService private readonly log: ILogService, @IAgentLoopService private readonly loop: IAgentLoopService, + @IAgentTaskService private readonly tasks: IAgentTaskService | undefined, @ISessionEventBus sessionBus: ISessionEventBus, + @IBashParserService private readonly bashParser: IBashParserService, + @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext | undefined, ) { super(); this.agentState.contributeState(towerKey); this.agentState.contributeState(towerOwnerKey); - this.agentState.contributeState(towerBaseKey); this._register( this.dispatcher.hooks.onDidRestore.register('tower', async (_ctx, next) => { await this.reconcileForeignTower(); @@ -159,6 +190,7 @@ export class AgentTowerService extends Disposable implements IAgentTowerService return; } this.wakeTurnId = event.turnId; + this.wakeTurnBatch = this.inboxWakeLastNotified; }), ); this._register( @@ -167,12 +199,15 @@ export class AgentTowerService extends Disposable implements IAgentTowerService if (event.agentId !== this.agentCtx.agentId) return; if (event.turnId === this.wakeTurnId) { this.wakeTurnId = undefined; + if (!this.wakeAbortedForUserPrompt) this.wakeTurnBatch = undefined; return; } if (!this.wakeAbortedForUserPrompt) return; if (this.loop === undefined || this.loop.snapshot().queue.length > 0) return; this.wakeAbortedForUserPrompt = false; - this.inboxWakeSignals = Math.max(this.inboxWakeSignals, 1); + const batch = this.wakeTurnBatch; + this.wakeTurnBatch = undefined; + if (batch !== undefined) this.requeueInboxWake(batch); this.scheduleInboxWake(); }), ); @@ -199,7 +234,7 @@ export class AgentTowerService extends Disposable implements IAgentTowerService this._register( toolExecutor.onBeforeExecuteTool((event) => { if (this.flags.enabled(TOWER_FLAG_ID)) return; - if (!TOWER_MODE_TOOLS.includes(event.toolCall.name)) return; + if (!TOWER_FLAG_VETO_TOOLS.has(event.toolCall.name)) return; event.veto( denyToolExecution( this.toolApproval.formatDenyMessage( @@ -237,6 +272,33 @@ export class AgentTowerService extends Disposable implements IAgentTowerService ); }), ); + this._register( + toolExecutor.onBeforeExecuteTool((event) => { + if (!this.flags.enabled(TOWER_FLAG_ID)) return; + if (!this.isActive) return; + if (event.toolCall.name === 'CreateGoal') { + event.veto( + denyToolExecution( + this.toolApproval.formatDenyMessage( + 'CreateGoal is not available while tower mode is active — tower mode and goals are mutually exclusive, and the previously active goal was paused when tower mode entered. If the work genuinely needs a goal instead, exit tower mode first.', + ), + ), + ); + return; + } + if (event.toolCall.name !== 'UpdateGoal') return; + const args = event.args; + if (typeof args !== 'object' || args === null) return; + if ((args as { readonly status?: unknown }).status !== 'active') return; + event.veto( + denyToolExecution( + this.toolApproval.formatDenyMessage( + 'Resuming a goal is not available while tower mode is active — tower mode and goals are mutually exclusive. If the work genuinely needs the goal instead, exit tower mode first.', + ), + ), + ); + }), + ); this._register( toolExecutor.onBeforeExecuteTool(async (event) => { if (!this.flags.enabled(TOWER_FLAG_ID)) return; @@ -248,24 +310,40 @@ export class AgentTowerService extends Disposable implements IAgentTowerService if (typeof resume !== 'string') return; const resumeId = resume.trim(); if (resumeId.length === 0) return; + const store = new TowerStore(resolveTowerRepoRoot(this.sessionCtx.cwd)); + const resolved = await store.load().then( + (state) => { + const entry = store.resolveAgent(state, resumeId); + if (entry === undefined) return undefined; + const branch = + entry.branch ?? + entry.reviewTarget ?? + state.missions.find((mission) => mission.id === entry.missionId)?.branch; + return { entry, branch }; + }, + () => undefined, + ); + if (resolved === undefined) return; + if (resolved.branch !== undefined && this.isBranchLeased(resolved.branch)) { + event.veto( + denyToolExecution( + this.toolApproval.formatDenyMessage( + `Cannot resume tower agent "${resolved.entry.name}" while branch "${resolved.branch}" is leased by a merge/rebase — no agent was started; retry after that operation finishes`, + ), + ), + ); + return; + } if ((args as { readonly run_in_background?: unknown }).run_in_background === true) return; const backgroundAvailable = this.toolPolicy.isToolActive('TaskList') && this.toolPolicy.isToolActive('TaskOutput') && this.toolPolicy.isToolActive('TaskStop'); if (!backgroundAvailable) return; - const store = new TowerStore(resolveTowerRepoRoot(this.sessionCtx.cwd)); - const entry = await store - .load() - .then( - (state) => store.resolveAgent(state, resumeId), - () => undefined, - ); - if (entry === undefined) return; event.veto( denyToolExecution( this.toolApproval.formatDenyMessage( - `Resuming tower agent "${entry.name}" in the foreground would freeze the tower until it finishes — pass run_in_background=true instead; its completion (and any inbox traffic) will wake you.`, + `Resuming tower agent "${resolved.entry.name}" in the foreground would freeze the tower until it finishes — pass run_in_background=true instead; its completion (and any inbox traffic) will wake you.`, ), ), ); @@ -307,6 +385,32 @@ export class AgentTowerService extends Disposable implements IAgentTowerService ); }), ); + this._register( + toolExecutor.onBeforeExecuteTool(async (event) => { + if (event.toolCall.name !== 'Bash') return; + const args = event.args; + if (typeof args !== 'object' || args === null) return; + const command = (args as { readonly command?: unknown }).command; + if (typeof command !== 'string' || !commandNeedsTowerGuard(command)) return; + const mainCheckout = resolveTowerRepoRoot(this.sessionCtx.cwd); + if (!(await towerWorkspaceOwned(join(mainCheckout, STATE_FILE)))) return; + const cwdArg = (args as { readonly cwd?: unknown }).cwd; + const cwd = + typeof cwdArg === 'string' + ? this.resolveBashGuardCwd(cwdArg) + : (this.workspaceCtx?.workDir ?? this.sessionCtx.cwd); + const reason = analyzeTowerBashCommand({ command, cwd, mainCheckout }, (source) => + this.bashParser.parse(source, TOWER_BASH_GUARD_PARSE_OPTIONS), + ); + if (reason === undefined) return; + event.veto(denyToolExecution(this.toolApproval.formatDenyMessage(reason))); + }), + ); + } + + private resolveBashGuardCwd(cwdArg: string): string { + if (this.workspaceCtx !== undefined) return this.workspaceCtx.resolve(cwdArg); + return isAbsolute(cwdArg) ? resolve(cwdArg) : resolve(this.sessionCtx.cwd, cwdArg); } async enter(base?: string): Promise { @@ -322,15 +426,6 @@ export class AgentTowerService extends Disposable implements IAgentTowerService if (this.agentCtx.agentId !== 'main') return { entered: false, reason: 'not-main-agent' }; if (!this.flags.enabled(TOWER_FLAG_ID)) return { entered: false, reason: 'experiment-off' }; if (!isTowerFeatureAssembled(this.flags)) return { entered: false, reason: 'feature-not-assembled' }; - if (base !== undefined) { - await this.prepareUserBase(base); - } - if (this.isActive) { - if (base !== undefined && base !== this.agentState.get(towerBaseKey)) { - this.dispatchEnter(base); - } - return { entered: true }; - } const owner = await this.resolveTowerOwner(); if (owner !== undefined && owner !== this.sessionCtx.sessionId) { const ownerHandle = this.sessions.get(owner); @@ -347,17 +442,19 @@ export class AgentTowerService extends Disposable implements IAgentTowerService .exit('takeover'); } } + if (base !== undefined) { + await this.prepareUserBase(base); + } + if (this.isActive) { + return { entered: true }; + } await this.adoptTowerRoster(); for (const name of TOWER_MODE_TOOLS) this.profile.addActiveTool(name); this.lastPublished = true; - this.dispatchEnter(base); + this.dispatchEnter(); return { entered: true }; } - get requestedBase(): string | undefined { - return this.agentState.get(towerBaseKey) ?? undefined; - } - private async prepareUserBase(base: string): Promise { const repoRoot = resolveTowerRepoRoot(this.sessionCtx.cwd); const store = new TowerStore(repoRoot); @@ -368,14 +465,6 @@ export class AgentTowerService extends Disposable implements IAgentTowerService await assertLocalBaseBranch(repoRoot, base); return; } - const open = state.missions.filter( - (mission) => mission.status !== 'merged' && mission.status !== 'abandoned', - ); - if (open.length > 0) { - throw new TowerProtocolError( - `tower workspace already records base "${state.base}" with ${String(open.length)} open mission(s) (${open.map((mission) => mission.id).join(', ')}) — merge or abandon them (or /tower teardown) before switching the tower to base "${base}"`, - ); - } if (!(await branchExists(repoRoot, base))) { await this.createBaseBranch(repoRoot, base); } @@ -413,12 +502,11 @@ export class AgentTowerService extends Disposable implements IAgentTowerService } } - private dispatchEnter(base: string | undefined): void { + private dispatchEnter(): void { void this.dispatcher.dispatch( new TowerModeEnter({ agentId: this.agentCtx.agentId, sessionId: this.sessionCtx.sessionId, - base, }), ); } @@ -433,9 +521,22 @@ export class AgentTowerService extends Disposable implements IAgentTowerService } private dropInboxWake(): void { - this.inboxWakeHandle?.drop(); + this.inboxWakeGeneration += 1; + this.inboxWakeDiscarding = true; + try { + this.inboxWakeHandle?.drop(); + } finally { + this.inboxWakeDiscarding = false; + } this.inboxWakeHandle = undefined; - this.inboxWakeSignals = 0; + this.inboxWakePending = false; + this.inboxWakeItems = []; + this.inboxWakeOmitted = 0; + this.inboxWakeRecentKeys.clear(); + this.inboxWakeLastNotified = undefined; + this.wakeTurnBatch = undefined; + this.wakeTurnId = undefined; + this.wakeAbortedForUserPrompt = false; } private async adoptTowerRoster(): Promise { @@ -470,6 +571,26 @@ export class AgentTowerService extends Disposable implements IAgentTowerService ); } + private readonly branchLeases = new Set(); + + isBranchLeased(branch: string): boolean { + return this.branchLeases.has(branch); + } + + async withBranchLease(branch: string, execute: () => Promise): Promise { + if (this.branchLeases.has(branch)) { + throw new TowerProtocolError( + `branch "${branch}" is leased by another tower merge/rebase — not waiting; retry after that operation finishes`, + ); + } + this.branchLeases.add(branch); + try { + return await execute(); + } finally { + this.branchLeases.delete(branch); + } + } + private async reconcileForeignTower(): Promise { if (this.agentCtx.agentId !== 'main') return; if (!this.agentState.get(towerKey)) return; @@ -573,13 +694,19 @@ export class AgentTowerService extends Disposable implements IAgentTowerService return owner !== undefined && owner !== this.sessionCtx.sessionId ? owner : undefined; } - private inboxWakeSignals = 0; - private inboxWakeLatest: { readonly from: string; readonly subject: string } | undefined; + private inboxWakeItems: InboxWakeItem[] = []; + private inboxWakeOmitted = 0; + private inboxWakeLegacySequence = 0; + private inboxWakeGeneration = 0; + private readonly inboxWakeRecentKeys = new Set(); private inboxWakeScheduled = false; private inboxWakePending = false; private inboxWakeHandle: LoopNotifyHandle | undefined; + private inboxWakeLastNotified: InboxWakeBatch | undefined; + private inboxWakeDiscarding = false; private wakeDisposed = false; private wakeTurnId: number | undefined; + private wakeTurnBatch: InboxWakeBatch | undefined; private wakeAbortedForUserPrompt = false; private onTowerInboxSent(event: TowerInboxSent): void { @@ -587,44 +714,88 @@ export class AgentTowerService extends Disposable implements IAgentTowerService if (!this.isActive) return; if (event.from === TOWER_NAME) return; if (event.to !== TOWER_NAME && event.to !== BROADCAST_NAME) return; - this.inboxWakeSignals += 1; - this.inboxWakeLatest = { from: event.from, subject: event.subject }; + let key: string; + if (event.messageId !== undefined) { + key = `id:${event.messageId}`; + } else if (event.file !== undefined) { + key = `file:${event.file}`; + } else { + this.inboxWakeLegacySequence += 1; + key = `legacy:${String(this.inboxWakeLegacySequence)}`; + } + this.enqueueInboxWake({ + key, + from: event.from, + subject: event.subject, + messageId: event.messageId, + file: event.file, + sentAt: event.sentAt, + missionId: event.missionId, + }); this.scheduleInboxWake(); } + private enqueueInboxWake(item: InboxWakeItem, replay = false): void { + if (this.inboxWakeItems.some((candidate) => candidate.key === item.key)) return; + if (!replay && this.inboxWakeRecentKeys.has(item.key)) return; + this.inboxWakeRecentKeys.add(item.key); + if (this.inboxWakeRecentKeys.size > WAKE_TRACKED_KEY_LIMIT) { + const oldest = this.inboxWakeRecentKeys.values().next().value; + if (oldest !== undefined) this.inboxWakeRecentKeys.delete(oldest); + } + this.inboxWakeItems.push(item); + if (this.inboxWakeItems.length > WAKE_BATCH_LIMIT) { + this.inboxWakeItems.shift(); + this.inboxWakeOmitted += 1; + } + } + + private requeueInboxWake(batch: InboxWakeBatch): void { + for (const item of batch.items) this.enqueueInboxWake(item, true); + this.inboxWakeOmitted += batch.omitted; + } + private scheduleInboxWake(): void { - if (this.inboxWakeScheduled || this.inboxWakePending) return; + if (this.inboxWakeScheduled || this.inboxWakePending || this.inboxWakeItems.length === 0) { + return; + } this.inboxWakeScheduled = true; queueMicrotask(() => { - this.flushInboxWake(); + void this.flushInboxWake(); }); } - private flushInboxWake(): void { + private async flushInboxWake(): Promise { this.inboxWakeScheduled = false; if (this.wakeDisposed || !this.isActive || this.loop === undefined) { - this.inboxWakeSignals = 0; + this.inboxWakeItems = []; + this.inboxWakeOmitted = 0; return; } - const count = this.inboxWakeSignals; - const latest = this.inboxWakeLatest; - if (count === 0 || latest === undefined) return; - this.inboxWakeSignals = 0; + if (this.inboxWakeItems.length === 0) return; + const batch: InboxWakeBatch = { + items: this.inboxWakeItems, + omitted: this.inboxWakeOmitted, + }; + this.inboxWakeItems = []; + this.inboxWakeOmitted = 0; + this.inboxWakeLastNotified = batch; this.inboxWakePending = true; - const countText = count === 1 ? '1 new tower inbox message' : `${String(count)} new tower inbox messages`; - const subject = - latest.subject.length > WAKE_SUBJECT_PREVIEW_MAX - ? `${latest.subject.slice(0, WAKE_SUBJECT_PREVIEW_MAX)}…` - : latest.subject; + const generation = this.inboxWakeGeneration; + const text = await this.renderInboxWake(batch); + if ( + generation !== this.inboxWakeGeneration || + this.wakeDisposed || + !this.isActive || + this.loop === undefined + ) { + if (generation === this.inboxWakeGeneration) this.inboxWakePending = false; + return; + } this.inboxWakeHandle = this.loop.notify({ message: { role: 'user', - content: [ - { - type: 'text', - text: `${countText} — latest from ${latest.from}: "${subject}". Read and route with TowerInbox.`, - }, - ], + content: [{ type: 'text', text }], toolCalls: [], origin: { kind: 'injection', variant: TOWER_INBOX_WAKE_VARIANT }, }, @@ -632,15 +803,104 @@ export class AgentTowerService extends Disposable implements IAgentTowerService onConsume: () => { this.inboxWakeHandle = undefined; this.inboxWakePending = false; - if (this.inboxWakeSignals > 0) this.scheduleInboxWake(); + if (this.inboxWakeItems.length > 0) this.scheduleInboxWake(); }, onDrop: () => { this.inboxWakeHandle = undefined; this.inboxWakePending = false; + if (this.inboxWakeDiscarding || this.wakeDisposed || !this.isActive) return; + this.requeueInboxWake(batch); + this.scheduleInboxWake(); }, }); } + private async renderInboxWake(batch: InboxWakeBatch): Promise { + const count = batch.items.length + batch.omitted; + const countText = + count === 1 ? '1 new tower inbox message' : `${String(count)} new tower inbox messages`; + const lines = [ + `${countText} — ${String(batch.items.length)} reference(s), omitted=${String(batch.omitted)}. Read and route with TowerInbox; this wake does not acknowledge them.`, + ]; + for (const item of batch.items) { + const subject = + item.subject.length > WAKE_SUBJECT_PREVIEW_MAX + ? `${item.subject.slice(0, WAKE_SUBJECT_PREVIEW_MAX)}…` + : item.subject; + const references = [ + item.messageId !== undefined ? `messageId=${item.messageId}` : undefined, + item.file !== undefined ? `file=${item.file}` : undefined, + item.sentAt !== undefined ? `sentAt=${item.sentAt}` : undefined, + item.missionId !== undefined ? `mission=${item.missionId}` : undefined, + ].filter((reference) => reference !== undefined); + lines.push( + `- ${item.from}: "${subject}"${references.length > 0 ? ` (${references.join(', ')})` : ''}`, + ); + } + lines.push(...(await this.renderInboxWakeDigests(batch.items))); + return lines.join('\n'); + } + + private async renderInboxWakeDigests(items: readonly InboxWakeItem[]): Promise { + const missionIds = [...new Set(items.flatMap((item) => item.missionId ?? []))]; + const store = new TowerStore(resolveTowerRepoRoot(this.sessionCtx.cwd)); + const state = await store.load().then( + (loaded) => loaded, + () => undefined, + ); + const lines: string[] = []; + if (state?.recoveredAt !== undefined) { + lines.push( + `⚠️ Tower state was recovered after a loss at ${state.recoveredAt} — missions, roster, and protocol history before that point are gone. Report the loss to the user instead of continuing silently.`, + ); + } + if (missionIds.length === 0) return lines; + lines.push('Gate digest:'); + if (state === undefined) { + for (const missionId of missionIds.slice(0, WAKE_DIGEST_LIMIT)) { + lines.push(`- ${missionId}: gate=unavailable`); + } + } else { + const activeAgentIds = this.activeAgentIds(); + for (const missionId of missionIds.slice(0, WAKE_DIGEST_LIMIT)) { + const mission = state.missions.find((candidate) => candidate.id === missionId); + if (mission === undefined) { + lines.push(`- ${missionId}: gate=mission-missing`); + continue; + } + if (mission.status === 'merged' || mission.status === 'abandoned') { + lines.push(`- ${missionId}: gate=${mission.status}`); + continue; + } + try { + const gate = await store.missionGate(state, mission, { activeAgentIds }); + lines.push( + gate.ready + ? `- ${missionId}: gate=READY reasons=none${gate.reviewBinding === undefined ? '' : ` binding=${gate.reviewBinding}`}` + : `- ${missionId}: gate=BLOCKED reasons=${gate.blocks.map((block) => block.reason).join(',')}`, + ); + } catch { + lines.push(`- ${missionId}: gate=unavailable`); + } + } + } + if (missionIds.length > WAKE_DIGEST_LIMIT) { + lines.push(`- digest-omitted=${String(missionIds.length - WAKE_DIGEST_LIMIT)}`); + } + return lines; + } + + private activeAgentIds(): ReadonlySet | undefined { + if (this.tasks === undefined) return undefined; + return new Set( + this.tasks + .list(true) + .flatMap((task) => + task.kind === 'agent' && task.agentId !== undefined ? [task.agentId] : [], + ), + ); + } + private restoreTowerTools(): void { if (!this.flags.enabled(TOWER_FLAG_ID)) return; if (!this.isActive) return; diff --git a/packages/agent-core-v2/src/features/tower/workerProfile.ts b/packages/agent-core-v2/src/features/tower/workerProfile.ts index f19b4c72131..b3c7bc41b82 100644 --- a/packages/agent-core-v2/src/features/tower/workerProfile.ts +++ b/packages/agent-core-v2/src/features/tower/workerProfile.ts @@ -12,8 +12,8 @@ import { TOWER_WORKER_PROFILE } from './tower'; import TOWER_WORKER_ROLE_OVERLAY from './tower-worker-overlay.md?raw'; const TOWER_WORKER_TOOLS = [ - 'Agent', 'Bash', + 'TowerComplete', 'TowerFinding', 'TowerInbox', 'TowerMission', @@ -60,7 +60,6 @@ export const TOWER_WORKER_PROFILE_DEF: AgentProfile = normalizeAgentProfile({ whenToUse: 'Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent.', tools: TOWER_WORKER_TOOLS, - subagents: ['explore', 'plan'], renderSystemPrompt: (context) => renderSystemPromptResult(TOWER_WORKER_ROLE, context, { skillActive: skillActiveFor(TOWER_WORKER_TOOLS), diff --git a/packages/agent-core-v2/test/agent/modeMutex/modeMutex.test.ts b/packages/agent-core-v2/test/agent/modeMutex/modeMutex.test.ts index 562681c9cfe..1d8c862b077 100644 --- a/packages/agent-core-v2/test/agent/modeMutex/modeMutex.test.ts +++ b/packages/agent-core-v2/test/agent/modeMutex/modeMutex.test.ts @@ -8,14 +8,19 @@ 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 { AgentStatusUpdated } from '#/agent/usage/usageEvents'; import { IEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; +import { GoalUpdated } from '#/features/goal/goalOps'; +import { IAgentGoalService } from '#/features/goal/goalService'; +import type { GoalSnapshot } from '#/features/goal/types'; import { IAgentPlanService } from '#/features/plan/plan'; import { PlanModeEnter, planKey } from '#/features/plan/planOps'; import { IAgentSwarmService } from '#/features/swarm/agent/swarm'; import { SwarmModeEnter } from '#/features/swarm/swarmOps'; import { IAgentTowerService } from '#/features/tower/tower'; import { TowerModeEnter } from '#/features/tower/towerOps'; +import { IEventDispatcher } from '#/state/eventDispatcher'; import { registerTestAgentWire, testWireScope } from '../../wire/stubs'; @@ -25,8 +30,11 @@ describe('AgentModeMutexService', () => { let planExit: ReturnType; let swarmExit: ReturnType; let towerExit: ReturnType; + let goalPause: ReturnType; + let dispatch: ReturnType; let swarmActive: boolean; let towerActive: boolean; + let goalStatus: string | undefined; beforeEach(() => { disposables = new DisposableStore(); @@ -39,8 +47,11 @@ describe('AgentModeMutexService', () => { planExit = vi.fn(); swarmExit = vi.fn(); towerExit = vi.fn(); + goalPause = vi.fn(); + dispatch = vi.fn(); swarmActive = false; towerActive = false; + goalStatus = undefined; ix.stub(IAgentPlanService, { exit: planExit } as unknown as IAgentPlanService); ix.stub(IAgentSwarmService, { exit: swarmExit, @@ -54,13 +65,18 @@ describe('AgentModeMutexService', () => { return towerActive; }, } as unknown as IAgentTowerService); + ix.stub(IAgentGoalService, { + pauseGoal: goalPause, + getGoal: () => ({ goal: goalStatus === undefined ? null : { status: goalStatus } }), + } as unknown as IAgentGoalService); + ix.stub(IEventDispatcher, { dispatch } as unknown as IEventDispatcher); ix.get(IAgentStateService).contributeState(planKey); ix.set(IAgentModeMutexService, new SyncDescriptor(AgentModeMutexService)); ix.get(IAgentModeMutexService); }); afterEach(() => disposables.dispose()); - function publish(event: PlanModeEnter | SwarmModeEnter | TowerModeEnter): void { + function publish(event: PlanModeEnter | SwarmModeEnter | TowerModeEnter | GoalUpdated): void { const agentContext = ix.get(IAgentScopeContext).agentContext; ix.get(IEventBus).publish(event, agentContext); } @@ -69,6 +85,7 @@ describe('AgentModeMutexService', () => { towerActive = true; publish(new PlanModeEnter({ agentId: 'test-agent', id: 'plan_1' })); expect(towerExit).toHaveBeenCalledTimes(1); + expect(towerExit).toHaveBeenCalledWith('mode-mutex'); }); it('plan mode entry leaves an inactive tower mode alone', () => { @@ -80,6 +97,7 @@ describe('AgentModeMutexService', () => { towerActive = true; publish(new SwarmModeEnter({ agentId: 'test-agent', trigger: 'manual' })); expect(towerExit).toHaveBeenCalledTimes(1); + expect(towerExit).toHaveBeenCalledWith('mode-mutex'); }); it('swarm mode entry leaves an inactive tower mode alone', () => { @@ -100,4 +118,60 @@ describe('AgentModeMutexService', () => { expect(planExit).not.toHaveBeenCalled(); expect(swarmExit).not.toHaveBeenCalled(); }); + + it('tower mode entry pauses an active goal', () => { + goalStatus = 'active'; + publish(new TowerModeEnter({ agentId: 'test-agent' })); + expect(goalPause).toHaveBeenCalledTimes(1); + }); + + it('tower mode entry leaves a non-active goal alone', () => { + goalStatus = 'paused'; + publish(new TowerModeEnter({ agentId: 'test-agent' })); + expect(goalPause).not.toHaveBeenCalled(); + }); + + it('goal activation exits an active tower mode with the mode-mutex reason', () => { + towerActive = true; + publish( + new GoalUpdated({ + agentId: 'test-agent', + snapshot: { status: 'active' } as GoalSnapshot, + }), + ); + expect(towerExit).toHaveBeenCalledTimes(1); + expect(towerExit).toHaveBeenCalledWith('mode-mutex'); + expect(dispatch).toHaveBeenCalledTimes(1); + const status = dispatch.mock.calls[0]![0] as AgentStatusUpdated; + expect(status).toBeInstanceOf(AgentStatusUpdated); + expect(status.agentId).toBe('test-agent'); + expect(status.towerMode).toBe(false); + expect(status.towerExitReason).toBe('mode-mutex'); + expect(dispatch.mock.invocationCallOrder[0]).toBeLessThan( + towerExit.mock.invocationCallOrder[0]!, + ); + }); + + it('goal activation leaves an inactive tower mode alone', () => { + publish( + new GoalUpdated({ + agentId: 'test-agent', + snapshot: { status: 'active' } as GoalSnapshot, + }), + ); + expect(towerExit).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('a non-active goal update does not exit tower mode', () => { + towerActive = true; + publish( + new GoalUpdated({ + agentId: 'test-agent', + snapshot: { status: 'paused' } as GoalSnapshot, + }), + ); + expect(towerExit).not.toHaveBeenCalled(); + expect(dispatch).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 e21568965e6..3eb39f15412 100644 --- a/packages/agent-core-v2/test/features/tower/store.test.ts +++ b/packages/agent-core-v2/test/features/tower/store.test.ts @@ -11,7 +11,13 @@ import { STATE_FILE, TowerProtocolError, TowerStore, + branchExists, + commitPaths, + isAncestor, + isWorktreeDirty, + mergeNoFf, parseFrontmatter, + scopesOverlap, worktreeAddNewBranch, } from '../../../src/features/tower/protocol'; import type { @@ -71,6 +77,7 @@ async function setupMission(input: { file: string; content: string; deps?: string[]; + status?: TowerMission['status']; }): Promise { const missions = await store.plan([ { title: input.title, scope: [input.scope], deps: input.deps }, @@ -79,6 +86,12 @@ async function setupMission(input: { const state = await store.load(); await store.addWorktree(mission.worktree, mission.branch, state.base); await commitFile(worktreeOf(mission), input.file, input.content, `work on ${mission.id}`); + await store.updateMission( + 'tower', + mission.id, + { status: input.status ?? 'completed' }, + { silent: true }, + ); return mission; } @@ -93,6 +106,10 @@ async function spliceMissionIntoState(mission: TowerMission): Promise { await writeFile(file, `${JSON.stringify(state, null, 2)}\n`); } +async function completeMission(mission: TowerMission): Promise { + await store.updateMission('tower', mission.id, { status: 'completed' }, { silent: true }); +} + async function cleanReview(reviewer: string, target: string): Promise { await store.submitReview(reviewer, { target, @@ -296,22 +313,28 @@ describe('init', () => { expect(await store.isInitialized()).toBe(false); }); - it('ignores a conflicting base on re-init and keeps the recorded one', async () => { + it('re-anchors the base on re-init with an explicit different one', async () => { await git(repo, 'branch', 'develop'); await store.init(); const second = await store.init(undefined, 'develop'); expect(second).toEqual({ - base: 'main', + base: 'develop', created: false, retiredAgents: [], checkout: 'main', - ignoredBase: 'develop', + rebasedFrom: 'main', + rebaseDigest: { + from: 'main', + to: 'develop', + baseTip: await git(repo, 'rev-parse', 'develop'), + missions: [], + }, openMissions: [], }); const state = await store.load(); - expect(state.base).toBe('main'); + expect(state.base).toBe('develop'); }); }); @@ -367,6 +390,155 @@ describe('release', () => { }); }); +describe('state loss degradation', () => { + async function destroyComms(): Promise { + await rm(join(repo, '.tower/comms'), { recursive: true, force: true }); + } + + it('reports never-initialized (not state-lost) when .tower never existed', async () => { + expect(await store.isInitialized()).toBe(false); + expect(await store.isStateLost()).toBe(false); + await expect(store.load()).rejects.toThrow( + 'tower is not initialized in this repository — run TowerInit first', + ); + await expect(store.loadOrRecover()).rejects.toThrow( + 'tower is not initialized in this repository — run TowerInit first', + ); + await expect(store.merge('feat/x')).rejects.toThrow( + 'tower is not initialized in this repository — run TowerInit first', + ); + }); + + it('detects state loss when comms is deleted but .tower survives', async () => { + await store.init(); + await destroyComms(); + + expect(await store.isInitialized()).toBe(false); + expect(await store.isStateLost()).toBe(true); + await expect(store.load()).rejects.toThrow('tower state was lost'); + await expect(store.load()).rejects.not.toThrow('not initialized'); + }); + + it('refuses locked mutations like merge and rebaseMission with an explicit state-lost error, recovering nothing', async () => { + await store.init(); + const mission = await setupMission({ + title: 'engine work', + scope: 'src/engine/**', + file: 'src/engine/a.ts', + content: 'export {}\n', + }); + await store.registerAgent( + rosterEntry({ name: 'rev', kind: 'reviewer', reviewTarget: mission.branch }), + ); + await cleanReview('rev', mission.branch); + await destroyComms(); + + await expect(store.merge(mission.branch)).rejects.toThrow('tower state was lost'); + await expect(store.merge(mission.branch)).rejects.not.toThrow('not initialized'); + await expect(store.rebaseMission('tower', mission.id)).rejects.toThrow('tower state was lost'); + + expect(await store.isInitialized()).toBe(false); + expect(await git(repo, 'rev-parse', '--abbrev-ref', 'HEAD')).toBe('main'); + }); + + it('recovers a minimal state stamped recoveredAt with the current checkout branch as base', async () => { + await store.init(); + await git(repo, 'checkout', '-b', 'hotfix'); + await destroyComms(); + + const state = await store.loadOrRecover(); + + expect(state.base).toBe('hotfix'); + expect(state.mode).toBe('branch'); + expect(state.recoveredAt).toBeTruthy(); + expect(state.missions).toEqual([]); + expect(state.roster.agents).toEqual([]); + for (const sub of ['inbox', 'findings', 'reviews', 'missions', 'log']) { + expect((await stat(join(repo, '.tower/comms', sub))).isDirectory()).toBe(true); + } + expect((await stat(join(repo, '.tower/comms/MISSIONS.md'))).isFile()).toBe(true); + const log = await readFile(join(repo, '.tower/comms/log/activity.log'), 'utf8'); + expect(log).toContain('state.recovered'); + expect(log).toContain('base=hotfix'); + + expect((await store.load()).recoveredAt).toBe(state.recoveredAt); + expect((await store.loadOrRecover()).recoveredAt).toBe(state.recoveredAt); + }); + + it('recovers exactly once under concurrent loadOrRecover calls', async () => { + await store.init(); + await destroyComms(); + + const [a, b] = await Promise.all([store.loadOrRecover(), store.loadOrRecover()]); + + expect(a.recoveredAt).toBeTruthy(); + expect(a.recoveredAt).toBe(b.recoveredAt); + }); + + it('registers a placeholder roster entry for an unknown caller of a recovered state', async () => { + await store.init(); + await destroyComms(); + const recovered = await store.loadOrRecover(); + + const resolved = await store.resolveMessagingCaller(recovered, 'agent-99'); + + expect(resolved.caller).toBe('agent-99'); + expect(resolved.placeholder).toBe(true); + expect(resolved.state.roster.agents).toEqual([ + expect.objectContaining({ + name: 'agent-99', + agentId: 'agent-99', + kind: 'worker', + spawnedAt: recovered.recoveredAt, + }), + ]); + const log = await readFile(join(repo, '.tower/comms/log/activity.log'), 'utf8'); + expect(log).toContain('roster.placeholder'); + + const again = await store.resolveMessagingCaller(resolved.state, 'agent-99'); + expect(again.caller).toBe('agent-99'); + expect(again.placeholder).toBeUndefined(); + expect((await store.load()).roster.agents).toHaveLength(1); + + const asTower = await store.resolveMessagingCaller(resolved.state, 'main'); + expect(asTower.caller).toBe('tower'); + expect(asTower.placeholder).toBeUndefined(); + }); + + it('still rejects unknown callers when the state was never recovered', async () => { + await store.init(); + + await expect(store.resolveMessagingCaller(await store.load(), 'agent-99')).rejects.toThrow( + 'not a tower participant', + ); + }); + + it('delivers a degraded TowerComplete report without marking any mission completed', async () => { + await store.init(); + await destroyComms(); + const recovered = await store.loadOrRecover(); + const { caller } = await store.resolveMessagingCaller(recovered, 'agent-99'); + + const result = await store.complete(caller, 'the work is done — full details'); + + expect(result.mission).toBeUndefined(); + expect(result.recoveredAt).toBe(recovered.recoveredAt); + expect(result.notificationCreated).toBe(true); + expect(result.message?.item).toMatchObject({ + from: 'agent-99', + to: 'tower', + subject: 'completion-report-state-lost', + body: 'the work is done — full details', + }); + + const retry = await store.complete(caller, 'the work is done — full details'); + expect(retry.notificationCreated).toBe(false); + expect(retry.message?.item.file).toBe(result.message?.item.file); + + expect((await store.load()).missions).toEqual([]); + }); +}); + describe('markAgentDied', () => { it('marks the roster entry and appends an activity log line', async () => { await store.init('session-a'); @@ -465,12 +637,71 @@ describe('rebase', () => { expect(log).toContain('to=develop'); }); - it('refuses while missions are open and keeps the recorded base', async () => { + it('re-anchors even while missions are open and logs the open count', async () => { await git(repo, 'branch', 'develop'); await store.init('session-a', 'main'); await store.plan([{ title: 'engine', scope: ['src/engine/**'] }]); - await expect(store.rebase('develop')).rejects.toThrow('cannot rebase'); + await store.rebase('develop'); + + const state = await store.load(); + expect(state.base).toBe('develop'); + expect(state.missions).toHaveLength(1); + const log = await readFile(join(repo, '.tower/comms/log/activity.log'), 'utf8'); + expect(log).toContain('rebase'); + expect(log).toContain('open_missions=1'); + }); + + it('returns exact per-mission re-anchor relations and branch tips', async () => { + await store.init('session-a', 'main'); + const [current, stale, planned] = await store.plan([ + { title: 'current work', scope: ['src/current/**'] }, + { title: 'stale work', scope: ['src/stale/**'] }, + { title: 'planned work', scope: ['src/planned/**'] }, + ]); + const state = await store.load(); + await store.addWorktree(current!.worktree, current!.branch, state.base); + await store.addWorktree(stale!.worktree, stale!.branch, state.base); + await commitFile(worktreeOf(current!), 'src/current/current.ts', 'current\n', 'current work'); + await commitFile(worktreeOf(stale!), 'src/stale/stale.ts', 'stale\n', 'stale work'); + await git(repo, 'branch', '-f', 'develop', current!.branch); + + const digest = await store.rebase('develop'); + + expect(digest).toEqual({ + from: 'main', + to: 'develop', + baseTip: await git(repo, 'rev-parse', current!.branch), + missions: [ + { + id: current!.id, + branch: current!.branch, + relation: 'up-to-date', + branchTip: await git(repo, 'rev-parse', current!.branch), + }, + { + id: stale!.id, + branch: stale!.branch, + relation: 'stale', + branchTip: await git(repo, 'rev-parse', stale!.branch), + }, + { id: planned!.id, branch: planned!.branch, relation: 'no-branch' }, + ], + }); + expect((await store.load()).base).toBe('develop'); + }); + + it('does not save a new base when a required mission observation fails', async () => { + await git(repo, 'branch', 'develop'); + await store.init('session-a', 'main'); + await store.plan([{ title: 'broken observation', scope: ['src/broken/**'] }]); + await writeFile(join(repo, '.git/refs/heads/broken'), `${'0'.repeat(40)}\n`); + const state = await store.load(); + state.missions[0] = { ...state.missions[0]!, branch: 'broken' }; + await writeFile(store.abs(STATE_FILE), `${JSON.stringify(state, null, 2)}\n`); + + await expect(store.rebase('develop')).rejects.toThrow(); + expect((await store.load()).base).toBe('main'); }); @@ -549,13 +780,50 @@ describe('plan', () => { expect(missionFile).not.toContain('## Context'); }); - it('rejects overlapping scopes', async () => { - const attempt = store.plan([ + it('serializes overlapping scopes with an auto dependency instead of rejecting them', async () => { + const missions = await store.plan([ { title: 'outer', scope: ['src/a/**'] }, { title: 'inner', scope: ['src/a/b/**'] }, ]); - await expect(attempt).rejects.toThrow(TowerProtocolError); - await expect(attempt).rejects.toThrow(/scopes overlap/); + expect(missions.map((m) => m.id)).toEqual(['M1', 'M2']); + expect(missions[1]!.deps).toEqual(['M1']); + expect(missions[1]!.notes.join('\n')).toContain('auto dependency on M1'); + + const index = await readFile(join(repo, '.tower/comms/MISSIONS.md'), 'utf8'); + expect(index).toContain('M1 → M2'); + }); + + it('rejects whole-repo scopes', async () => { + const patterns = ['**', '**/', '**/*', '*']; + for (const [i, raw] of patterns.entries()) { + await expect(store.plan([{ title: `everything ${i}`, scope: [raw] }])).rejects.toThrow( + /covers the whole repo/, + ); + } + }); + + it('auto-dependencies on overlap chain transitively and gate the merge order', async () => { + const first = await setupMission({ + title: 'first config', + scope: 'config/**', + file: 'config/settings.go', + content: 'package config\n', + }); + const second = await setupMission({ + title: 'second config', + scope: 'config/**', + file: 'config/settings.go', + content: 'package config\n// second\n', + }); + expect(second.deps).toEqual([first.id]); + + await store.registerAgent( + rosterEntry({ name: 'rev-2', kind: 'reviewer', reviewTarget: second.branch }), + ); + await cleanReview('rev-2', second.branch); + await expect(store.merge(second.branch)).rejects.toThrow( + new RegExp(`dependencies not merged yet \\(${first.id}\\)`), + ); }); it('rejects deps referencing unknown missions', async () => { @@ -565,15 +833,18 @@ describe('plan', () => { }); it('lets new missions reuse the scope of an already-merged mission', async () => { - await store.plan([{ title: 'survey', scope: ['src/a/**'] }]); - await store.updateMission('tower', 'M1', { status: 'merged' }); + const [survey] = await store.plan([{ title: 'survey', scope: ['src/a/**'], kind: 'survey' }]); + const state = await store.load(); + await store.addWorktree(survey!.worktree, survey!.branch, state.base); + await completeMission(survey!); + await store.merge(survey!.branch); const missions = await store.plan([{ title: 'implement', scope: ['src/a/b/**'] }]); expect(missions[0]?.id).toBe('M2'); + expect(missions[0]?.deps).toEqual([]); - await expect(store.plan([{ title: 'clash', scope: ['src/a/**'] }])).rejects.toThrow( - /scopes overlap/, - ); + const [clash] = await store.plan([{ title: 'clash', scope: ['src/a/**'] }]); + expect(clash!.deps).toEqual(['M2']); }); it('survey missions reserve no scope and may overlap builds and each other', async () => { @@ -584,9 +855,10 @@ describe('plan', () => { ]); expect(missions.map((m) => m.kind)).toEqual(['survey', 'survey', 'build']); - await expect( - store.plan([{ title: 'touch layers too', scope: ['src/layer/vulkan/shader/**'] }]), - ).rejects.toThrow(/scopes overlap/); + const [overlap] = await store.plan([ + { title: 'touch layers too', scope: ['src/layer/vulkan/shader/**'] }, + ]); + expect(overlap!.deps).toEqual(['M3']); }); it('rejects a new mission whose slugged branch collides with an existing mission, even a closed one', async () => { @@ -701,33 +973,6 @@ describe('inbox send', () => { expect(ref).toBe(rel); expect((await stat(join(repo, ref!))).isFile()).toBe(true); }); - - it('stamps the sender token count into the frontmatter and the activity log', async () => { - const rel = await store.send('tower', { - to: 'w1', - subject: 'get started', - body: 'please start on M1', - tokens: 12345, - }); - - const { fields } = parseFrontmatter(await readFile(join(repo, rel), 'utf8')); - expect(fields['tokens']).toBe('12345'); - - const log = await readFile(join(repo, '.tower/comms/log/activity.log'), 'utf8'); - const sendLine = log.split('\n').find((line) => line.includes('inbox.send')); - expect(sendLine).toContain('tokens=12345'); - }); - - it('records tokens as -1 when the sender usage is unavailable', async () => { - const rel = await store.send('tower', { - to: 'w1', - subject: 'get started', - body: 'please start on M1', - }); - - const { fields } = parseFrontmatter(await readFile(join(repo, rel), 'utf8')); - expect(fields['tokens']).toBe('-1'); - }); }); describe('readInbox', () => { @@ -792,25 +1037,6 @@ describe('inbox read tracking', () => { ); }); - it('markInboxRead advances to the newest seen sent_at, falls back to now, and never regresses', async () => { - await store.registerAgent( - rosterEntry({ name: 'w1', kind: 'worker', spawnedAt: '2026-09-20T10:00:00.000Z' }), - ); - - await store.markInboxRead('w1', '2026-09-21T00:00:00.000Z'); - expect((await store.load()).roster.agents[0]?.lastInboxReadAt).toBe('2026-09-21T00:00:00.000Z'); - - await store.markInboxRead('w1', '2026-09-20T12:00:00.000Z'); - expect((await store.load()).roster.agents[0]?.lastInboxReadAt).toBe('2026-09-21T00:00:00.000Z'); - - await store.markInboxRead('w1'); - const at = (await store.load()).roster.agents[0]?.lastInboxReadAt; - expect(Date.parse(at!)).toBeGreaterThan(Date.parse('2026-09-21T00:00:00.000Z')); - - await store.markInboxRead('ghost', '2030-01-01T00:00:00.000Z'); - expect((await store.load()).roster.agents).toHaveLength(1); - }); - it('refuses a worker completion while unread inbox messages wait and names the count', async () => { const mission = await seedOwnedMission(); await store.registerAgent( @@ -826,7 +1052,7 @@ describe('inbox read tracking', () => { expect((await store.load()).missions.find((m) => m.id === mission.id)?.status).toBe('planned'); }); - it('lets the worker complete once the inbox is read', async () => { + it('lets the worker complete once every returned message is acknowledged', async () => { const mission = await seedOwnedMission(); await store.send('tower', { to: 'w1', subject: 'requirement change', body: 'add a poem' }); @@ -834,8 +1060,8 @@ describe('inbox read tracking', () => { /unread inbox message/, ); - const items = await store.readInbox('w1', 20); - await store.markInboxRead('w1', items[0]?.sentAt); + const items = await store.readUnreadInbox('w1', 20); + expect(await store.ackInbox('w1', items.map((item) => item.messageId))).toBe(0); const completed = await store.updateMission('w1', mission.id, { status: 'completed' }); expect(completed.status).toBe('completed'); @@ -850,6 +1076,32 @@ describe('inbox read tracking', () => { ); }); + it('tracks broadcast acknowledgements independently for each recipient', async () => { + await seedOwnedMission(); + await store.registerAgent( + rosterEntry({ name: 'w2', kind: 'worker', spawnedAt: '2026-09-20T00:00:00.000Z' }), + ); + await store.send('tower', { to: 'all', subject: 'policy update', body: 'new rule' }); + + const [message] = await store.readUnreadInbox('w1', 20); + expect(message?.subject).toBe('policy update'); + expect(await store.ackInbox('w1', [message!.messageId])).toBe(0); + + expect(await store.readUnreadInbox('w1', 20)).toHaveLength(0); + expect((await store.readUnreadInbox('w2', 20)).map((item) => item.subject)).toEqual([ + 'policy update', + ]); + expect((await store.readUnreadInbox('tower', 20)).map((item) => item.subject)).toEqual([ + 'policy update', + ]); + const state = await store.load(); + expect(state.roster.agents.find((agent) => agent.name === 'w1')?.inboxAckIds).toEqual([ + message!.messageId, + ]); + expect(state.roster.agents.find((agent) => agent.name === 'w2')?.inboxAckIds).toBeUndefined(); + expect(state.inboxAckIds).toBeUndefined(); + }); + it('does not block on messages that predate the worker registration', async () => { await store.send('tower', { to: 'all', subject: 'old broadcast', body: 'before spawn' }); const mission = await seedOwnedMission(new Date().toISOString()); @@ -858,6 +1110,40 @@ describe('inbox read tracking', () => { expect(completed.status).toBe('completed'); }); + it('uses stable fallback IDs for legacy files without resurrecting cutoff history', async () => { + await store.registerAgent( + rosterEntry({ + name: 'w1', + kind: 'worker', + spawnedAt: '2026-09-20T00:00:00.000Z', + lastInboxReadAt: '2026-09-21T00:00:00.000Z', + }), + ); + const oldRel = join('.tower/comms/inbox', 'legacy-old.md'); + const newRel = join('.tower/comms/inbox', 'legacy-new.md'); + await writeFile( + store.abs(oldRel), + '---\ntype: inbox\nfrom: tower\nto: w1\nsubject: already cutoff\nsent_at: 2026-09-20T12:00:00.000Z\n---\n\nold\n', + ); + await writeFile( + store.abs(newRel), + '---\ntype: inbox\nfrom: tower\nto: w1\nsubject: after cutoff\nsent_at: 2026-09-22T00:00:00.000Z\n---\n\nnew\n', + ); + + const history = await store.readInbox('w1', 20); + expect(history.find((item) => item.file === oldRel)?.messageId).toBe(oldRel); + expect(history.find((item) => item.file === newRel)?.messageId).toBe(newRel); + const unread = await store.readUnreadInbox('w1', 20); + expect(unread.map((item) => item.subject)).toEqual(['after cutoff']); + expect(await store.ackInbox('w1', unread.map((item) => item.messageId))).toBe(0); + expect(await store.readUnreadInbox('w1', 20)).toHaveLength(0); + + const state = await store.load(); + expect(state.version).toBe(1); + expect(state.roster.agents[0]?.lastInboxReadAt).toBe('2026-09-21T00:00:00.000Z'); + expect(state.roster.agents[0]?.inboxAckIds).toEqual([newRel]); + }); + it('does not gate the tower completing a mission with unread worker messages', async () => { const mission = await seedOwnedMission(); await store.send('tower', { to: 'w1', subject: 'requirement change', body: 'add a poem' }); @@ -902,29 +1188,6 @@ describe('findings', () => { expect(text).toContain('**Type**: bug'); expect(text).toContain('**Severity**: high'); }); - - it('stamps the reporter token count into the finding and defaults to -1', async () => { - const rel = await store.fileFinding('w1', { - type: 'bug', - title: 'leaky cache', - summary: 'the cache never invalidates', - details: 'no eviction path exists', - suggestedFix: 'add a ttl', - tokens: 4321, - }); - const withTokens = await readFile(join(repo, rel), 'utf8'); - expect(withTokens).toContain('**Tokens**: 4321'); - - const relDefault = await store.fileFinding('w1', { - type: 'improve', - title: 'second finding', - summary: 's', - details: 'd', - suggestedFix: 'f', - }); - const withoutTokens = await readFile(join(repo, relDefault), 'utf8'); - expect(withoutTokens).toContain('**Tokens**: -1'); - }); }); describe('merge gate', () => { @@ -952,6 +1215,7 @@ describe('merge gate', () => { findings: 'one nit to fix', decision: 'fix first', }); + await store.updateMission('tower', mission.id, { status: 'completed' }, { silent: true }); await expect(store.merge(mission.branch)).rejects.toThrow(/clean round is required/); await cleanReview('rev', mission.branch); @@ -1052,43 +1316,6 @@ describe('merge gate', () => { ); }); - it('stamps the reviewer token count into the review frontmatter and defaults to -1', 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 store.submitReview('rev', { - target: mission.branch, - status: 'clean', - merge: 'merge', - findings: 'none', - decision: 'ok', - tokens: 777, - }); - const withTokens = await store.latestReview(mission.branch); - expect( - parseFrontmatter(await readFile(join(repo, withTokens!.file), 'utf8')).fields['tokens'], - ).toBe('777'); - - await store.submitReview('rev', { - target: mission.branch, - status: 'clean', - merge: 'merge', - findings: 'none', - decision: 'ok again', - }); - const withoutTokens = await store.latestReview(mission.branch); - expect( - parseFrontmatter(await readFile(join(repo, withoutTokens!.file), 'utf8')).fields['tokens'], - ).toBe('-1'); - }); - it('refuses to merge while dependency missions are unmerged', async () => { const base = await setupMission({ title: 'base lib', @@ -1116,6 +1343,9 @@ describe('merge gate', () => { await cleanReview('rev-base', base.branch); await store.merge(base.branch); + await expect(store.merge(consumer.branch)).rejects.toThrow(/behind base "main"/); + const rebased = await store.rebaseMission('tower', consumer.id); + expect(rebased.status).toBe('rebased'); await store.merge(consumer.branch); const state = await store.load(); expect(state.missions.find((m) => m.id === consumer.id)?.status).toBe('merged'); @@ -1136,21 +1366,27 @@ describe('merge gate', () => { ); await cleanReview('rev', mission.branch); - await expect(store.merge(mission.branch)).rejects.toThrow(/outside mission M1 scope/); + await expect( + store.merge(mission.branch, { activeAgentIds: new Set() }), + ).rejects.toThrow(/outside mission M1 scope/); await store.plan([{ title: 'other', scope: ['src/other/**'] }]); await expect( store.updateMission('w1', mission.id, { scope: ['src/x/**', 'src/outside.ts'] }), ).rejects.toThrow(/cannot change mission scope/); - await expect( - store.updateMission('tower', mission.id, { scope: ['src/x/**', 'src/other/**'] }), - ).rejects.toThrow(/scopes overlap/); + const widened = await store.updateMission('tower', mission.id, { + scope: ['src/x/**', 'src/other/**'], + }); + expect(widened.deps).toEqual(['M2']); + expect(widened.notes.join('\n')).toContain('auto dependency on M2'); + + await store.updateMission('tower', 'M2', { status: 'abandoned' }); await store.updateMission('tower', mission.id, { scope: ['src/x/**', 'src/outside.ts'] }); const log = (await store.recentLog(5)).join('\n'); expect(log).toContain('mission.update'); expect(log).toContain('scope=src/x/**,src/outside.ts'); - await store.merge(mission.branch); + await store.merge(mission.branch, { activeAgentIds: new Set() }); }); it('merge reports unmerged branches that changed the same files', async () => { @@ -1193,6 +1429,7 @@ describe('merge gate', () => { await store.addWorktree(mission!.worktree, mission!.branch, state.base); const baseTip = await git(repo, 'rev-parse', 'HEAD'); + await completeMission(mission!); const result = await store.merge(mission!.branch); expect(result.noop).toBe(true); expect(result.mergeCommit).toBe(baseTip); @@ -1201,6 +1438,25 @@ describe('merge gate', () => { expect(await git(repo, 'rev-parse', 'HEAD')).toBe(baseTip); }); + it('enforces a zero-diff survey mission\'s own open dependencies', async () => { + const [, survey] = await store.plan([ + { title: 'build dependency', scope: ['src/a/**'] }, + { title: 'survey dependent', scope: ['src/a/**'], kind: 'survey', deps: ['M1'] }, + ]); + const state = await store.load(); + await store.addWorktree(survey!.worktree, survey!.branch, state.base); + await completeMission(survey!); + + await expect(store.merge(survey!.branch)).rejects.toThrow( + /dependencies not merged yet \(M1\)/, + ); + expect((await store.load()).missions.find((mission) => mission.id === survey!.id)?.status).toBe('completed'); + + await store.updateMission('tower', 'M1', { status: 'abandoned' }); + await store.merge(survey!.branch); + expect((await store.load()).missions.find((mission) => mission.id === survey!.id)?.status).toBe('merged'); + }); + it('refuses to merge a survey branch that has changes', async () => { const [mission] = await store.plan([ { title: 'scan layer', scope: ['src/layer/**'], kind: 'survey' }, @@ -1208,6 +1464,9 @@ describe('merge gate', () => { const state = await store.load(); await store.addWorktree(mission!.worktree, mission!.branch, state.base); await commitFile(worktreeOf(mission!), 'src/layer/notes.ts', 'oops\n', 'stray edit'); + const completed = await store.load(); + completed.missions[0]!.status = 'completed'; + await writeFile(store.abs(STATE_FILE), `${JSON.stringify(completed, null, 2)}\n`); await expect(store.merge(mission!.branch)).rejects.toThrow(/read-only/); expect((await store.load()).missions[0]?.status).not.toBe('merged'); @@ -1232,6 +1491,7 @@ describe('merge gate', () => { await cleanReview('rev', build.branch); await expect(store.merge(build.branch)).rejects.toThrow(/dependencies not merged yet/); + await completeMission(survey!); await store.merge(survey!.branch); await store.merge(build.branch); expect((await store.load()).missions.find((m) => m.id === build.id)?.status).toBe('merged'); @@ -1245,6 +1505,7 @@ describe('merge gate', () => { const state = await store.load(); await store.addWorktree(followUp!.worktree, followUp!.branch, state.base); await commitFile(worktreeOf(followUp!), 'src/b/b.ts', 'b\n', 'work on M2'); + await completeMission(followUp!); await store.registerAgent( rosterEntry({ name: 'rev', kind: 'reviewer', reviewTarget: followUp!.branch }), ); @@ -1267,6 +1528,7 @@ describe('merge gate', () => { await store.addWorktree(second!.worktree, second!.branch, state.base); await commitFile(worktreeOf(first!), 'src/a/shared.ts', 'from first\n', 'first'); await commitFile(worktreeOf(second!), 'src/a/shared.ts', 'from second\n', 'second strays'); + await completeMission(first!); await store.registerAgent( rosterEntry({ name: 'rev', kind: 'reviewer', reviewTarget: first!.branch }), ); @@ -1279,6 +1541,7 @@ describe('merge gate', () => { const [third] = await store.plan([{ title: 'third', scope: ['src/a/**'] }]); await store.addWorktree(third!.worktree, third!.branch, state.base); await commitFile(worktreeOf(third!), 'src/a/shared.ts', 'from third\n', 'third'); + await completeMission(third!); await store.registerAgent( rosterEntry({ name: 'rev3', kind: 'reviewer', reviewTarget: third!.branch }), ); @@ -1332,6 +1595,7 @@ describe('merge gate', () => { const state = await store.load(); await store.addWorktree(first!.worktree, first!.branch, state.base); await commitFile(worktreeOf(first!), 'src/x/x.ts', 'x\n', 'work on M1'); + await completeMission(first!); const second: TowerMission = { ...first!, id: 'M2', @@ -1394,6 +1658,7 @@ describe('merge gate', () => { const state = await store.load(); await store.addWorktree(mission!.worktree, mission!.branch, state.base); await commitFile(worktreeOf(mission!), 'src/x/x.ts', 'x\n', 'work on M1'); + await completeMission(mission!); await store.registerAgent( rosterEntry({ name: 'rev', kind: 'reviewer', reviewTarget: mission!.branch }), ); @@ -1545,8 +1810,9 @@ describe('merge gate', () => { findings: 'a real problem', decision: 'do not merge', }); + await completeMission(mission); - await expect(store.merge(mission.branch)).rejects.toThrow(/clean round is required/); + await expect(store.merge(mission.branch)).rejects.toThrow(/says hold/); expect((await store.load()).missions.find((m) => m.id === mission.id)?.status).not.toBe( 'merged', ); @@ -1573,6 +1839,7 @@ describe('merge gate', () => { decision: 'do not merge', }); await cleanReview('rev-new', mission.branch); + await completeMission(mission); await store.merge(mission.branch); @@ -1607,8 +1874,9 @@ describe('merge gate', () => { const now = new Date(); await utimes(store.abs(earlier.file), now, now); await utimes(store.abs(later.file), new Date(now.getTime() - 60_000), new Date(now.getTime() - 60_000)); + await completeMission(mission); - await expect(store.merge(mission.branch)).rejects.toThrow(/clean round is required/); + await expect(store.merge(mission.branch)).rejects.toThrow(/says hold/); }); it('stamps a monotonically increasing submission sequence on reviews', async () => { @@ -1652,25 +1920,507 @@ describe('merge gate', () => { const log = await readFile(join(repo, '.tower/comms/log/activity.log'), 'utf8'); expect(log).toContain('merge.blocked'); }); -}); - -describe('rework loop closure', () => { - beforeEach(async () => { - await store.init(); - }); - async function missionIn( - status: 'planned' | 'active' | 'completed' | 'blocked' | 'paused', - title = 'feature x', - ) { + it('refuses a mission that is not completed even with a clean review', async () => { const mission = await setupMission({ - title, - scope: `src/${title.replaceAll(' ', '-')}/**`, - file: `src/${title.replaceAll(' ', '-')}/x.ts`, + title: 'feature x', + scope: 'src/x/**', + file: 'src/x/x.ts', content: 'x\n', + status: 'active', }); - if (status === 'blocked') { - await store.updateMission('tower', mission.id, { blocker: 'waiting on an answer' }); + await store.registerAgent( + rosterEntry({ name: 'rev', kind: 'reviewer', reviewTarget: mission.branch }), + ); + await cleanReview('rev', mission.branch); + + await expect(store.merge(mission.branch)).rejects.toThrow(/only a completed mission/); + }); + + it('refuses a completed mission that still has an open task', 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); + const state = await store.load(); + state.missions.find((candidate) => candidate.id === mission.id)!.tasks.push({ + text: 'unfinished', + done: false, + }); + await writeFile(store.abs(STATE_FILE), `${JSON.stringify(state, null, 2)}\n`); + + await expect(store.merge(mission.branch)).rejects.toThrow(/1 open task.*unfinished/); + }); + + it('refuses a completed mission that still has blockers', 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); + const state = await store.load(); + state.missions.find((candidate) => candidate.id === mission.id)!.blockers.push('still blocked'); + await writeFile(store.abs(STATE_FILE), `${JSON.stringify(state, null, 2)}\n`); + + await expect(store.merge(mission.branch)).rejects.toThrow(/1 blocker.*still blocked/); + }); + + it('fails closed for unknown worker runtime and refuses busy workers before accepting idle workers', async () => { + const mission = await setupMission({ + title: 'feature x', + scope: 'src/x/**', + file: 'src/x/x.ts', + content: 'x\n', + }); + const worker = rosterEntry({ name: 'w1', kind: 'worker', missionId: mission.id }); + await store.registerAgent(worker); + await store.registerAgent( + rosterEntry({ name: 'rev', kind: 'reviewer', reviewTarget: mission.branch }), + ); + await cleanReview('rev', mission.branch); + + await expect(store.merge(mission.branch)).rejects.toThrow(/unknown is not idle/); + await expect( + store.merge(mission.branch, { activeAgentIds: new Set([worker.agentId]) }), + ).rejects.toThrow(/mid-turn/); + await store.merge(mission.branch, { activeAgentIds: new Set() }); + + expect((await store.load()).missions.find((candidate) => candidate.id === mission.id)?.status).toBe('merged'); + }); + + it('requires the latest applicable review to be clean with merge approval', 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 store.submitReview('rev', { + target: mission.branch, + status: 'clean', + merge: 'hold', + findings: 'none', + decision: 'wait', + }); + await expect(store.merge(mission.branch)).rejects.toThrow(/says hold/); + + await store.submitReview('rev', { + target: mission.branch, + status: 'clean', + merge: 'fix-then-merge', + findings: 'none', + decision: 'fix first', + }); + await expect(store.merge(mission.branch)).rejects.toThrow(/fix-then-merge is not approval/); + + await cleanReview('rev', mission.branch); + await store.merge(mission.branch); + expect((await store.load()).missions.find((candidate) => candidate.id === mission.id)?.status).toBe('merged'); + }); + + it('keeps accepting a legacy clean review without a merge field', 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); + const review = (await store.latestReview(mission.branch))!; + const raw = await readFile(store.abs(review.file), 'utf8'); + await writeFile(store.abs(review.file), raw.replace(/^merge:.*\r?\n/m, '')); + + await store.merge(mission.branch); + + expect((await store.load()).missions.find((candidate) => candidate.id === mission.id)?.status).toBe('merged'); + }); + + it('merges only the evaluated SHA and reports an advanced branch without marking the mission merged', 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); + const evaluated = await git(repo, 'rev-parse', mission.branch); + const worktree = worktreeOf(mission); + await writeFile( + join(repo, '.git/hooks/post-merge'), + [ + '#!/bin/sh', + 'unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE', + `printf 'export const later = 2;\\n' > "${worktree}/src/x/later.ts"`, + `git -C "${worktree}" add src/x/later.ts`, + `git -C "${worktree}" commit -m 'worker advances during merge'`, + '', + ].join('\n'), + { mode: 0o755 }, + ); + + const result = await store.merge(mission.branch); + const advanced = await git(repo, 'rev-parse', mission.branch); + + expect(result.status).toBe('advanced'); + expect(result.evaluatedBranchTip).toBe(evaluated); + expect(result.currentBranchTip).toBe(advanced); + expect(advanced).not.toBe(evaluated); + expect(await git(repo, 'rev-parse', 'HEAD^2')).toBe(evaluated); + expect(await git(repo, 'log', '-1', '--format=%s')).toBe(`Merge branch '${mission.branch}'`); + await expect(git(repo, 'merge-base', '--is-ancestor', advanced, 'HEAD')).rejects.toThrow(); + expect((await store.load()).missions.find((candidate) => candidate.id === mission.id)?.status).toBe('completed'); + expect((await store.recentLog(3)).join('\n')).toContain('merge.advanced'); + }); +}); + +describe('rebaseMission', () => { + beforeEach(async () => { + await store.init(); + }); + + it('refuses non-tower callers', async () => { + const mission = await setupMission({ + title: 'feature x', + scope: 'src/x/**', + file: 'src/x/x.ts', + content: 'x\n', + }); + await expect(store.rebaseMission('w1', mission.id)).rejects.toThrow(/only the tower/); + }); + + it('reports up-to-date when the branch already contains the base tip', async () => { + const mission = await setupMission({ + title: 'feature x', + scope: 'src/x/**', + file: 'src/x/x.ts', + content: 'x\n', + }); + const result = await store.rebaseMission('tower', mission.id); + expect(result.status).toBe('up-to-date'); + }); + + it('rebases a stale branch cleanly and the merge gate waives the re-review', 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 commitFile(repo, 'docs/readme.md', 'docs\n', 'base moves on'); + + await expect(store.merge(mission.branch)).rejects.toThrow(/behind base "main"/); + const result = await store.rebaseMission('tower', mission.id); + expect(result.status).toBe('rebased'); + + await store.merge(mission.branch); + expect((await store.load()).missions.find((m) => m.id === mission.id)?.status).toBe('merged'); + const log = (await store.recentLog(10)).join('\n'); + expect(log).toContain('rebase.mission'); + expect(log).toContain('review_waived=yes'); + }); + + it('a worker commit after the tower rebase voids the review waiver', 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 commitFile(repo, 'docs/readme.md', 'docs\n', 'base moves on'); + await store.rebaseMission('tower', mission.id); + + await commitFile(worktreeOf(mission), 'src/x/x.ts', 'x2\n', 'worker adds more'); + await expect(store.merge(mission.branch)).rejects.toThrow(/moved since the clean review/); + }); + + it('aborts a conflicting rebase, marks the mission blocked, and reports the conflicted files', async () => { + const mission = await setupMission({ + title: 'feature x', + scope: 'src/x/**', + file: 'src/x/shared.ts', + content: 'from branch\n', + }); + const tipBefore = await git(repo, 'rev-parse', mission.branch); + await commitFile(repo, 'src/x/shared.ts', 'from base\n', 'base touches the same file'); + + const result = await store.rebaseMission('tower', mission.id); + expect(result.status).toBe('conflict'); + expect(result.files).toEqual(['src/x/shared.ts']); + + const after = (await store.load()).missions.find((m) => m.id === mission.id)!; + expect(after.status).toBe('blocked'); + expect(after.blockers.join('\n')).toContain('src/x/shared.ts'); + expect(await git(worktreeOf(mission), 'status', '--porcelain')).toBe(''); + expect(await git(repo, 'rev-parse', mission.branch)).toBe(tipBefore); + }); + + it('refuses while the worktree has uncommitted changes', async () => { + const mission = await setupMission({ + title: 'feature x', + scope: 'src/x/**', + file: 'src/x/x.ts', + content: 'x\n', + }); + await commitFile(repo, 'docs/readme.md', 'docs\n', 'base moves on'); + await writeFile(join(worktreeOf(mission), 'src/x/x.ts'), 'dirty\n'); + + await expect(store.rebaseMission('tower', mission.id)).rejects.toThrow( + /uncommitted changes/, + ); + }); + + it('requires authoritative idle runtime for every mission worker', async () => { + const mission = await setupMission({ + title: 'feature x', + scope: 'src/x/**', + file: 'src/x/x.ts', + content: 'x\n', + }); + const worker = rosterEntry({ name: 'w1', kind: 'worker', missionId: mission.id }); + await store.registerAgent(worker); + await commitFile(repo, 'docs/readme.md', 'docs\n', 'base moves on'); + + await expect(store.rebaseMission('tower', mission.id)).rejects.toThrow(/unknown is not idle/); + await expect( + store.rebaseMission('tower', mission.id, { activeAgentIds: new Set([worker.agentId]) }), + ).rejects.toThrow(/mid-turn/); + const result = await store.rebaseMission('tower', mission.id, { activeAgentIds: new Set() }); + expect(result.status).toBe('rebased'); + }); + + it('refuses a worktree whose current branch does not match the mission', async () => { + const mission = await setupMission({ + title: 'feature x', + scope: 'src/x/**', + file: 'src/x/x.ts', + content: 'x\n', + }); + await git(worktreeOf(mission), 'checkout', '--detach', 'HEAD'); + + await expect(store.rebaseMission('tower', mission.id)).rejects.toThrow( + /not mission branch/, + ); + expect((await store.load()).missions.find((candidate) => candidate.id === mission.id)?.lastRebase).toBeUndefined(); + }); + + it('refuses a worktree whose HEAD differs from the mission branch tip', async () => { + const mission = await setupMission({ + title: 'feature x', + scope: 'src/x/**', + file: 'src/x/x.ts', + content: 'x\n', + }); + const worktree = worktreeOf(mission); + await commitFile(worktree, 'src/x/more.ts', 'more\n', 'branch advances'); + const { stdout } = await execFileAsync('which', ['git']); + const realGit = stdout.trim(); + const wrapperDir = await mkdtemp(join(tmpdir(), 'tower-git-head-wrapper-')); + const previousPath = process.env['PATH'] ?? ''; + try { + await writeFile( + join(wrapperDir, 'git'), + [ + '#!/bin/sh', + 'if [ "$1" = "rev-parse" ] && [ "$2" = "HEAD" ]; then', + ` exec '${realGit}' rev-parse HEAD~1`, + 'fi', + `exec '${realGit}' "$@"`, + '', + ].join('\n'), + { mode: 0o755 }, + ); + process.env['PATH'] = `${wrapperDir}:${previousPath}`; + + await expect(store.rebaseMission('tower', mission.id)).rejects.toThrow( + /does not match mission branch/, + ); + } finally { + process.env['PATH'] = previousPath; + await rm(wrapperDir, { recursive: true, force: true }); + } + expect((await store.load()).missions.find((candidate) => candidate.id === mission.id)?.lastRebase).toBeUndefined(); + }); + + it('does not mark a mission blocked for a non-conflict rebase failure', async () => { + const mission = await setupMission({ + title: 'feature x', + scope: 'src/x/**', + file: 'src/x/x.ts', + content: 'x\n', + }); + await commitFile(repo, 'docs/readme.md', 'docs\n', 'base moves on'); + await writeFile(join(repo, '.git/hooks/pre-rebase'), '#!/bin/sh\nexit 1\n', { mode: 0o755 }); + + await expect(store.rebaseMission('tower', mission.id)).rejects.toThrow( + /no rebase conflict with unmerged paths/, + ); + const after = (await store.load()).missions.find((candidate) => candidate.id === mission.id)!; + expect(after.status).toBe('completed'); + expect(after.blockers).toEqual([]); + expect(after.lastRebase).toBeUndefined(); + }); + + it('reports recovery-required without claiming recovery when rebase abort fails', async () => { + const mission = await setupMission({ + title: 'feature x', + scope: 'src/x/shared.ts', + file: 'src/x/shared.ts', + content: 'from branch\n', + }); + await commitFile(repo, 'src/x/shared.ts', 'from base\n', 'base touches the same file'); + const { stdout } = await execFileAsync('which', ['git']); + const realGit = stdout.trim(); + const wrapperDir = await mkdtemp(join(tmpdir(), 'tower-git-wrapper-')); + const previousPath = process.env['PATH'] ?? ''; + try { + await writeFile( + join(wrapperDir, 'git'), + [ + '#!/bin/sh', + 'if [ "$1" = "rebase" ] && [ "$2" = "--abort" ]; then', + ' exit 97', + 'fi', + `exec '${realGit}' "$@"`, + '', + ].join('\n'), + { mode: 0o755 }, + ); + process.env['PATH'] = `${wrapperDir}:${previousPath}`; + + await expect(store.rebaseMission('tower', mission.id)).rejects.toThrow(/recovery-required/); + } finally { + process.env['PATH'] = previousPath; + await rm(wrapperDir, { recursive: true, force: true }); + } + + const after = (await store.load()).missions.find((candidate) => candidate.id === mission.id)!; + expect(after.status).toBe('completed'); + expect(after.blockers).toEqual([]); + expect(after.lastRebase).toBeUndefined(); + await git(worktreeOf(mission), 'rebase', '--abort'); + }); + + it('runs the whole rebase under the tower state lock', async () => { + const mission = await setupMission({ + title: 'feature x', + scope: 'src/x/**', + file: 'src/x/x.ts', + content: 'x\n', + }); + const locked = new TowerStore(repo, { stateLockTimeoutMs: 100, stateLockPollMs: 10 }); + const lockPath = `${store.abs(STATE_FILE)}.lock`; + await writeFile(lockPath, 'pid=999999 since=2026-09-30T00:00:00.000Z token=other'); + try { + await expect(locked.rebaseMission('tower', mission.id)).rejects.toThrow( + /timed out.*tower state lock/, + ); + } finally { + await rm(lockPath, { force: true }); + } + expect((await store.load()).missions.find((candidate) => candidate.id === mission.id)?.lastRebase).toBeUndefined(); + }); + + it('mergeNoFf aborts and restores a non-conflicting merge rejected by a commit hook', async () => { + await git(repo, 'checkout', '-b', 'hook-failure'); + await commitFile(repo, 'hook.txt', 'side\n', 'non-conflicting side change'); + await git(repo, 'checkout', 'main'); + const headBefore = await git(repo, 'rev-parse', 'HEAD'); + const statusBefore = await git(repo, 'status', '--porcelain'); + await writeFile(join(repo, '.git/hooks/commit-msg'), '#!/bin/sh\nexit 1\n', { mode: 0o755 }); + + await expect(mergeNoFf(repo, 'hook-failure')).rejects.toThrow(); + + expect(await git(repo, 'rev-parse', 'HEAD')).toBe(headBefore); + expect(await git(repo, 'status', '--porcelain')).toBe(statusBefore); + await expect(git(repo, 'rev-parse', '--verify', '--quiet', 'MERGE_HEAD')).rejects.toThrow(); + await expect(stat(join(repo, 'hook.txt'))).rejects.toThrow(); + }); + + it('mergeNoFf refuses to start over an existing merge and leaves it untouched', async () => { + await git(repo, 'checkout', '-b', 'existing-merge'); + await commitFile(repo, 'existing.txt', 'side\n', 'existing merge work'); + await git(repo, 'checkout', 'main'); + await git(repo, 'merge', '--no-ff', '--no-commit', 'existing-merge'); + const mergeHead = await git(repo, 'rev-parse', 'MERGE_HEAD'); + const headBefore = await git(repo, 'rev-parse', 'HEAD'); + const statusBefore = await git(repo, 'status', '--porcelain'); + + await expect(mergeNoFf(repo, 'existing-merge')).rejects.toThrow(/MERGE_HEAD already exists/); + + expect(await git(repo, 'rev-parse', 'MERGE_HEAD')).toBe(mergeHead); + expect(await git(repo, 'rev-parse', 'HEAD')).toBe(headBefore); + expect(await git(repo, 'status', '--porcelain')).toBe(statusBefore); + await git(repo, 'merge', '--abort'); + }); + + it('mergeNoFf aborts a conflicting merge and leaves the checkout clean', async () => { + await git(repo, 'checkout', '-b', 'conflicting'); + await commitFile(repo, 'README.md', '# conflicting\n', 'conflict change'); + await git(repo, 'checkout', 'main'); + const mainTip = await git(repo, 'rev-parse', 'HEAD'); + await commitFile(repo, 'README.md', '# main change\n', 'main change'); + + await expect(mergeNoFf(repo, 'conflicting')).rejects.toThrow(); + expect(await git(repo, 'status', '--porcelain')).toBe(''); + expect(await git(repo, 'rev-parse', 'HEAD')).toBe(await git(repo, 'rev-parse', 'main')); + expect(mainTip).not.toBe(await git(repo, 'rev-parse', 'HEAD')); + }); + + it('propagates git query failures instead of reporting clean, missing, or not-ancestor', async () => { + const missing = join(repo, 'missing-worktree'); + + await expect(branchExists(missing, 'main')).rejects.toThrow(); + await expect(isAncestor(repo, 'missing-ref', 'HEAD')).rejects.toThrow(); + await expect(isWorktreeDirty(missing)).rejects.toThrow(); + }); +}); + +describe('rework loop closure', () => { + beforeEach(async () => { + await store.init(); + }); + + async function missionIn( + status: 'planned' | 'active' | 'completed' | 'blocked' | 'paused', + title = 'feature x', + ) { + const mission = await setupMission({ + title, + scope: `src/${title.replaceAll(' ', '-')}/**`, + file: `src/${title.replaceAll(' ', '-')}/x.ts`, + content: 'x\n', + status, + }); + if (status === 'blocked') { + await store.updateMission('tower', mission.id, { blocker: 'waiting on an answer' }); } else if (status !== 'planned') { await store.updateMission('tower', mission.id, { status }); } @@ -1690,7 +2440,7 @@ describe('rework loop closure', () => { }); } - it('flips a completed mission back to active when a non-clean verdict lands', async () => { + it('flips a completed mission back to active for rework and lets it complete again', async () => { const mission = await missionIn('completed'); await nonCleanReview(mission.branch); @@ -1709,6 +2459,9 @@ describe('rework loop closure', () => { expect(index).toContain('🔵'); const log = await readFile(join(repo, '.tower/comms/log/activity.log'), 'utf8'); expect(log).toContain('mission.rework'); + expect((await store.updateMission('tower', mission.id, { status: 'completed' })).status).toBe( + 'completed', + ); }); it('keeps a completed mission completed on a clean verdict', async () => { @@ -1761,6 +2514,7 @@ describe('rework loop closure', () => { scope: 'src/y/**', file: 'src/y/y.ts', content: 'y\n', + status: 'planned', }); await store.registerAgent( rosterEntry({ name: 'rev-y', kind: 'reviewer', reviewTarget: planned.branch }), @@ -1790,6 +2544,181 @@ describe('rework loop closure', () => { }); }); +describe('review notification recipients', () => { + beforeEach(async () => { + await store.init(); + }); + + async function setupMissionForReview(options: { readonly withOwner?: boolean } = {}) { + const mission = await setupMission({ + title: 'feature x', + scope: 'src/x/**', + file: 'src/x/x.ts', + content: 'x\n', + }); + if (options.withOwner !== false) { + await store.registerAgent( + rosterEntry({ name: 'w1', kind: 'worker', missionId: mission.id }), + ); + await store.updateMission('tower', mission.id, { owner: 'w1' }, { silent: true }); + } + await store.registerAgent( + rosterEntry({ + name: 'rev', + kind: 'reviewer', + reviewTarget: mission.branch, + reviewMissionId: mission.id, + }), + ); + return mission; + } + + it('notifies only the tower for a clean verdict and returns the structured review', async () => { + const mission = await setupMissionForReview(); + + const result = await store.submitReview('rev', { + target: mission.branch, + status: 'clean', + merge: 'merge', + findings: 'none', + decision: 'ok', + }); + + expect(result.review).toMatchObject({ target: mission.branch, round: 1, mission: mission.id }); + expect(result.storedMessages.map((message) => message.item.to)).toEqual(['tower']); + expect(result.notificationError).toBeUndefined(); + }); + + it('notifies a valid open-mission owner and the tower with full review metadata', async () => { + const mission = await setupMissionForReview(); + + const result = await store.submitReview('rev', { + target: mission.branch, + status: 'p1-1items', + merge: 'hold', + findings: 'broken', + decision: 'fix it', + }); + + expect(result.storedMessages.map((message) => message.item.to)).toEqual(['w1', 'tower']); + const ownerMessage = result.storedMessages[0]!; + expect(ownerMessage.item.subject).toBe('review-result'); + expect(ownerMessage.item.scope).toBe(mission.id); + expect(ownerMessage.item.action).toBe('review-result'); + expect(ownerMessage.item.body).toContain(`file: ${result.review.file}`); + expect(ownerMessage.item.body).toContain(`target: ${mission.branch}`); + expect(ownerMessage.item.body).toContain(`mission: ${mission.id}`); + expect(ownerMessage.item.body).toContain('round: 1'); + expect(ownerMessage.item.body).toContain('verdict: p1-1items'); + expect(ownerMessage.item.body).toContain('merge recommendation: hold'); + expect(ownerMessage.item.body).toContain(`reviewedCommit: ${result.review.reviewedCommit}`); + expect(ownerMessage.item.body).toContain('owner: w1'); + expect((await store.load()).missions.find((candidate) => candidate.id === mission.id)?.status).toBe('active'); + }); + + it('uses only the tower when there is no valid owner', async () => { + const mission = await setupMissionForReview({ withOwner: false }); + + const result = await store.submitReview('rev', { + target: mission.branch, + status: 'p2-1items', + merge: 'fix-then-merge', + findings: 'nit', + decision: 'fix later', + }); + + expect(result.storedMessages.map((message) => message.item.to)).toEqual(['tower']); + }); + + it('does not wake the author or reopen a closed mission', async () => { + const mission = await setupMissionForReview(); + await store.updateMission('tower', mission.id, { status: 'abandoned' }); + + const result = await store.submitReview('rev', { + target: mission.branch, + status: 'p1-1items', + merge: 'hold', + findings: 'late problem', + decision: 'late review', + }); + + expect(result.storedMessages.map((message) => message.item.to)).toEqual(['tower']); + expect(result.storedMessages.some((message) => message.item.to === 'w1')).toBe(false); + expect((await store.load()).missions.find((candidate) => candidate.id === mission.id)?.status).toBe('abandoned'); + }); + + it('never sends a tower-originated review to the tower itself', async () => { + const mission = await setupMissionForReview(); + + const clean = await store.submitReview('tower', { + target: mission.branch, + status: 'clean', + merge: 'merge', + findings: 'none', + decision: 'ok', + }); + const nonClean = await store.submitReview('tower', { + target: mission.branch, + status: 'p1-1items', + merge: 'hold', + findings: 'problem', + decision: 'fix it', + }); + + expect(clean.storedMessages).toEqual([]); + expect(nonClean.storedMessages.map((message) => message.item.to)).toEqual(['w1']); + }); +}); + +describe('complete and review concurrency', () => { + beforeEach(async () => { + await store.init(); + }); + + it('serializes complete and non-clean review without losing review-result', async () => { + const mission = await setupMission({ + title: 'feature x', + scope: 'src/x/**', + file: 'src/x/x.ts', + content: 'x\n', + status: 'active', + }); + await store.registerAgent( + rosterEntry({ name: 'w1', kind: 'worker', missionId: mission.id }), + ); + await store.updateMission('tower', mission.id, { owner: 'w1' }, { silent: true }); + await store.registerAgent( + rosterEntry({ + name: 'rev', + kind: 'reviewer', + reviewTarget: mission.branch, + reviewMissionId: mission.id, + }), + ); + + const [reviewOutcome, completeOutcome] = await Promise.allSettled([ + store.submitReview('rev', { + target: mission.branch, + status: 'p1-1items', + merge: 'hold', + findings: 'problem', + decision: 'fix it', + }), + store.complete('w1', 'race report'), + ]); + + expect(reviewOutcome.status).toBe('fulfilled'); + if (completeOutcome.status === 'rejected') { + expect(String(completeOutcome.reason)).toContain('unread inbox message'); + } + const state = await store.load(); + expect(state.missions.find((candidate) => candidate.id === mission.id)?.status).toBe('active'); + const messages = await store.readInbox('tower', 50); + expect(messages.filter((item) => item.subject === 'review-result' && item.to === 'w1')).toHaveLength(1); + expect(messages.filter((item) => item.subject === 'review-result' && item.to === 'tower')).toHaveLength(1); + }); +}); + describe('dirty base checkout', () => { beforeEach(async () => { await store.init(); @@ -1915,6 +2844,7 @@ describe('dirty base checkout', () => { 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 completeMission(mission!); await cleanReview('rev', mission!.branch); await expect(store.merge(mission!.branch)).rejects.toThrow( @@ -1928,6 +2858,8 @@ describe('dirty base checkout', () => { await git(repo, 'add', 'wip.ts'); await git(repo, 'commit', '-m', 'commit my wip'); + const rebased = await store.rebaseMission('tower', mission!.id); + expect(rebased.status).toBe('rebased'); 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'); @@ -1944,6 +2876,7 @@ describe('dirty base checkout', () => { 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 completeMission(mission!); await store.registerAgent( rosterEntry({ name: 'rev', kind: 'reviewer', reviewTarget: mission!.branch }), ); @@ -2016,6 +2949,16 @@ describe('updateMission', () => { expect(withTask.tasks.find((t) => t.text === 'scaffold')?.done).toBe(true); }); + it('rejects direct merged patches from both the owner worker and the tower', async () => { + await expect(store.updateMission('w1', 'M1', { status: 'merged' })).rejects.toThrow( + /only TowerMerge/, + ); + await expect(store.updateMission('tower', 'M1', { status: 'merged' })).rejects.toThrow( + /only TowerMerge/, + ); + expect((await store.load()).missions[0]?.status).toBe('planned'); + }); + it('rejects task_done matching no open task', async () => { await expect(store.updateMission('w1', 'M1', { taskDone: 'no such task' })).rejects.toThrow( /no open task matching "no such task"/, @@ -2041,7 +2984,12 @@ describe('updateMission', () => { }); it('logs merge refusals with their reason', async () => { - await store.plan([{ title: 'gamma', scope: ['src/gamma/**'] }]); + await setupMission({ + title: 'gamma', + scope: 'src/gamma/**', + file: 'src/gamma/gamma.ts', + content: 'gamma\n', + }); await expect(store.merge('feat/gamma')).rejects.toThrow(/no review/); const log = (await store.recentLog(5)).join('\n'); expect(log).toContain('merge.blocked'); @@ -2076,26 +3024,75 @@ describe('updateMission', () => { expect(log).toContain('status=abandoned'); }); - it('frees an abandoned mission\'s scope for new plans', async () => { - await expect(store.plan([{ title: 'gamma', scope: ['src/alpha/**'] }])).rejects.toThrow( - /scopes overlap/, + it('does not reopen an abandoned mission but allows an exact no-op', async () => { + await store.updateMission('tower', 'M1', { status: 'abandoned' }); + + await expect(store.updateMission('tower', 'M1', { status: 'active' })).rejects.toThrow( + /terminal/, + ); + await expect(store.updateMission('w1', 'M1', { status: 'planned' })).rejects.toThrow( + /terminal/, ); + await expect(store.updateMission('w1', 'M1', { blocker: 'reopen' })).rejects.toThrow( + /terminal/, + ); + expect((await store.updateMission('tower', 'M1', { status: 'abandoned' })).status).toBe( + 'abandoned', + ); + }); + + it('does not reopen or reclassify a merged mission but allows an exact no-op', async () => { + const state = await store.load(); + const mission = state.missions[0]!; + await store.addWorktree(mission.worktree, mission.branch, state.base); + await commitFile(worktreeOf(mission), 'src/alpha/x.ts', 'x\n', 'work on M1'); + await store.updateMission('tower', mission.id, { taskDone: 'scaffold' }); + await store.updateMission('tower', mission.id, { taskDone: 'implement' }); + await completeMission(mission); + await store.registerAgent( + rosterEntry({ + name: 'rev', + kind: 'reviewer', + reviewTarget: mission.branch, + reviewMissionId: mission.id, + }), + ); + await cleanReview('rev', mission.branch); + await store.merge(mission.branch, { activeAgentIds: new Set() }); + + await expect(store.updateMission('tower', 'M1', { status: 'active' })).rejects.toThrow( + /terminal/, + ); + await expect(store.updateMission('tower', 'M1', { status: 'abandoned' })).rejects.toThrow( + /terminal/, + ); + expect((await store.updateMission('tower', 'M1', { status: 'merged' })).status).toBe('merged'); + }); + + it('serializes new plans into an open mission\'s scope with an auto dependency, and frees it on abandon', async () => { + const [overlapping] = await store.plan([{ title: 'gamma overlap', scope: ['src/alpha/**'] }]); + expect(overlapping!.deps).toEqual(['M1']); await store.updateMission('tower', 'M1', { status: 'abandoned' }); + const [chained] = await store.plan([{ title: 'gamma chained', scope: ['src/alpha/**'] }]); + expect(chained!.deps).toEqual(['M3']); - const [gamma] = await store.plan([{ title: 'gamma', scope: ['src/alpha/**'] }]); - expect(gamma!.id).toBe('M3'); + await store.updateMission('tower', 'M3', { status: 'abandoned' }); + await store.updateMission('tower', 'M4', { status: 'abandoned' }); + const [gamma] = await store.plan([{ title: 'gamma fresh', scope: ['src/alpha/**'] }]); + expect(gamma!.id).toBe('M5'); + expect(gamma!.deps).toEqual([]); }); it('frees an abandoned mission\'s scope for scope patches', async () => { - await expect( - store.updateMission('tower', 'M2', { scope: ['src/alpha/**'] }), - ).rejects.toThrow(/scopes overlap/); + const patched = await store.updateMission('tower', 'M2', { scope: ['src/alpha/**'] }); + expect(patched.deps).toEqual(['M1']); await store.updateMission('tower', 'M1', { status: 'abandoned' }); - const patched = await store.updateMission('tower', 'M2', { scope: ['src/alpha/**'] }); - expect(patched.scope).toEqual(['src/alpha/**']); + const repatched = await store.updateMission('tower', 'M2', { scope: ['src/alpha/**'] }); + expect(repatched.scope).toEqual(['src/alpha/**']); + expect(repatched.deps).toEqual(['M1']); }); }); @@ -2971,3 +3968,35 @@ describe('addWorktree branch ownership', () => { expect(await readFile(join(wt, 'src/x/x.ts'), 'utf8')).toBe('x\n'); }); }); + + +describe('scopesOverlap', () => { + it('detects identical and nested directory globs', () => { + expect(scopesOverlap(['src/a/**'], ['src/a/**'])).toBe(true); + expect(scopesOverlap(['src/a/**'], ['src/a/b/**'])).toBe(true); + expect(scopesOverlap(['src/a/b/**'], ['src/a/**'])).toBe(true); + expect(scopesOverlap(['src/a/**'], ['src/b/**'])).toBe(false); + }); + + it('normalizes trailing slashes and only treats path-boundary prefixes as overlapping', () => { + expect(scopesOverlap(['src/a/'], ['src/a'])).toBe(true); + expect(scopesOverlap(['src/a'], ['src/ab'])).toBe(false); + expect(scopesOverlap(['src/ab'], ['src/a'])).toBe(false); + }); + + it('treats single-level star globs as their parent directory, conservatively including deeper paths', () => { + expect(scopesOverlap(['src/*'], ['src/foo.ts'])).toBe(true); + expect(scopesOverlap(['src/*'], ['src/foo/bar/**'])).toBe(true); + }); + + it('does not detect overlap for mid-path wildcards (stem heuristic limitation)', () => { + expect(scopesOverlap(['src/*.ts'], ['src/foo.ts'])).toBe(false); + expect(scopesOverlap(['src/**/foo.ts'], ['src/a/foo.ts'])).toBe(false); + }); + + it('skips empty stems and empty scope lists', () => { + expect(scopesOverlap(['**'], ['src/a/**'])).toBe(false); + expect(scopesOverlap([], ['src/a/**'])).toBe(false); + expect(scopesOverlap(['src/a/**'], [])).toBe(false); + }); +}); 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 f3345007c59..2ac2b1660a8 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 @@ -15,7 +15,7 @@ import type { PermissionMode } from '#/agent/permissionPolicy/types'; import type { AgentContext } from '#/agent/agentContext/agentContext'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentTaskService } from '#/agent/task/task'; -import { STATE_FILE, TowerStore } from '#/features/tower/protocol/index'; +import { STATE_FILE, TowerProtocolError, TowerStore } from '#/features/tower/protocol/index'; import type { TowerState } from '#/features/tower/protocol/index'; import { IAgentTowerService } from '#/features/tower/tower'; import { ITowerRateLimitService } from '#/features/tower/towerRateLimit'; @@ -71,6 +71,7 @@ describe('TowerSpawnTool', () => { let store: TowerStore; let towerActive: boolean; + let branchLeaseError: string | undefined; let gate: { readonly ok: true } | { readonly ok: false; readonly reason: string }; let release: Mock<() => void>; let createAgent: Mock; @@ -104,6 +105,7 @@ describe('TowerSpawnTool', () => { await store.plan([{ title: 'Build gemm', scope: ['src/**'] }]); towerActive = true; + branchLeaseError = undefined; gate = { ok: true }; release = vi.fn(); completion = deferred(); @@ -132,11 +134,13 @@ describe('TowerSpawnTool', () => { get isActive() { return towerActive; }, - get requestedBase() { - return undefined; - }, enter: () => Promise.resolve({ entered: true as const }), exit: () => {}, + isBranchLeased: () => branchLeaseError !== undefined, + withBranchLease: async (_branch: string, execute: () => Promise) => { + if (branchLeaseError !== undefined) throw new TowerProtocolError(branchLeaseError); + return execute(); + }, } as unknown as IAgentTowerService); ix.stub(ITowerRateLimitService, { acquire: () => gate, @@ -236,6 +240,44 @@ describe('TowerSpawnTool', () => { expect(createAgent).not.toHaveBeenCalled(); }); + it('respects a branch lease before worktree or agent side effects', async () => { + branchLeaseError = 'branch feat/build-gemm is leased by a merge'; + + const result = await execute(WORKER_ARGS); + + expect(result.isError).toBe(true); + expect(result.output).toContain('leased by a merge'); + expect(createAgent).not.toHaveBeenCalled(); + expect(runAgent).not.toHaveBeenCalled(); + expect(registerTask).not.toHaveBeenCalled(); + await expect(git(repo, 'rev-parse', '--verify', 'refs/heads/feat/build-gemm')).rejects.toThrow(); + }); + + it.each(['merged', 'abandoned'] as const)( + 'refuses a %s mission before any worktree, agent, task, or roster side effect', + async (status) => { + const state = await store.load(); + state.missions.find((mission) => mission.id === 'M1')!.status = status; + await writeFile(store.abs(STATE_FILE), `${JSON.stringify(state, null, 2)}\n`); + + const result = await execute(WORKER_ARGS); + + expect(result.isError).toBe(true); + expect(result.output).toContain(`mission M1 is ${status}`); + expect(result.output).toContain('terminal missions cannot spawn workers'); + expect(createAgent).not.toHaveBeenCalled(); + expect(runAgent).not.toHaveBeenCalled(); + expect(registerTask).not.toHaveBeenCalled(); + expect(release).not.toHaveBeenCalled(); + const after = await store.load(); + expect(after.roster.agents).toHaveLength(0); + const mission = after.missions.find((candidate) => candidate.id === 'M1'); + expect(mission?.status).toBe(status); + expect(mission?.owner).toBeUndefined(); + await expect(git(repo, 'rev-parse', '--verify', 'refs/heads/feat/build-gemm')).rejects.toThrow(); + }, + ); + it('records the death of a worker whose task settled before roster registration finished', async () => { taskInfoLookup = () => ({ taskId: 'task-1', @@ -298,6 +340,22 @@ describe('TowerSpawnTool', () => { expect(state.roster.agents).toHaveLength(0); }); + it('aborts the launched run when post-registration setup fails', async () => { + const forced = vi + .spyOn(TowerStore.prototype, 'updateMission') + .mockRejectedValueOnce(new TowerProtocolError('forced post-launch failure')); + try { + const result = await execute(WORKER_ARGS); + + expect(result.isError).toBe(true); + expect(result.output).toContain('forced post-launch failure'); + expect(runAgent.mock.calls[0]?.[2]?.signal.aborted).toBe(true); + expect((await store.load()).roster.agents.some((agent) => agent.agentId === 'agent-7')).toBe(true); + } finally { + forced.mockRestore(); + } + }); + it('spawns a detached tower-worker, registers the roster entry, and releases the slot on settle', async () => { const result = await execute(WORKER_ARGS); @@ -536,6 +594,9 @@ describe('TowerSpawnTool', () => { expect(result.isError).toBeUndefined(); expect(result.output).toContain('review_target: feat/build-gemm'); + expect(result.output).toContain('A reviewer is one-shot'); + expect(result.output).toContain('spawn a fresh reviewer only if the mission still needs one'); + expect(result.output).not.toContain('Agent(resume='); const state = await store.load(); const entry = state.roster.agents.find((agent) => agent.name === 'reviewer-a'); expect(entry).toMatchObject({ @@ -590,6 +651,29 @@ describe('TowerSpawnTool', () => { expect(createAgent).not.toHaveBeenCalled(); }); + it('does not recommend resuming a duplicate reviewer', async () => { + await store.registerAgent({ + name: 'reviewer-a', + agentId: 'agent-reviewer-old', + kind: 'reviewer', + reviewTarget: 'feat/build-gemm', + reviewMissionId: 'M1', + spawnedAt: new Date().toISOString(), + }); + + const result = await execute({ + name: 'reviewer-a', + kind: 'reviewer', + review_target: 'feat/build-gemm', + }); + + expect(result.isError).toBe(true); + expect(result.output).toContain('a reviewer is one-shot'); + expect(result.output).toContain('spawn a fresh reviewer with a new name'); + expect(result.output).not.toContain('Agent(resume='); + expect(createAgent).not.toHaveBeenCalled(); + }); + it('refuses a reserved protocol name before any side effects', async () => { const result = await execute({ ...WORKER_ARGS, name: 'tower' }); @@ -715,10 +799,11 @@ describe('TowerSpawnTool', () => { 'call TowerInbox once and incorporate anything new into the delivery', ); expect(prompt).toContain( - 'the store refuses status="completed" while unread messages wait in your inbox', + 'TowerComplete refuses while unread messages wait in your inbox', ); expect(prompt).toContain('Re-read your mission too (TowerMission(id="M1") with no patch fields)'); - expect(prompt).toContain('split them into chunks and check TowerInbox between chunks'); + expect(prompt).toContain('Call TowerComplete(report=what you changed and why'); + expect(prompt).not.toContain('Mark the mission completed: TowerMission'); }); it('briefs the survey worker to read the inbox before completing', async () => { @@ -732,6 +817,8 @@ describe('TowerSpawnTool', () => { const prompt = (runAgent.mock.calls.at(-1)?.[1] as { prompt: string }).prompt; expect(prompt).toContain('# When the survey is done'); expect(prompt).toContain('Call TowerInbox once and fold anything new into your summary'); + expect(prompt).toContain('Call TowerComplete(report=the full survey result)'); + expect(prompt).not.toContain('subject="survey-summary"'); }); it('briefs the reviewer with the mission text and the worker self-report', async () => { @@ -766,6 +853,8 @@ describe('TowerSpawnTool', () => { expect(prompt).toContain("# The author's own account"); expect(prompt).toContain('Rewrote the intro; tone kept friendly, internals left out.'); expect(prompt).toContain('1. Intent'); + expect(prompt).toContain('Standard review-result notifications are stored automatically'); + expect(prompt).not.toContain('Notify the author with TowerSend'); }); it('falls back to the generic checklist when the review target owns no mission', async () => { 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 d6404819231..df85a13903a 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 @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { promisify } from 'node:util'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DisposableStore } from '#/_base/di/lifecycle'; import type { ServiceIdentifier } from '#/_base/di/instantiation'; @@ -12,16 +12,16 @@ import { createServices, type TestInstantiationService } from '#/_base/di/test'; import { userCancellationReason } from '#/_base/utils/abort'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentTaskService, type AgentTaskInfo } from '#/agent/task/task'; +import { IAgentLoopService } from '#/agent/loop/loop'; import type { AnyAgentTool } from '#/agent/toolRegistry/toolContribution'; import { ISessionManager } from '#/app/sessionManager/sessionManager'; import { TOWER_TOOL_CONTRIBUTIONS } from '#/features/tower/towerFeature'; import { IAgentTowerService } from '#/features/tower/tower'; import { ITowerRateLimitService } from '#/features/tower/towerRateLimit'; import { TowerStore, parseFrontmatter } from '#/features/tower/protocol/index'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import { ISessionUsageService } from '#/session/usage/sessionUsage'; import type { ExecutableTool } from '#/tool/toolContract'; -import type { TokenUsage } from '#human/llm/usage'; import { ITowerInitTool } from '#/features/tower/tools/init/init'; import { TowerInitTool } from '#/features/tower/tools/init/initTool'; @@ -29,6 +29,10 @@ import { ITowerPlanTool } from '#/features/tower/tools/plan/plan'; import { TowerPlanTool } from '#/features/tower/tools/plan/planTool'; import { ITowerMergeTool } from '#/features/tower/tools/merge/merge'; import { TowerMergeTool } from '#/features/tower/tools/merge/mergeTool'; +import { ITowerRebaseTool } from '#/features/tower/tools/rebase/rebase'; +import { TowerRebaseTool } from '#/features/tower/tools/rebase/rebaseTool'; +import { ITowerCompleteTool } from '#/features/tower/tools/complete/complete'; +import { TowerCompleteTool } from '#/features/tower/tools/complete/completeTool'; import { ITowerTeardownTool } from '#/features/tower/tools/teardown/teardown'; import { TowerTeardownTool } from '#/features/tower/tools/teardown/teardownTool'; import { ITowerSendTool } from '#/features/tower/tools/send/send'; @@ -42,6 +46,7 @@ import { TowerReviewTool } from '#/features/tower/tools/review/reviewTool'; import { ITowerMissionTool } from '#/features/tower/tools/mission/mission'; import { TowerMissionTool } from '#/features/tower/tools/mission/missionTool'; import { ITowerStatusTool } from '#/features/tower/tools/status/status'; +import { readTowerStatus, readTowerStatusSummary } from '#/features/tower/tools/status/statusReader'; import { TowerStatusTool } from '#/features/tower/tools/status/statusTool'; import { executeTool } from '../../../tools/fixtures/execute-tool'; @@ -74,13 +79,12 @@ let repo: string; let disposables: DisposableStore; let ix: TestInstantiationService; let towerActive: boolean; -let towerRequestedBase: string | undefined; let currentAgentId: string; let currentSessionId: string; let liveSessionIds: string[]; let liveAgentTaskIds: string[]; -let usageTotal: TokenUsage | undefined; const agentContexts = new Map(); +const agentLoops = new Map(); beforeEach(async () => { repo = await mkdtemp(join(tmpdir(), 'tower-tools-test-')); @@ -90,13 +94,12 @@ beforeEach(async () => { await commitFile(repo, 'README.md', '# fixture\n', 'initial'); towerActive = false; - towerRequestedBase = undefined; currentAgentId = 'main'; liveSessionIds = []; liveAgentTaskIds = []; - usageTotal = undefined; currentSessionId = 'session-test'; agentContexts.clear(); + agentLoops.clear(); disposables = new DisposableStore(); ix = createServices(disposables, { @@ -133,9 +136,6 @@ beforeEach(async () => { get isActive() { return towerActive; }, - get requestedBase() { - return towerRequestedBase; - }, enter: () => { towerActive = true; return Promise.resolve({ entered: true as const }); @@ -144,6 +144,8 @@ beforeEach(async () => { towerActive = false; return Promise.resolve(); }, + isBranchLeased: () => false, + withBranchLease: (_branch: string, execute: () => Promise) => execute(), }); reg.defineInstance(ISessionManager, { get: (id: string) => (liveSessionIds.includes(id) ? {} : undefined), @@ -151,19 +153,40 @@ beforeEach(async () => { reg.definePartialInstance(ITowerRateLimitService, { snapshot: () => ({ budget: 2, inflight: 0, blockedUntil: null }), }); - reg.definePartialInstance(ISessionUsageService, { - status: () => ({ total: usageTotal }), - }); reg.definePartialInstance(IAgentTaskService, { list: () => liveAgentTaskIds.map( (agentId) => ({ kind: 'agent', agentId }) as unknown as AgentTaskInfo, ), }); + reg.definePartialInstance(IAgentLifecycleService, { + handleOf: ((agentId: string) => { + const loop = agentLoops.get(agentId); + if (loop === undefined) return undefined; + return { + accessor: { + get: (token: unknown) => + token === IAgentLoopService + ? { + snapshot: () => ({ state: loop.state }), + submit: (entry: unknown) => { + const text = (entry as { message: { content: { text: string }[] } }) + .message.content[0]!.text; + loop.submitted.push(text); + return { id: 'prompt-1' }; + }, + } + : undefined, + }, + }; + }) as unknown as IAgentLifecycleService['handleOf'], + }); reg.define(ITowerInitTool, TowerInitTool); reg.define(ITowerPlanTool, TowerPlanTool); reg.define(ITowerMergeTool, TowerMergeTool); + reg.define(ITowerRebaseTool, TowerRebaseTool); reg.define(ITowerTeardownTool, TowerTeardownTool); + reg.define(ITowerCompleteTool, TowerCompleteTool); reg.define(ITowerSendTool, TowerSendTool); reg.define(ITowerInboxTool, TowerInboxTool); reg.define(ITowerFindingTool, TowerFindingTool); @@ -190,6 +213,26 @@ async function initViaTool() { return result; } +function readStatus() { + return readTowerStatus({ + active: towerActive, + cwd: repo, + agentId: currentAgentId, + tasks: ix.get(IAgentTaskService), + concurrency: () => ix.get(ITowerRateLimitService).snapshot(), + }); +} + +function readStatusSummary() { + return readTowerStatusSummary({ + active: towerActive, + cwd: repo, + agentId: currentAgentId, + tasks: ix.get(IAgentTaskService), + concurrency: () => ix.get(ITowerRateLimitService).snapshot(), + }); +} + describe('TowerInitTool', () => { it('refuses when tower mode is inactive — only the user can enable it', async () => { const result = await run(ix.get(ITowerInitTool), {}); @@ -225,41 +268,50 @@ describe('TowerInitTool', () => { expect(state.base).toBe('develop'); }); - it('falls back to the base requested when tower mode was enabled', async () => { + it('re-anchors the base when re-initializing with a different one', async () => { await git(repo, 'branch', 'develop'); - towerActive = true; - towerRequestedBase = 'develop'; + await initViaTool(); - const result = await run(ix.get(ITowerInitTool), {}); + const second = await run(ix.get(ITowerInitTool), { base: 'develop' }); - expect(result.isError).toBeFalsy(); - expect(result.output).toContain('base branch: develop'); + expect(second.isError).toBeFalsy(); + expect(second.output).toContain('base re-anchored from "main" to "develop"'); const state = await new TowerStore(repo).load(); expect(state.base).toBe('develop'); }); - it('prefers an explicit base over the requested one', async () => { - await git(repo, 'branch', 'develop'); - towerActive = true; - towerRequestedBase = 'main'; + it('prints the real re-anchor relation for every open mission', async () => { + await initViaTool(); + const store = new TowerStore(repo); + const [current, stale, planned] = await store.plan([ + { title: 'current work', scope: ['src/current/**'] }, + { title: 'stale work', scope: ['src/stale/**'] }, + { title: 'planned work', scope: ['src/planned/**'] }, + ]); + const state = await store.load(); + await store.addWorktree(current!.worktree, current!.branch, state.base); + await store.addWorktree(stale!.worktree, stale!.branch, state.base); + await commitFile( + join(repo, '.tower/worktrees', current!.worktree), + 'src/current/current.ts', + 'current\n', + 'current work', + ); + await commitFile( + join(repo, '.tower/worktrees', stale!.worktree), + 'src/stale/stale.ts', + 'stale\n', + 'stale work', + ); + await git(repo, 'branch', '-f', 'develop', current!.branch); const result = await run(ix.get(ITowerInitTool), { base: 'develop' }); expect(result.isError).toBeFalsy(); - const state = await new TowerStore(repo).load(); - expect(state.base).toBe('develop'); - }); - - it('reports an ignored base when re-initializing with a different one', async () => { - await git(repo, 'branch', 'develop'); - await initViaTool(); - - const second = await run(ix.get(ITowerInitTool), { base: 'develop' }); - - expect(second.isError).toBeFalsy(); - expect(second.output).toContain('requested base "develop" ignored'); - const state = await new TowerStore(repo).load(); - expect(state.base).toBe('main'); + expect(result.output).toContain(`mission ${current!.id} (${current!.branch}): up-to-date`); + expect(result.output).toContain(`mission ${stale!.id} (${stale!.branch}): stale`); + expect(result.output).toContain(`mission ${planned!.id} (${planned!.branch}): no-branch`); + expect(result.output).not.toContain('their branches are behind'); }); it('rejects a base that is not a local branch', async () => { @@ -549,13 +601,13 @@ describe('TowerSendTool + TowerInboxTool', () => { name: 'w1', kind: 'worker', agentId: 'agent-w1', - spawnedAt: new Date().toISOString(), + spawnedAt: '2026-09-20T00:00:00.000Z', }); await store.registerAgent({ name: 'w2', kind: 'worker', agentId: 'agent-w2', - spawnedAt: new Date().toISOString(), + spawnedAt: '2026-09-20T00:00:00.000Z', }); }); @@ -571,6 +623,12 @@ describe('TowerSendTool + TowerInboxTool', () => { expect(w1Inbox.output).toContain('subject: for w1'); expect(w1Inbox.output).toContain('subject: broadcast'); expect(w1Inbox.output).not.toContain('subject: for w2'); + expect(w1Inbox.output).toContain('0 unread message(s) remaining'); + + currentAgentId = 'agent-w2'; + const w2Inbox = await run(ix.get(ITowerInboxTool), {}); + expect(w2Inbox.output).toContain('2 message(s) for w2'); + expect(w2Inbox.output).toContain('subject: broadcast'); currentAgentId = 'agent-w1'; await run(ix.get(ITowerSendTool), { to: 'tower', subject: 'report', body: 'd' }); @@ -637,26 +695,6 @@ describe('TowerSendTool + TowerInboxTool', () => { expect(busy.output).not.toContain('has no running task'); }); - it('stamps the sender token count from the usage service into the message frontmatter', async () => { - usageTotal = { inputOther: 100, output: 50, inputCacheRead: 10, inputCacheCreation: 5 }; - - await run(ix.get(ITowerSendTool), { to: 'w1', subject: 'metered', body: 'x' }); - - const dir = join(repo, '.tower/comms/inbox'); - const file = (await readdir(dir)).find((name) => name.includes('metered')); - const { fields } = parseFrontmatter(await readFile(join(dir, file!), 'utf8')); - expect(fields['tokens']).toBe('165'); - }); - - it('records tokens as -1 when the usage service reports nothing', async () => { - await run(ix.get(ITowerSendTool), { to: 'w1', subject: 'unmetered', body: 'x' }); - - const dir = join(repo, '.tower/comms/inbox'); - const file = (await readdir(dir)).find((name) => name.includes('unmetered')); - const { fields } = parseFrontmatter(await readFile(join(dir, file!), 'utf8')); - expect(fields['tokens']).toBe('-1'); - }); - it('skips the delivery note for broadcasts and for sends from workers', async () => { const broadcast = await run(ix.get(ITowerSendTool), { to: 'all', subject: 'b', body: 'x' }); expect(broadcast.output).not.toContain('has no running task'); @@ -666,7 +704,7 @@ describe('TowerSendTool + TowerInboxTool', () => { expect(fromWorker.output).not.toContain('has no running task'); }); - it('marks the caller inbox read, so a completion refused for unread messages passes after TowerInbox', async () => { + it('acks only returned unread pages, so completion waits for every page even with identical timestamps', async () => { const store = new TowerStore(repo); await run(ix.get(ITowerPlanTool), { missions: [{ title: 'Build engine', scope: ['src/engine/**'] }], @@ -683,43 +721,175 @@ describe('TowerSendTool + TowerInboxTool', () => { spawnedAt: '2026-09-20T00:00:00.000Z', }); await store.updateMission('tower', 'M1', { status: 'active', owner: 'w3' }, { silent: true }); - const sent = await run(ix.get(ITowerSendTool), { - to: 'w3', - subject: 'requirement change', - body: 'add tests', - }); - expect(sent.isError).toBeFalsy(); + for (const subject of ['requirement change', 'test update', 'final note']) { + const sent = await run(ix.get(ITowerSendTool), { to: 'w3', subject, body: 'incorporate this' }); + expect(sent.isError).toBeFalsy(); + } + const inboxDir = join(repo, '.tower/comms/inbox'); + for (const file of await readdir(inboxDir)) { + const path = join(inboxDir, file); + const text = await readFile(path, 'utf8'); + await writeFile(path, text.replace(/^sent_at:.*$/m, 'sent_at: 2026-09-21T00:00:00.000Z')); + } currentAgentId = 'agent-w3'; const refused = await run(ix.get(ITowerMissionTool), { id: 'M1', status: 'completed' }); expect(refused.isError).toBe(true); - expect(refused.output).toContain('1 unread inbox message(s) for w3'); + expect(refused.output).toContain('3 unread inbox message(s) for w3'); expect(refused.output).toContain('call TowerInbox'); - const inbox = await run(ix.get(ITowerInboxTool), {}); - expect(inbox.isError).toBeFalsy(); - expect(inbox.output).toContain('subject: requirement change'); - const entry = (await store.load()).roster.agents.find((agent) => agent.name === 'w3'); - expect(Date.parse(entry!.lastInboxReadAt!)).toBeGreaterThan( - Date.parse('2026-09-20T00:00:00.000Z'), - ); + const firstPage = await run(ix.get(ITowerInboxTool), { limit: 2 }); + expect(firstPage.isError).toBeFalsy(); + expect(firstPage.output).toContain('2 message(s) for w3'); + expect(firstPage.output).toContain('1 unread message(s) remaining'); + let entry = (await store.load()).roster.agents.find((agent) => agent.name === 'w3'); + expect(entry?.inboxAckIds).toHaveLength(2); + + const stillRefused = await run(ix.get(ITowerMissionTool), { id: 'M1', status: 'completed' }); + expect(stillRefused.isError).toBe(true); + expect(stillRefused.output).toContain('1 unread inbox message(s) for w3'); + + const lastPage = await run(ix.get(ITowerInboxTool), { limit: 2 }); + expect(lastPage.output).toContain('1 message(s) for w3'); + expect(lastPage.output).toContain('0 unread message(s) remaining'); + entry = (await store.load()).roster.agents.find((agent) => agent.name === 'w3'); + expect(entry?.inboxAckIds).toHaveLength(3); + expect(entry?.lastInboxReadAt).toBe('2026-09-20T00:00:00.000Z'); const accepted = await run(ix.get(ITowerMissionTool), { id: 'M1', status: 'completed' }); expect(accepted.isError).toBeFalsy(); expect(accepted.output).toContain('status: completed'); }); + + it('include_read returns history without acknowledging items outside the returned page', async () => { + const store = new TowerStore(repo); + for (const subject of ['one', 'two', 'three']) { + await run(ix.get(ITowerSendTool), { to: 'w1', subject, body: subject }); + } + currentAgentId = 'agent-w1'; + + const firstPage = await run(ix.get(ITowerInboxTool), { limit: 2 }); + expect(firstPage.output).toContain('1 unread message(s) remaining'); + const history = await run(ix.get(ITowerInboxTool), { limit: 2, include_read: true }); + expect(history.output).toContain('2 message(s) for w1'); + expect(history.output).toContain('1 unread message(s) remaining'); + expect( + (await store.load()).roster.agents.find((agent) => agent.name === 'w1')?.inboxAckIds, + ).toHaveLength(2); + + const lastPage = await run(ix.get(ITowerInboxTool), {}); + expect(lastPage.output).toContain('1 message(s) for w1'); + expect(lastPage.output).toContain('0 unread message(s) remaining'); + const stateFile = join(repo, '.tower/comms/state.json'); + const beforeEmptyPage = await readFile(stateFile, 'utf8'); + const emptyPage = await run(ix.get(ITowerInboxTool), {}); + expect(emptyPage.output).toContain('0 unread message(s) remaining'); + expect(await readFile(stateFile, 'utf8')).toBe(beforeEmptyPage); + + const fullHistory = await run(ix.get(ITowerInboxTool), { limit: 3, include_read: true }); + expect(fullHistory.output).toContain('3 message(s) for w1'); + expect(fullHistory.output).toContain('0 unread message(s) remaining'); + }); + + it('steers a message into a running agent loop so it lands at the next step boundary', async () => { + agentLoops.set('agent-w1', { state: 'running', submitted: [] }); + + const result = await run(ix.get(ITowerSendTool), { + to: 'w1', + subject: 'rebase now', + body: 'rebase onto latest main', + }); + + expect(result.isError).toBeFalsy(); + expect(result.output).toContain('steered into its running turn'); + const loop = agentLoops.get('agent-w1')!; + expect(loop.submitted).toHaveLength(1); + expect(loop.submitted[0]).toContain('new message from tower: "rebase now"'); + expect(loop.submitted[0]).toContain('TowerInbox'); + }); + + it('does not steer into an idle loop — the message waits in the inbox', async () => { + agentLoops.set('agent-w1', { state: 'idle', submitted: [] }); + + const result = await run(ix.get(ITowerSendTool), { to: 'w1', subject: 'later', body: 'x' }); + + expect(agentLoops.get('agent-w1')!.submitted).toHaveLength(0); + expect(result.output).not.toContain('steered into its running turn'); + }); + + it('steers worker-to-worker sends into the sibling loop', async () => { + agentLoops.set('agent-w2', { state: 'running', submitted: [] }); + currentAgentId = 'agent-w1'; + + const result = await run(ix.get(ITowerSendTool), { to: 'w2', subject: 'contract', body: 'x' }); + + expect(result.output).toContain('steered into its running turn'); + expect(agentLoops.get('agent-w2')!.submitted[0]).toContain('new message from w1'); + }); }); describe('TowerStatusTool', () => { + it('keeps active independent from the initialized dashboard data', async () => { + expect(await readStatus()).toEqual({ active: false, initialized: false, stateLost: false }); + + towerActive = true; + expect(await readStatus()).toEqual({ active: true, initialized: false, stateLost: false }); + + await run(ix.get(ITowerInitTool), {}); + const activeStatus = await readStatus(); + expect(activeStatus).toMatchObject({ active: true, initialized: true }); + + towerActive = false; + const inactiveStatus = await readStatus(); + expect(inactiveStatus).toEqual({ ...activeStatus, active: false }); + expect(inactiveStatus).toMatchObject({ + initialized: true, + state: { base: 'main', mode: 'branch', missions: [], roster: { agents: [] } }, + caller: 'tower', + gate: [], + inbox: { count: 0 }, + concurrency: { budget: 2, inflight: 0, blockedUntil: null }, + }); + }); + it('renders the dashboard including the rate-limiter concurrency section', async () => { await initViaTool(); const result = await run(ix.get(ITowerStatusTool), {}); + const status = await readStatus(); expect(result.isError).toBeFalsy(); expect(result.output).toContain('# Tower status — base: main (mode: branch), you are: tower'); expect(result.output).toContain('(no missions planned — use TowerPlan)'); expect(result.output).toContain('budget: 2 agent(s) · inflight: 0 · spawns open'); + expect(status).toMatchObject({ + active: true, + initialized: true, + state: { base: 'main', mode: 'branch', missions: [], roster: { agents: [] } }, + caller: 'tower', + gate: [], + inbox: { count: 0 }, + concurrency: { budget: 2, inflight: 0, blockedUntil: null }, + recentActivity: expect.arrayContaining([expect.stringContaining(' tower init')]), + }); + }); + + it('keeps the Markdown dashboard available to tower workers', async () => { + await initViaTool(); + await new TowerStore(repo).registerAgent({ + name: 'w1', + kind: 'worker', + agentId: 'agent-w1', + spawnedAt: new Date().toISOString(), + }); + towerActive = false; + currentAgentId = 'agent-w1'; + + const result = await run(ix.get(ITowerStatusTool), {}); + + expect(result.isError).toBeFalsy(); + expect(result.output).toContain('# Tower status — base: main (mode: branch)'); + expect(result.output).not.toContain('# Tower status — OFF'); }); it('marks dead roster agents and warns about the missions they own', async () => { @@ -827,6 +997,298 @@ describe('TowerStatusTool', () => { const settled = await run(ix.get(ITowerStatusTool), {}); expect(settled.output).not.toContain('## Awaiting spawn'); }); + + it('uses the merge evaluator for clean-rebase waivers and marks worker runtime unknown off-main', async () => { + await initViaTool(); + await run(ix.get(ITowerPlanTool), { + missions: [{ title: 'Build engine', scope: ['src/engine/**'] }], + }); + const store = new TowerStore(repo); + const state = await store.load(); + const mission = state.missions[0]!; + await store.addWorktree(mission.worktree, mission.branch, state.base); + const worktree = join(repo, '.tower/worktrees', mission.worktree); + await commitFile(worktree, 'src/engine/engine.ts', 'export const engine = 1;\n', 'engine work'); + await store.updateMission('tower', mission.id, { status: 'completed' }, { silent: true }); + await store.registerAgent({ + name: 'w1', + kind: 'worker', + agentId: 'agent-w1', + missionId: mission.id, + worktree: mission.worktree, + branch: mission.branch, + spawnedAt: new Date().toISOString(), + }); + await store.registerAgent({ + name: 'r1', + kind: 'reviewer', + agentId: 'agent-r1', + reviewTarget: mission.branch, + reviewMissionId: mission.id, + spawnedAt: new Date().toISOString(), + }); + await store.submitReview('r1', { + target: mission.branch, + status: 'clean', + merge: 'merge', + findings: 'none', + decision: 'ok', + }); + await commitFile(repo, 'docs/readme.md', 'docs\n', 'base moves on'); + await store.rebaseMission('tower', mission.id, { activeAgentIds: new Set() }); + + const mainStatus = await run(ix.get(ITowerStatusTool), {}); + + expect(mainStatus.isError).toBeFalsy(); + expect(mainStatus.output).toContain('READY reasons=none'); + expect(mainStatus.output).toContain('binding=clean-rebase-waived'); + + liveAgentTaskIds.push('agent-w1'); + const busyStatus = await run(ix.get(ITowerStatusTool), {}); + expect(busyStatus.output).toContain('BLOCKED reasons=worker-busy'); + liveAgentTaskIds.length = 0; + + currentAgentId = 'agent-w1'; + const workerStatus = await run(ix.get(ITowerStatusTool), {}); + expect(workerStatus.isError).toBeFalsy(); + expect(workerStatus.output).toContain('BLOCKED reasons=worker-unknown'); + expect(workerStatus.output).toContain('unknown is not idle'); + expect(workerStatus.output).not.toContain('READY reasons=none'); + }); +}); + +describe('readTowerStatusSummary', () => { + it('renders the uninitialized states as one- and two-line text', async () => { + expect(await readStatusSummary()).toBe('Tower mode: OFF'); + + towerActive = true; + expect(await readStatusSummary()).toBe('Tower mode: ON\nTower is not initialized.'); + }); + + it('renders the initialized dashboard as compact text with a state headline', async () => { + await initViaTool(); + await run(ix.get(ITowerPlanTool), { + missions: [{ title: 'Build engine', scope: ['src/engine/**'] }], + }); + const store = new TowerStore(repo); + await store.registerAgent({ + name: 'w1', + kind: 'worker', + agentId: 'agent-w1', + missionId: 'M1', + branch: 'feat/build-engine', + worktree: 'build-engine', + spawnedAt: new Date().toISOString(), + }); + await store.updateMission('tower', 'M1', { status: 'active', owner: 'w1' }, { silent: true }); + + const summary = await readStatusSummary(); + + const [headline, ...bodyLines] = summary.split('\n'); + expect(headline).toBe('Tower status — ON'); + const body = bodyLines.join('\n'); + expect(body).toContain('Base: main (mode: branch) · You are: tower'); + expect(body).toContain('Missions:\n M1 Build engine — active · owner w1 · feat/build-engine'); + expect(body).toContain('Roster:\n w1 (worker) — mission M1 · agent-w1'); + expect(body).toContain('Review gate:\n M1 feat/build-engine — BLOCKED: not-completed'); + expect(body).toContain('Inbox: 0 message(s)'); + expect(body).toContain('Concurrency: budget: 2 agent(s) · inflight: 0 · spawns open'); + expect(body).toContain('Recent activity:'); + expect(body).toContain(' tower init'); + + towerActive = false; + const inactiveSummary = await readStatusSummary(); + const [inactiveHeadline, ...inactiveBodyLines] = inactiveSummary.split('\n'); + expect(inactiveHeadline).toBe('Tower mode: OFF'); + expect(inactiveBodyLines.join('\n')).toBe(body); + }); +}); + +describe('TowerCompleteTool', () => { + async function setupCompletable(kind: 'build' | 'survey' = 'build') { + await initViaTool(); + const store = new TowerStore(repo); + const [mission] = await store.plan([ + { + title: kind === 'survey' ? 'Survey engine' : 'Build engine', + scope: ['src/engine/**'], + kind, + }, + ]); + const state = await store.load(); + await store.addWorktree(mission!.worktree, mission!.branch, state.base); + if (kind === 'build') { + await commitFile( + join(repo, '.tower/worktrees', mission!.worktree), + 'src/engine/engine.ts', + 'export const engine = 1;\n', + 'engine work', + ); + } + await store.registerAgent({ + name: 'w1', + kind: 'worker', + agentId: 'agent-w1', + missionId: mission!.id, + worktree: mission!.worktree, + branch: mission!.branch, + spawnedAt: new Date().toISOString(), + }); + await store.updateMission( + 'tower', + mission!.id, + { status: 'active', owner: 'w1' }, + { silent: true }, + ); + currentAgentId = 'agent-w1'; + return { store, mission: mission! }; + } + + it('completes a build mission and stores one review-request with mission metadata', async () => { + const { store } = await setupCompletable(); + + const result = await run(ix.get(ITowerCompleteTool), { report: 'implemented the engine' }); + + expect(result.isError).toBeFalsy(); + expect(result.output).toContain('mission M1 persisted as completed'); + expect(result.output).toContain('review-request notification:'); + expect(result.output).toContain('notification: stored once'); + expect((await store.load()).missions[0]?.status).toBe('completed'); + const messages = await store.readInbox('tower', 20); + const notification = messages.find((item) => item.subject === 'review-request'); + expect(notification).toMatchObject({ + from: 'w1', + to: 'tower', + body: 'implemented the engine', + scope: 'M1', + action: 'complete', + }); + }); + + it('completes a zero-diff survey with survey-summary', async () => { + const { store } = await setupCompletable('survey'); + + const result = await run(ix.get(ITowerCompleteTool), { report: 'survey findings' }); + + expect(result.isError).toBeFalsy(); + expect(result.output).toContain('survey-summary notification:'); + const messages = await store.readInbox('tower', 20); + expect(messages.find((item) => item.subject === 'survey-summary')).toMatchObject({ + body: 'survey findings', + scope: 'M1', + action: 'complete', + }); + }); + + it('rejects the tower and reviewers without writing a completion notification', async () => { + const { store } = await setupCompletable(); + currentAgentId = 'main'; + const towerResult = await run(ix.get(ITowerCompleteTool), { report: 'not mine' }); + expect(towerResult.isError).toBe(true); + expect(towerResult.output).toContain('only the worker'); + + await store.registerAgent({ + name: 'r1', + kind: 'reviewer', + agentId: 'agent-r1', + reviewTarget: 'feat/build-engine', + reviewMissionId: 'M1', + spawnedAt: new Date().toISOString(), + }); + currentAgentId = 'agent-r1'; + const reviewerResult = await run(ix.get(ITowerCompleteTool), { report: 'not mine either' }); + expect(reviewerResult.isError).toBe(true); + expect(reviewerResult.output).toContain('only the worker'); + expect((await store.readInbox('tower', 20)).some((item) => item.action === 'complete')).toBe(false); + }); + + it('fails unread completion checks before writing a notification', async () => { + const { store } = await setupCompletable(); + await store.send('tower', { to: 'w1', subject: 'requirement change', body: 'read me' }); + + const result = await run(ix.get(ITowerCompleteTool), { report: 'premature completion' }); + + expect(result.isError).toBe(true); + expect(result.output).toContain('unread inbox message'); + expect((await store.load()).missions[0]?.status).toBe('active'); + expect((await store.readInbox('tower', 20)).some((item) => item.action === 'complete')).toBe(false); + }); + + it('refuses to complete while blockers remain', async () => { + const { store } = await setupCompletable(); + await store.updateMission('tower', 'M1', { blocker: 'still blocked' }); + + const result = await run(ix.get(ITowerCompleteTool), { report: 'blocked completion' }); + + expect(result.isError).toBe(true); + expect(result.output).toContain('blocker(s) remain'); + expect((await store.load()).missions[0]?.status).toBe('blocked'); + expect((await store.readInbox('tower', 20)).some((item) => item.action === 'complete')).toBe(false); + }); + + it('rechecks unread messages before creating a different notification for a completed mission', async () => { + const { store } = await setupCompletable(); + const first = await run(ix.get(ITowerCompleteTool), { report: 'original report' }); + expect(first.isError).toBeFalsy(); + await store.send('tower', { to: 'w1', subject: 'late requirement change', body: 'read before new report' }); + + const result = await run(ix.get(ITowerCompleteTool), { report: 'different report' }); + + expect(result.isError).toBe(true); + expect(result.output).toContain('unread inbox message'); + expect((await store.load()).missions[0]?.status).toBe('completed'); + const messages = await store.readInbox('tower', 20); + expect(messages.filter((item) => item.action === 'complete')).toHaveLength(1); + expect(messages.find((item) => item.action === 'complete')?.body).toBe('original report'); + }); + + it('rejects a non-zero survey branch during completion without a summary', async () => { + const { store, mission } = await setupCompletable('survey'); + await commitFile( + join(repo, '.tower/worktrees', mission.worktree), + 'src/engine/notes.ts', + 'not allowed\n', + 'survey edit', + ); + + const result = await run(ix.get(ITowerCompleteTool), { report: 'survey with edits' }); + + expect(result.isError).toBe(true); + expect(result.output).toContain('read-only survey'); + expect((await store.load()).missions[0]?.status).toBe('active'); + expect((await store.readInbox('tower', 20)).some((item) => item.action === 'complete')).toBe(false); + }); + + it('reports persisted completion on notification failure and reuses the message on retry', async () => { + const { store } = await setupCompletable(); + const failure = vi + .spyOn(TowerStore.prototype, 'appendLog') + .mockImplementationOnce(async () => {}) + .mockRejectedValueOnce(new Error('forced notification write failure')); + try { + const first = await run(ix.get(ITowerCompleteTool), { report: 'retry report' }); + + expect(first.isError).toBe(true); + expect(first.output).toContain('mission M1 persisted as completed'); + expect(first.output).toContain('activity log error'); + expect(first.output).toContain('forced notification write failure'); + const before = await store.readInbox('tower', 20); + expect(before.filter((item) => item.action === 'complete')).toHaveLength(1); + failure.mockRestore(); + + const second = await run(ix.get(ITowerCompleteTool), { report: 'retry report' }); + + expect(second.isError).toBeFalsy(); + expect(second.output).toContain('reused the stored message'); + const after = await store.readInbox('tower', 20); + expect(after.filter((item) => item.action === 'complete')).toHaveLength(1); + expect(after.find((item) => item.action === 'complete')?.file).toBe( + before.find((item) => item.action === 'complete')?.file, + ); + } finally { + failure.mockRestore(); + } + }); }); describe('TowerReviewTool', () => { @@ -874,9 +1336,11 @@ describe('TowerReviewTool', () => { const review = await store.latestReview('feat/build-engine'); expect(review).toBeDefined(); expect(result.output).toContain(`review submitted: ${review!.file}`); - expect(result.output).toContain(`next: resume w1 with this review file (${review!.file})`); - expect(result.output).toContain('Agent(resume="agent-w1", run_in_background=true'); - expect(result.output).not.toContain('merge-ready'); + expect(result.output).toContain('notified: w1, tower'); + expect(result.output).toContain('the author must fix and re-review'); + expect(result.output).not.toContain('Agent(resume='); + expect(result.output).not.toContain('TowerSend'); + expect(result.output).not.toContain('is merge-ready'); }); it('reports a clean verdict as merge-ready for TowerMerge', async () => { @@ -893,9 +1357,117 @@ describe('TowerReviewTool', () => { expect(result.isError).toBeFalsy(); expect(result.output).toContain('next: feat/build-engine is merge-ready'); expect(result.output).toContain('TowerMerge'); + expect(result.output).toContain('notified: tower'); expect(result.output).not.toContain('resume w1'); }); + it('preserves the review and recovers without a new round or duplicate after review.write log failure', async () => { + const store = await setupReviewableBranch(); + const failure = vi + .spyOn(TowerStore.prototype, 'appendLog') + .mockRejectedValueOnce(new Error('forced review.write activity log failure')); + const input = { + target: 'feat/build-engine', + status: 'clean', + merge: 'merge', + findings: 'none', + decision: 'looks good', + } as const; + try { + const first = await run(ix.get(ITowerReviewTool), input); + + expect(first.isError).toBe(true); + expect(first.output).toContain('review submitted:'); + expect(first.output).toContain('activity log error'); + expect(first.output).toContain('was stored'); + expect(first.output).toContain('forced review.write activity log failure'); + const firstReview = await store.latestReview('feat/build-engine'); + expect(firstReview?.round).toBe(1); + expect((await store.readInbox('tower', 20)).filter((item) => item.action === 'review-result')).toHaveLength(1); + failure.mockRestore(); + + const second = await run(ix.get(ITowerReviewTool), input); + + expect(second.isError).toBeFalsy(); + const secondReview = await store.latestReview('feat/build-engine'); + expect(secondReview?.round).toBe(1); + expect(secondReview?.file).toBe(firstReview?.file); + expect(await store.reviewsFor('feat/build-engine')).toHaveLength(1); + expect((await store.readInbox('tower', 20)).filter((item) => item.action === 'review-result')).toHaveLength(1); + } finally { + failure.mockRestore(); + } + }); + + it('recovers an inbox.send log failure without a new review round or duplicate message', async () => { + const store = await setupReviewableBranch(); + const failure = vi + .spyOn(TowerStore.prototype, 'appendLog') + .mockImplementationOnce(async () => {}) + .mockRejectedValueOnce(new Error('forced inbox.send activity log failure')); + const input = { + target: 'feat/build-engine', + status: 'clean', + merge: 'merge', + findings: 'none', + decision: 'looks good', + } as const; + try { + const first = await run(ix.get(ITowerReviewTool), input); + + expect(first.isError).toBe(true); + expect(first.output).toContain('activity log error'); + expect(first.output).toContain('inbox.send activity log failed'); + const firstReview = await store.latestReview('feat/build-engine'); + expect(firstReview?.round).toBe(1); + expect((await store.readInbox('tower', 20)).filter((item) => item.action === 'review-result')).toHaveLength(1); + failure.mockRestore(); + + const second = await run(ix.get(ITowerReviewTool), input); + + expect(second.isError).toBeFalsy(); + expect((await store.latestReview('feat/build-engine'))?.file).toBe(firstReview?.file); + expect(await store.reviewsFor('feat/build-engine')).toHaveLength(1); + expect((await store.readInbox('tower', 20)).filter((item) => item.action === 'review-result')).toHaveLength(1); + } finally { + failure.mockRestore(); + } + }); + + it('does not call a clean hold review merge-ready', async () => { + await setupReviewableBranch(); + + const result = await run(ix.get(ITowerReviewTool), { + target: 'feat/build-engine', + status: 'clean', + merge: 'hold', + findings: 'none', + decision: 'wait for coordination', + }); + + expect(result.isError).toBeFalsy(); + expect(result.output).not.toContain('merge-ready'); + expect(result.output).toContain('blocked by the latest review\'s "hold" verdict'); + expect(result.output).toContain('merge gate rejects it'); + }); + + it('does not call a clean fix-then-merge review merge-ready', async () => { + await setupReviewableBranch(); + + const result = await run(ix.get(ITowerReviewTool), { + target: 'feat/build-engine', + status: 'clean', + merge: 'fix-then-merge', + findings: 'none', + decision: 'approval only after fixes', + }); + + expect(result.isError).toBeFalsy(); + expect(result.output).not.toContain('merge-ready'); + expect(result.output).toContain('"fix-then-merge" is not approval'); + expect(result.output).toContain('merge gate requires a clean review with merge verdict "merge"'); + }); + it('routes a non-clean verdict through the tower when no worker owns the branch', async () => { await setupReviewableBranch({ withOwner: false }); @@ -908,7 +1480,8 @@ describe('TowerReviewTool', () => { }); expect(result.isError).toBeFalsy(); - expect(result.output).toContain('next: no worker on record owns feat/build-engine'); + expect(result.output).toContain('notified: tower'); + expect(result.output).toContain('the author must fix and re-review'); expect(result.output).not.toContain('Agent(resume='); }); }); @@ -923,6 +1496,7 @@ describe('TowerMergeTool', () => { await commitFile(repo, 'src/engine/engine.ts', 'export const engine = 1;\n', 'engine work'); await git(repo, 'checkout', 'main'); const store = new TowerStore(repo); + await store.updateMission('tower', 'M1', { status: 'completed' }, { silent: true }); await store.submitReview('tower', { target: 'feat/build-engine', status: 'clean', @@ -963,6 +1537,12 @@ describe('TowerMergeTool', () => { missions: [{ title: 'Survey engine', scope: ['src/engine/**'], kind: 'survey' }], }); await git(repo, 'branch', 'feat/survey-engine'); + await new TowerStore(repo).updateMission( + 'tower', + 'M1', + { status: 'completed' }, + { silent: true }, + ); const result = await run(ix.get(ITowerMergeTool), { branch: 'feat/survey-engine' }); @@ -972,6 +1552,212 @@ describe('TowerMergeTool', () => { }); }); +describe('TowerRebaseTool', () => { + async function setupRebasableMission() { + await initViaTool(); + const store = new TowerStore(repo); + const [mission] = await store.plan([ + { title: 'Build engine', scope: ['src/engine/**'] }, + ]); + const state = await store.load(); + await store.addWorktree(mission!.worktree, mission!.branch, state.base); + const worktree = join(repo, '.tower/worktrees', mission!.worktree); + await commitFile(worktree, 'src/engine/engine.ts', 'export const engine = 1;\n', 'engine work'); + return { store, mission: mission! }; + } + + it('rebases a stale branch and preserves review binding without changing merge approval', async () => { + const { mission } = await setupRebasableMission(); + await commitFile(repo, 'docs/readme.md', 'docs\n', 'base moves on'); + + const result = await run(ix.get(ITowerRebaseTool), { mission: mission.id }); + + expect(result.isError).toBeFalsy(); + expect(result.output).toContain(`rebased mission ${mission.id}`); + expect(result.output).toContain('keeps its exact-tip binding'); + expect(result.output).toContain('merge verdict "merge"'); + }); + + it('reports up-to-date branches without touching them', async () => { + const { mission } = await setupRebasableMission(); + + const result = await run(ix.get(ITowerRebaseTool), { mission: mission.id }); + + expect(result.isError).toBeFalsy(); + expect(result.output).toContain('already up to date'); + }); + + it('refuses while the worker is mid-turn', async () => { + const { store, mission } = await setupRebasableMission(); + await store.registerAgent({ + name: 'w1', + kind: 'worker', + agentId: 'agent-w1', + missionId: mission.id, + spawnedAt: new Date().toISOString(), + }); + liveAgentTaskIds.push('agent-w1'); + await commitFile(repo, 'docs/readme.md', 'docs\n', 'base moves on'); + + const result = await run(ix.get(ITowerRebaseTool), { mission: mission.id }); + + expect(result.isError).toBe(true); + expect(result.output).toContain('mid-turn'); + }); + + it('reports a conflicting rebase and marks the mission blocked', async () => { + const { store, mission } = await setupRebasableMission(); + await commitFile(repo, 'src/engine/engine.ts', 'export const engine = 2;\n', 'base conflicts'); + + const result = await run(ix.get(ITowerRebaseTool), { mission: mission.id }); + + expect(result.isError).toBeFalsy(); + expect(result.output).toContain('conflicted and was aborted'); + expect(result.output).toContain('src/engine/engine.ts'); + expect((await store.load()).missions[0]?.status).toBe('blocked'); + }); +}); + +describe('tower state loss degradation', () => { + async function destroyComms(): Promise { + await rm(join(repo, '.tower/comms'), { recursive: true, force: true }); + } + + it('TowerStatus returns a state-lost report instead of hard-failing, and keeps not-initialized for a fresh repo', async () => { + towerActive = true; + const fresh = await run(ix.get(ITowerStatusTool), {}); + expect(fresh.isError).toBe(true); + expect(fresh.output).toContain('tower is not initialized in this repository'); + expect(await readStatus()).toEqual({ active: true, initialized: false, stateLost: false }); + + await initViaTool(); + await destroyComms(); + + expect(await readStatus()).toEqual({ active: true, initialized: false, stateLost: true }); + const result = await run(ix.get(ITowerStatusTool), {}); + expect(result.isError).toBeFalsy(); + expect(result.output).toContain('# Tower status — STATE LOST'); + expect(result.output).toContain('Report this history loss to the user'); + expect(result.output).toContain('TowerMerge and TowerRebase refuse'); + + const summary = await readStatusSummary(); + expect(summary).toContain('Tower mode: ON'); + expect(summary).toContain('Tower state was lost'); + }); + + it('TowerSend recovers a minimal state stamped recoveredAt and delivers normally', async () => { + await initViaTool(); + await destroyComms(); + + const result = await run(ix.get(ITowerSendTool), { + to: 'all', + subject: 'state check', + body: 'x', + }); + + expect(result.isError).toBeFalsy(); + expect(result.output).toContain('message sent to all'); + const state = await new TowerStore(repo).load(); + expect(state.base).toBe('main'); + expect(state.recoveredAt).toBeTruthy(); + expect(state.missions).toEqual([]); + expect(state.roster.agents).toEqual([]); + }); + + it('a worker whose roster entry was lost is re-registered as a placeholder and its report reaches the tower', async () => { + await initViaTool(); + const store = new TowerStore(repo); + await store.registerAgent({ + name: 'w1', + kind: 'worker', + agentId: 'agent-w1', + spawnedAt: '2026-09-20T00:00:00.000Z', + }); + await destroyComms(); + + currentAgentId = 'agent-w1'; + const result = await run(ix.get(ITowerSendTool), { + to: 'tower', + subject: 'accident report', + body: 'comms was deleted', + }); + + expect(result.isError).toBeFalsy(); + expect(result.output).toContain('message sent to tower'); + expect(result.output).toContain('placeholder "agent-w1"'); + expect((await store.load()).roster.agents).toEqual([ + expect.objectContaining({ name: 'agent-w1', agentId: 'agent-w1', kind: 'worker' }), + ]); + + currentAgentId = 'main'; + const inbox = await run(ix.get(ITowerInboxTool), {}); + expect(inbox.output).toContain('subject: accident report'); + expect(inbox.output).toContain('from: agent-w1'); + }); + + it('TowerComplete degrades explicitly: the report reaches the tower and no mission is marked completed', async () => { + await initViaTool(); + const store = new TowerStore(repo); + await store.plan([{ title: 'Build engine', scope: ['src/engine/**'] }]); + await store.registerAgent({ + name: 'w1', + kind: 'worker', + agentId: 'agent-w1', + missionId: 'M1', + spawnedAt: new Date().toISOString(), + }); + await destroyComms(); + + currentAgentId = 'agent-w1'; + const result = await run(ix.get(ITowerCompleteTool), { report: 'done: implemented engine' }); + + expect(result.isError).toBeFalsy(); + expect(result.output).toContain('no mission was marked completed'); + expect(result.output).toContain('completion-report-state-lost'); + expect((await store.load()).missions).toEqual([]); + + currentAgentId = 'main'; + const inbox = await run(ix.get(ITowerInboxTool), {}); + expect(inbox.output).toContain('done: implemented engine'); + + currentAgentId = 'agent-w1'; + const retry = await run(ix.get(ITowerCompleteTool), { report: 'done: implemented engine' }); + expect(retry.output).toContain('reused the stored message'); + }); + + it('TowerStatus and the status summary flag a recovered state with its recoveredAt marker', async () => { + await initViaTool(); + await destroyComms(); + await run(ix.get(ITowerSendTool), { to: 'all', subject: 'trigger recovery', body: 'x' }); + + const result = await run(ix.get(ITowerStatusTool), {}); + expect(result.isError).toBeFalsy(); + expect(result.output).toContain('## ⚠️ State recovered after a loss'); + expect(result.output).toContain('Report the history loss to the user'); + + const summary = await readStatusSummary(); + expect(summary).toContain('⚠️ State recovered at'); + expect(summary).toContain('report this history loss to the user'); + }); + + it('TowerMerge and TowerRebase fail with an explicit state-lost error instead of a not-initialized one', async () => { + await initViaTool(); + await destroyComms(); + + const merge = await run(ix.get(ITowerMergeTool), { branch: 'feat/x' }); + expect(merge.isError).toBe(true); + expect(merge.output).toContain('tower state was lost'); + expect(merge.output).not.toContain('tower is not initialized'); + + const rebase = await run(ix.get(ITowerRebaseTool), { mission: 'M1' }); + expect(rebase.isError).toBe(true); + expect(rebase.output).toContain('tower state was lost'); + expect(rebase.output).not.toContain('tower is not initialized'); + + expect(await new TowerStore(repo).isInitialized()).toBe(false); + }); +}); + describe('tool registration', () => { it('declares no when gate on any tower tool contribution', () => { for (const contribution of TOWER_TOOL_CONTRIBUTIONS) { @@ -985,6 +1771,7 @@ describe('tool registration', () => { [ITowerInitTool, {}], [ITowerPlanTool, { missions: [] }], [ITowerMergeTool, { branch: 'tower/x' }], + [ITowerRebaseTool, { mission: 'M1' }], [ITowerTeardownTool, {}], ]; for (const [id, args] of cases) { diff --git a/packages/agent-core-v2/test/features/tower/towerFeature.test.ts b/packages/agent-core-v2/test/features/tower/towerFeature.test.ts index c6032ea8de5..8537ab5f7ad 100644 --- a/packages/agent-core-v2/test/features/tower/towerFeature.test.ts +++ b/packages/agent-core-v2/test/features/tower/towerFeature.test.ts @@ -107,12 +107,14 @@ describe('TowerFeature — experimental flag gating', () => { ); expect(tools.toSorted()).toEqual( [ + 'TowerComplete', 'TowerFinding', 'TowerInbox', 'TowerInit', 'TowerMerge', 'TowerMission', 'TowerPlan', + 'TowerRebase', 'TowerReview', 'TowerSend', 'TowerSpawn', @@ -196,7 +198,7 @@ describe('TowerFeature — config-sourced flag assembly', () => { ), ).toHaveLength(1); const agent = host.child(LifecycleScope.Agent, 'agent-1'); - expect(collectionViewOf(agent, AgentToolContribution).items).toHaveLength(11); + expect(collectionViewOf(agent, AgentToolContribution).items).toHaveLength(13); host.dispose(); }); 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 001a58518e4..5801ae2970e 100644 --- a/packages/agent-core-v2/test/features/tower/towerService.test.ts +++ b/packages/agent-core-v2/test/features/tower/towerService.test.ts @@ -1,4 +1,5 @@ import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -32,6 +33,8 @@ import type { } from '#/agent/toolExecutor/toolHooks'; import { STATE_FILE, TowerStore, type TowerState } from '#/features/tower/protocol/index'; import { TowerSendTool } from '#/features/tower/tools/send/sendTool'; +import { TowerReviewTool } from '#/features/tower/tools/review/reviewTool'; +import { TowerCompleteTool } from '#/features/tower/tools/complete/completeTool'; import { IAgentTowerService, TOWER_FLAG_ID, @@ -40,16 +43,17 @@ import { } from '#/features/tower/tower'; import { _setTowerFeatureAssembledForTests } from '#/features/tower/towerFeature'; import { AgentTowerService, TOWER_INBOX_WAKE_VARIANT, TOWER_MODE_TOOLS } from '#/features/tower/towerService'; -import { towerKey, TowerInboxSent } from '#/features/tower/towerOps'; +import { towerKey, TowerInboxSent, type TowerInboxSentPayload } from '#/features/tower/towerOps'; import { TaskTerminatedNotice } from '#/agent/task/taskOps'; import { IAgentTaskService } from '#/agent/task/task'; import { SubagentStarted } from '#/session/subagent/mirrorAgentRun'; -import { ISessionUsageService } from '#/session/usage/sessionUsage'; import { IAgentStateService } from '#/agent/state/agentState'; import { AgentStatusUpdated } from '#/agent/usage/usageEvents'; import { makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IEventBus, ISessionEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; +import { IBashParserService } from '#/app/bashParser/bashParser'; +import { BashParserService } from '#/app/bashParser/bashParserService'; import { IConfigService } from '#/app/config/config'; import { IFeatureManager } from '#/app/feature/featureManager'; import { IFlagService } from '#/app/flag/flag'; @@ -317,6 +321,31 @@ describe('AgentTowerService', () => { expect(events).toEqual([{ type: 'agent.status.updated', towerMode: true }]); }); + it('offers non-waiting branch leases and always releases them', async () => { + const tower = ix.get(IAgentTowerService); + let release!: () => void; + const running = tower.withBranchLease( + 'feat/leased', + () => new Promise((resolve) => { + release = resolve; + }), + ); + + expect(tower.isBranchLeased('feat/leased')).toBe(true); + await expect(tower.withBranchLease('feat/leased', () => Promise.resolve())).rejects.toThrow( + /not waiting/, + ); + await tower.withBranchLease('feat/other', () => Promise.resolve()); + release(); + await running; + expect(tower.isBranchLeased('feat/leased')).toBe(false); + + await expect( + tower.withBranchLease('feat/leased', () => Promise.reject(new Error('boom'))), + ).rejects.toThrow('boom'); + expect(tower.isBranchLeased('feat/leased')).toBe(false); + }); + it('tracks tower_mode_enter and tower_mode_exit on transitions only', async () => { const tower = ix.get(IAgentTowerService); @@ -335,7 +364,7 @@ describe('AgentTowerService', () => { expect(telemetryTrack2).not.toHaveBeenCalled(); }); - it('enter(base) records the requested base; exit clears it', async () => { + it('enter(base) prepares the workspace base and records the session owner', async () => { const repo = await mkdtemp(join(tmpdir(), 'tower-enter-base-')); try { await initGitRepo(repo); @@ -346,17 +375,15 @@ describe('AgentTowerService', () => { ix.stub(ISessionContext, { cwd: repo, sessionId: 'session-base' } as unknown as ISessionContext); const tower = ix.get(IAgentTowerService); - expect(tower.requestedBase).toBeUndefined(); await tower.enter('develop'); expect(tower.isActive).toBe(true); - expect(tower.requestedBase).toBe('develop'); const state = await new TowerStore(repo).load(); expect(state.base).toBe('develop'); expect(state.sessionId).toBe('session-base'); await tower.exit(); - expect(tower.requestedBase).toBeUndefined(); + expect(tower.isActive).toBe(false); } finally { await rm(repo, { recursive: true, force: true }); } @@ -375,7 +402,6 @@ describe('AgentTowerService', () => { await tower.enter('integration'); expect(tower.isActive).toBe(true); - expect(tower.requestedBase).toBe('integration'); const { stdout: checkout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repo }); expect(checkout.trim()).toBe('integration'); const { stdout: branchTip } = await execFileAsync('git', ['rev-parse', 'integration'], { cwd: repo }); @@ -402,7 +428,6 @@ describe('AgentTowerService', () => { await expect(tower.enter('no..dots')).rejects.toThrow('git checkout -b no..dots failed'); expect(tower.isActive).toBe(false); - expect(tower.requestedBase).toBeUndefined(); expect(addedTools).toEqual([]); expect(await new TowerStore(repo).isInitialized()).toBe(false); } finally { @@ -426,7 +451,6 @@ describe('AgentTowerService', () => { await tower.enter('develop'); expect(tower.isActive).toBe(true); - expect(tower.requestedBase).toBe('develop'); const state = await store.load(); expect(state.base).toBe('develop'); const { stdout: checkout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repo }); @@ -452,7 +476,6 @@ describe('AgentTowerService', () => { await tower.enter('add-new-feature'); expect(tower.isActive).toBe(true); - expect(tower.requestedBase).toBe('add-new-feature'); const { stdout: checkout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repo }); expect(checkout.trim()).toBe('add-new-feature'); const { stdout: status } = await execFileAsync('git', ['status', '--porcelain'], { cwd: repo }); @@ -465,26 +488,26 @@ describe('AgentTowerService', () => { } }); - it('enter(base) refuses to rebase while missions are open and creates nothing', async () => { - const repo = await mkdtemp(join(tmpdir(), 'tower-enter-rebase-blocked-')); + it('enter(base) re-anchors the workspace while missions are open', async () => { + const repo = await mkdtemp(join(tmpdir(), 'tower-enter-reanchor-')); 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 execFileAsync('git', ['branch', 'develop'], { cwd: repo }); const store = new TowerStore(repo); await store.init('session-previous', 'main'); await store.plan([{ title: 'engine', scope: ['src/engine/**'] }]); ix.stub(ISessionContext, { cwd: repo, sessionId: 'session-next' } as unknown as ISessionContext); const tower = ix.get(IAgentTowerService); - await expect(tower.enter('add-new-feature')).rejects.toThrow('open mission(s)'); + await tower.enter('develop'); - expect(tower.isActive).toBe(false); - expect(tower.requestedBase).toBeUndefined(); - const { stdout: branches } = await execFileAsync('git', ['branch', '--list', 'add-new-feature'], { cwd: repo }); - expect(branches.trim()).toBe(''); - expect((await store.load()).base).toBe('main'); + expect(tower.isActive).toBe(true); + const state = await store.load(); + expect(state.base).toBe('develop'); + expect(state.missions).toHaveLength(1); } finally { await rm(repo, { recursive: true, force: true }); } @@ -550,7 +573,6 @@ describe('AgentTowerService', () => { await expect(tower.enter('integration')).rejects.toThrow('unmerged paths'); expect(tower.isActive).toBe(false); - expect(tower.requestedBase).toBeUndefined(); const { stdout: branches } = await execFileAsync('git', ['branch', '--list', 'integration'], { cwd: repo }); expect(branches.trim()).toBe(''); expect(await new TowerStore(repo).isInitialized()).toBe(false); @@ -559,7 +581,7 @@ describe('AgentTowerService', () => { } }); - it('re-enter while active updates only the requested base', async () => { + it('re-enter while active only prepares the base again and does not re-dispatch', async () => { const repo = await mkdtemp(join(tmpdir(), 'tower-enter-rebase-')); try { await initGitRepo(repo); @@ -579,20 +601,16 @@ describe('AgentTowerService', () => { ); await tower.enter(); - expect(tower.requestedBase).toBeUndefined(); await tower.enter('develop'); expect(tower.isActive).toBe(true); - expect(tower.requestedBase).toBe('develop'); + expect((await new TowerStore(repo).load()).base).toBe('develop'); await tower.enter('develop'); await tower.enter(); - expect(tower.requestedBase).toBe('develop'); + expect(tower.isActive).toBe(true); - expect(events).toEqual([ - { type: 'agent.status.updated', towerMode: true }, - { type: 'agent.status.updated', towerMode: true }, - ]); + expect(events).toEqual([{ type: 'agent.status.updated', towerMode: true }]); } finally { await rm(repo, { recursive: true, force: true }); } @@ -962,7 +980,6 @@ describe('AgentTowerService', () => { await tower.enter('develop'); - expect(tower.requestedBase).toBe('develop'); expect((await store.load()).base).toBe('develop'); } finally { await rm(repo, { recursive: true, force: true }); @@ -1114,6 +1131,79 @@ describe('AgentTowerService', () => { expect(formatDenyMessage).not.toHaveBeenCalled(); }); + function goalHookContext( + toolName: 'CreateGoal' | 'UpdateGoal', + args: Record, + ): ResolvedToolExecutionHookContext { + const call = toolCall(toolName, `call_${toolName.toLowerCase()}`); + return { + turnId: 0, + signal, + toolCall: call, + toolCalls: [call], + args, + execution: { approvalRule: toolName, execute: async () => ({ output: '' }) }, + }; + } + + it('vetoes CreateGoal while tower mode is active', async () => { + const tower = ix.get(IAgentTowerService); + await tower.enter(); + + const decision = await fire(goalHookContext('CreateGoal', { objective: 'x' })); + + expect(decision).toEqual({ + veto: { + output: expect.stringContaining('CreateGoal is not available while tower mode is active'), + isError: true, + }, + }); + expect(decision?.veto?.output).toContain('mutually exclusive'); + expect(decision?.veto?.output).toContain('exit tower mode first'); + expect(permissionGateRan).toBe(false); + expect(formatDenyMessage).toHaveBeenCalledTimes(1); + }); + + it('abstains on CreateGoal while tower mode is inactive', async () => { + ix.get(IAgentTowerService); + + const decision = await fire(goalHookContext('CreateGoal', { objective: 'x' })); + + expect(decision).toBeUndefined(); + expect(permissionGateRan).toBe(true); + expect(formatDenyMessage).not.toHaveBeenCalled(); + }); + + it('vetoes an UpdateGoal resume while tower mode is active', async () => { + const tower = ix.get(IAgentTowerService); + await tower.enter(); + + const decision = await fire(goalHookContext('UpdateGoal', { status: 'active' })); + + expect(decision).toEqual({ + veto: { + output: expect.stringContaining( + 'Resuming a goal is not available while tower mode is active', + ), + isError: true, + }, + }); + expect(decision?.veto?.output).toContain('exit tower mode first'); + expect(permissionGateRan).toBe(false); + expect(formatDenyMessage).toHaveBeenCalledTimes(1); + }); + + it('abstains on an UpdateGoal complete while tower mode is active', async () => { + const tower = ix.get(IAgentTowerService); + await tower.enter(); + + const decision = await fire(goalHookContext('UpdateGoal', { status: 'complete' })); + + expect(decision).toBeUndefined(); + expect(permissionGateRan).toBe(true); + expect(formatDenyMessage).not.toHaveBeenCalled(); + }); + it('denies tower tools while the tower flag is off, even with the mode active', async () => { const tower = ix.get(IAgentTowerService); await tower.enter(); @@ -1260,6 +1350,59 @@ describe('AgentTowerService', () => { } }); + it('enter(base) rejects a non-owner re-anchor while a live owner is busy and leaves the store untouched', async () => { + const repo = await mkdtemp(join(tmpdir(), 'tower-enter-reanchor-denied-')); + 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 execFileAsync('git', ['branch', 'develop'], { cwd: repo }); + const store = new TowerStore(repo); + await store.init('session-original', 'main'); + + stubLiveSession('session-original', { busy: true }); + ix.stub(ISessionContext, { cwd: repo, sessionId: 'session-fork' } as unknown as ISessionContext); + const tower = ix.get(IAgentTowerService); + + await expect(tower.enter('develop')).resolves.toEqual({ + entered: false, + reason: 'owned-by-live-session', + owner: 'session-original', + }); + + expect(tower.isActive).toBe(false); + expect(addedTools).toEqual([]); + const state = await store.load(); + expect(state.base).toBe('main'); + expect(state.sessionId).toBe('session-original'); + } finally { + await rm(repo, { recursive: true, force: true }); + } + }); + + it('enter(base) lets the owning session re-anchor the workspace base', async () => { + const repo = await mkdtemp(join(tmpdir(), 'tower-enter-owner-reanchor-')); + 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 execFileAsync('git', ['branch', 'develop'], { cwd: repo }); + const store = new TowerStore(repo); + await store.init('session-main', 'main'); + ix.stub(ISessionContext, { cwd: repo, sessionId: 'session-main' } as unknown as ISessionContext); + const tower = ix.get(IAgentTowerService); + + await tower.enter('develop'); + + expect(tower.isActive).toBe(true); + expect((await store.load()).base).toBe('develop'); + } finally { + await rm(repo, { recursive: true, force: true }); + } + }); + it('enter() reports owned-by-live-session while the owning session waits on an interaction', async () => { const repo = await mkdtemp(join(tmpdir(), 'tower-enter-pending-')); try { @@ -1729,6 +1872,7 @@ describe('AgentTowerService', () => { ix2.stub(IAgentToolApprovalService, { formatDenyMessage }); ix2.stub(ITelemetryService, { track2: () => {} }); ix2.stub(IFlagService, stubFlag((id) => id === TOWER_FLAG_ID)); + ix2.stub(ILogService, stubLog()); ix2.stub(ISessionContext, { cwd: '/nonexistent-tower-repo' } as unknown as ISessionContext); ix2.stub( IAgentReminderService, @@ -1830,6 +1974,7 @@ describe('AgentTowerService', () => { ix2.stub(IAgentToolApprovalService, { formatDenyMessage }); ix2.stub(ITelemetryService, { track2: () => {} }); ix2.stub(IFlagService, stubFlag(() => false)); + ix2.stub(ILogService, stubLog()); ix2.stub(ISessionContext, { cwd: '/nonexistent-tower-repo' } as unknown as ISessionContext); ix2.stub( IAgentReminderService, @@ -1896,6 +2041,7 @@ describe('AgentTowerService', () => { ix2.stub(IAgentToolApprovalService, { formatDenyMessage }); ix2.stub(ITelemetryService, { track2: () => {} }); ix2.stub(IFlagService, stubFlag((id) => id === TOWER_FLAG_ID)); + ix2.stub(ILogService, stubLog()); ix2.stub(ISessionContext, { cwd: '/nonexistent-tower-repo' } as unknown as ISessionContext); ix2.stub( IAgentReminderService, @@ -1969,6 +2115,7 @@ describe('AgentTowerService', () => { ix2.stub(IAgentToolApprovalService, { formatDenyMessage }); ix2.stub(ITelemetryService, { track2: () => {} }); ix2.stub(IFlagService, stubFlag((id) => id === TOWER_FLAG_ID)); + ix2.stub(ILogService, stubLog()); ix2.stub(ISessionManager, { get: (id: string) => (id === 'session-original' ? {} : undefined), } as unknown as ISessionManager); @@ -2079,6 +2226,7 @@ describe('AgentTowerService', () => { ix2.stub(IAgentToolApprovalService, { formatDenyMessage }); ix2.stub(ITelemetryService, { track2: () => {} }); ix2.stub(IFlagService, stubFlag((id) => id === TOWER_FLAG_ID)); + ix2.stub(ILogService, stubLog()); ix2.stub(ISessionManager, { get: () => undefined, } as unknown as ISessionManager); @@ -2263,6 +2411,7 @@ describe('AgentTowerService', () => { ix2.stub(IAgentToolApprovalService, { formatDenyMessage }); ix2.stub(ITelemetryService, { track2: () => {} }); ix2.stub(IFlagService, stubFlag(() => false)); + ix2.stub(ILogService, stubLog()); ix2.stub(ISessionManager, { get: (id: string) => (id === 'session-original' ? {} : undefined), } as unknown as ISessionManager); @@ -2343,6 +2492,7 @@ describe('AgentTowerService', () => { ix2.stub(IAgentToolApprovalService, { formatDenyMessage }); ix2.stub(ITelemetryService, { track2: () => {} }); ix2.stub(IFlagService, stubFlag((id) => id === TOWER_FLAG_ID)); + ix2.stub(ILogService, stubLog()); ix2.stub(ISessionManager, { get: (id: string) => (id === 'session-original' ? {} : undefined), } as unknown as ISessionManager); @@ -2419,6 +2569,7 @@ describe('AgentTowerService', () => { ix2.stub(IAgentToolApprovalService, { formatDenyMessage }); ix2.stub(ITelemetryService, { track2: () => {} }); ix2.stub(IFlagService, stubFlag((id) => id === TOWER_FLAG_ID)); + ix2.stub(ILogService, stubLog()); ix2.stub(ISessionContext, { cwd: '/nonexistent-tower-repo', sessionId: 'session-owner', @@ -2476,6 +2627,7 @@ describe('AgentTowerService', () => { ix2.stub(IAgentToolApprovalService, { formatDenyMessage }); ix2.stub(ITelemetryService, { track2: () => {} }); ix2.stub(IFlagService, stubFlag((id) => id === TOWER_FLAG_ID)); + ix2.stub(ILogService, stubLog()); ix2.stub(ISessionContext, { cwd: '/nonexistent-tower-repo' } as unknown as ISessionContext); ix2.stub( IAgentReminderService, @@ -2671,13 +2823,17 @@ describe('AgentTowerService', () => { await execFileAsync('git', ['commit', '-m', 'initial'], { cwd: repo }); const store = new TowerStore(repo); await store.init('session-main'); + await store.plan([{ title: 'Build engine', scope: ['src/engine/**'] }]); + await execFileAsync('git', ['branch', 'feat/build-engine'], { cwd: repo }); await store.registerAgent({ name: 'w1', kind: 'worker', agentId: 'agent-w1', sessionId: 'session-main', + missionId: 'M1', spawnedAt: new Date().toISOString(), }); + await store.updateMission('tower', 'M1', { owner: 'w1' }, { silent: true }); await store.registerAgent({ name: 'w2', kind: 'worker', @@ -2708,7 +2864,7 @@ describe('AgentTowerService', () => { makeAgentScopeContext({ agentId, agentScope: testWireScope('wire', 'tower-test'), generation: 0 }), bus, { list: () => [] } as unknown as IAgentTaskService, - undefined as unknown as ISessionUsageService, + { handleOf: () => undefined } as unknown as IAgentLifecycleService, ); const result = await executeTool(tool, { turnId: 0, toolCallId: 'call_send', args: input, signal }); expect(result.isError).toBeFalsy(); @@ -2720,6 +2876,25 @@ describe('AgentTowerService', () => { expect(sent).toEqual([{ from: 'w1', to: 'tower', subject: 'need wider scope' }]); }); + it('includes stored message references and mission metadata in the inbox event', async () => { + let payload: TowerInboxSent | undefined; + disposables.add( + bus.subscribe(TowerInboxSent, (event) => { + payload = event; + }), + ); + + await sendAs('agent-w1', { to: 'tower', subject: 'digest me', body: 'x' }); + + expect(payload?.messageId).toBeTruthy(); + expect(payload?.file).toBeTruthy(); + expect(payload?.sentAt).toBeTruthy(); + expect(payload?.missionId).toBe('M1'); + const raw = await readFile(join(repo, payload!.file!), 'utf8'); + expect(raw).toContain(payload!.messageId); + expect(raw).toContain(payload!.sentAt); + }); + it('publishes an inbox event when a worker broadcasts', async () => { await sendAs('agent-w1', { to: 'all', subject: 'fyi fleet', body: 'x' }); @@ -2737,6 +2912,163 @@ describe('AgentTowerService', () => { expect(sent).toEqual([]); }); + + it('steers a non-clean review to its owner and wakes the tower without duplicate messages', async () => { + const store = new TowerStore(repo); + await store.registerAgent({ + name: 'r1', + kind: 'reviewer', + agentId: 'agent-r1', + sessionId: 'session-main', + reviewTarget: 'feat/build-engine', + reviewMissionId: 'M1', + spawnedAt: new Date().toISOString(), + }); + const submissions: string[] = []; + const lockStates: boolean[] = []; + disposables.add( + bus.subscribe(TowerInboxSent, () => { + lockStates.push(existsSync(join(repo, `${STATE_FILE}.lock`))); + }), + ); + const ownerLoop = { + snapshot: () => ({ state: 'running' }), + submit: (entry: unknown) => { + lockStates.push(existsSync(join(repo, `${STATE_FILE}.lock`))); + submissions.push(JSON.stringify(entry)); + }, + }; + const tool = new TowerReviewTool( + { cwd: repo } as unknown as ISessionContext, + makeAgentScopeContext({ agentId: 'agent-r1', agentScope: testWireScope('wire', 'tower-test'), generation: 0 }), + bus, + { + list: () => { + lockStates.push(existsSync(join(repo, `${STATE_FILE}.lock`))); + return []; + }, + } as unknown as IAgentTaskService, + { + handleOf: (agentId: string) => + agentId === 'agent-w1' + ? { + accessor: { + get: (token: unknown) => (token === IAgentLoopService ? ownerLoop : undefined), + }, + } + : undefined, + } as unknown as IAgentLifecycleService, + ); + + const result = await executeTool(tool, { + turnId: 0, + toolCallId: 'call_review', + args: { + target: 'feat/build-engine', + status: 'p1-1items', + merge: 'hold', + findings: 'problem', + decision: 'fix it', + }, + signal, + }); + + expect(result.isError).toBeFalsy(); + expect(result.output).toContain('notified: w1, tower'); + expect(submissions).toHaveLength(1); + expect(submissions[0]).toContain('review-result'); + expect(sent).toEqual([{ from: 'r1', to: 'tower', subject: 'review-result' }]); + const messages = await store.readInbox('tower', 20); + expect(messages.filter((item) => item.subject === 'review-result')).toHaveLength(2); + expect(messages.filter((item) => item.to === 'w1')).toHaveLength(1); + expect(messages.filter((item) => item.to === 'tower')).toHaveLength(1); + expect(lockStates.length).toBeGreaterThan(0); + expect(lockStates).not.toContain(true); + }); + + it('wakes the tower for TowerComplete only after the state lock is released', async () => { + await execFileAsync('git', ['checkout', 'feat/build-engine'], { cwd: repo }); + await mkdir(join(repo, 'src/engine'), { recursive: true }); + await writeFile(join(repo, 'src/engine/engine.ts'), 'export const engine = 1;\n'); + await execFileAsync('git', ['add', 'src/engine/engine.ts'], { cwd: repo }); + await execFileAsync('git', ['commit', '-m', 'engine work'], { cwd: repo }); + await execFileAsync('git', ['checkout', 'main'], { cwd: repo }); + const lockStates: boolean[] = []; + disposables.add( + bus.subscribe(TowerInboxSent, () => { + lockStates.push(existsSync(join(repo, `${STATE_FILE}.lock`))); + }), + ); + const tool = new TowerCompleteTool( + { cwd: repo } as unknown as ISessionContext, + makeAgentScopeContext({ agentId: 'agent-w1', agentScope: testWireScope('wire', 'tower-test'), generation: 0 }), + bus, + { list: () => [] } as unknown as IAgentTaskService, + { handleOf: () => undefined } as unknown as IAgentLifecycleService, + ); + + const result = await executeTool(tool, { + turnId: 0, + toolCallId: 'call_complete', + args: { report: 'completed the engine' }, + signal, + }); + + expect(result.isError).toBeFalsy(); + expect(result.output).toContain('mission M1 persisted as completed'); + expect(sent).toEqual([{ from: 'w1', to: 'tower', subject: 'review-request' }]); + expect(lockStates).toEqual([false]); + const store = new TowerStore(repo); + expect((await store.load()).missions[0]?.status).toBe('completed'); + expect((await store.readInbox('tower', 20)).filter((item) => item.action === 'complete')).toHaveLength(1); + }); + + it('redelivers a reused completion message after an initial publish failure', async () => { + await execFileAsync('git', ['checkout', 'feat/build-engine'], { cwd: repo }); + await mkdir(join(repo, 'src/engine'), { recursive: true }); + await writeFile(join(repo, 'src/engine/engine.ts'), 'export const engine = 1;\n'); + await execFileAsync('git', ['add', 'src/engine/engine.ts'], { cwd: repo }); + await execFileAsync('git', ['commit', '-m', 'engine work'], { cwd: repo }); + await execFileAsync('git', ['checkout', 'main'], { cwd: repo }); + const tool = new TowerCompleteTool( + { cwd: repo } as unknown as ISessionContext, + makeAgentScopeContext({ agentId: 'agent-w1', agentScope: testWireScope('wire', 'tower-test'), generation: 0 }), + bus, + { list: () => [] } as unknown as IAgentTaskService, + { handleOf: () => undefined } as unknown as IAgentLifecycleService, + ); + const publish = vi.spyOn(bus, 'publish').mockImplementationOnce(() => { + throw new Error('forced publish failure'); + }); + try { + await expect( + executeTool(tool, { + turnId: 0, + toolCallId: 'call_complete_publish', + args: { report: 'completed after publish failure' }, + signal, + }), + ).rejects.toThrow('forced publish failure'); + const store = new TowerStore(repo); + expect((await store.load()).missions[0]?.status).toBe('completed'); + expect((await store.readInbox('tower', 20)).filter((item) => item.action === 'complete')).toHaveLength(1); + publish.mockRestore(); + + const result = await executeTool(tool, { + turnId: 0, + toolCallId: 'call_complete_publish_retry', + args: { report: 'completed after publish failure' }, + signal, + }); + + expect(result.isError).toBeFalsy(); + expect(result.output).toContain('reused the stored message'); + expect(sent).toEqual([{ from: 'w1', to: 'tower', subject: 'review-request' }]); + expect((await store.readInbox('tower', 20)).filter((item) => item.action === 'complete')).toHaveLength(1); + } finally { + publish.mockRestore(); + } + }); }); describe('inbox wake', () => { @@ -2748,14 +3080,19 @@ describe('AgentTowerService', () => { ix.stub(ISessionEventBus, ix.get(IEventBus) as ISessionEventBus); }); - function publishInbox(input: { from: string; to: string; subject: string }): void { + function publishInbox(input: TowerInboxSentPayload): void { ix.get(IEventBus).publish(new TowerInboxSent(input)); } async function flushWake(): Promise { - await new Promise((resolve) => { - setImmediate(resolve); - }); + await vi + .waitFor( + () => { + expect(loop.queue.hasPendingRequests()).toBe(true); + }, + { timeout: 3000, interval: 5 }, + ) + .catch(() => {}); } function drainWakeMessages(): ContextMessage[] { @@ -2791,6 +3128,39 @@ describe('AgentTowerService', () => { expect(text).toContain('TowerInbox'); }); + it('warns in the wake text when the tower state was recovered after a loss', async () => { + const repo = await mkdtemp(join(tmpdir(), 'tower-wake-recovered-')); + 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 }); + const store = new TowerStore(repo); + await store.init('session-wake'); + const statePath = join(repo, '.tower/comms/state.json'); + const stored = JSON.parse(await readFile(statePath, 'utf8')) as TowerState; + await writeFile( + statePath, + JSON.stringify({ ...stored, recoveredAt: '2026-10-03T00:00:00.000Z' }), + ); + + ix.stub(ISessionContext, { cwd: repo, sessionId: 'session-wake' } as unknown as ISessionContext); + const tower = ix.get(IAgentTowerService); + await tower.enter(); + + publishInbox({ from: 'w1', to: 'tower', subject: 'after loss' }); + await flushWake(); + const messages = drainWakeMessages(); + + expect(messages).toHaveLength(1); + const text = wakeText(messages[0]!); + expect(text).toContain('recovered after a loss at 2026-10-03T00:00:00.000Z'); + expect(text).toContain('Report the loss to the user'); + } finally { + await rm(repo, { recursive: true, force: true }); + } + }); + it('coalesces a burst of inbox messages into a single wake naming the latest', async () => { const tower = ix.get(IAgentTowerService); await tower.enter(); @@ -2811,6 +3181,21 @@ describe('AgentTowerService', () => { expect(drainWakeMessages()).toEqual([]); }); + it('queues consecutive legacy events with the same sender and subject independently', async () => { + const tower = ix.get(IAgentTowerService); + await tower.enter(); + + publishInbox({ from: 'w1', to: 'tower', subject: 'same legacy subject' }); + publishInbox({ from: 'w1', to: 'tower', subject: 'same legacy subject' }); + await flushWake(); + const messages = drainWakeMessages(); + + expect(messages).toHaveLength(1); + const text = wakeText(messages[0]!); + expect(text).toContain('2 new tower inbox messages'); + expect(text.match(/- w1: "same legacy subject"/g)).toHaveLength(2); + }); + it('schedules exactly one follow-up wake for messages arriving while a wake is pending', async () => { const tower = ix.get(IAgentTowerService); await tower.enter(); @@ -2831,6 +3216,178 @@ describe('AgentTowerService', () => { expect(wakeText(second[0]!)).toContain('third'); }); + it('deduplicates a burst and bounds references while preserving the omitted count', async () => { + const tower = ix.get(IAgentTowerService); + await tower.enter(); + + for (let index = 1; index <= 8; index += 1) { + publishInbox({ + from: 'w1', + to: 'tower', + subject: `subject-${String(index)}`, + messageId: `msg-${String(index)}`, + }); + } + publishInbox({ from: 'w1', to: 'tower', subject: 'duplicate', messageId: 'msg-1' }); + await flushWake(); + const messages = drainWakeMessages(); + + expect(messages).toHaveLength(1); + const text = wakeText(messages[0]!); + expect(text).toContain('8 new tower inbox messages'); + expect(text).toContain('omitted=2'); + expect(text).not.toContain('messageId=msg-1\n'); + expect(text).not.toContain('messageId=msg-2\n'); + expect(text).toContain('messageId=msg-3'); + expect(text).toContain('messageId=msg-8'); + }); + + it('still wakes with message references when gate observation fails', async () => { + const tower = ix.get(IAgentTowerService); + await tower.enter(); + + publishInbox({ + from: 'w1', + to: 'tower', + subject: 'needs review', + messageId: 'msg-observation', + file: '.tower/comms/inbox/needs-review.md', + missionId: 'M1', + }); + await flushWake(); + await vi.waitFor(() => { + expect(loop.snapshot().hasPendingRequests).toBe(true); + }); + const messages = drainWakeMessages(); + + expect(messages).toHaveLength(1); + const text = wakeText(messages[0]!); + expect(text).toContain('messageId=msg-observation'); + expect(text).toContain('M1: gate=unavailable'); + expect(text).toContain('TowerInbox'); + }); + + it('includes message references and computes each related mission gate once', async () => { + const repo = await mkdtemp(join(tmpdir(), 'tower-wake-digest-')); + const gate = vi.spyOn(TowerStore.prototype, 'missionGate'); + 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 }); + const store = new TowerStore(repo); + await store.init('session-main'); + await store.plan([ + { title: 'first mission', scope: ['src/first/**'] }, + { title: 'second mission', scope: ['src/second/**'] }, + ]); + ix.stub(ISessionContext, { cwd: repo, sessionId: 'session-main' } as unknown as ISessionContext); + ix.stub(IAgentTaskService, { list: () => [] } as unknown as IAgentTaskService); + const tower = ix.get(IAgentTowerService); + await tower.enter(); + + publishInbox({ + from: 'w1', + to: 'tower', + subject: 'first update', + messageId: 'msg-digest-1', + file: '.tower/comms/inbox/first.md', + sentAt: '2026-09-30T00:00:00.000Z', + missionId: 'M1', + }); + publishInbox({ + from: 'w1', + to: 'tower', + subject: 'second update', + messageId: 'msg-digest-2', + file: '.tower/comms/inbox/second.md', + sentAt: '2026-09-30T00:00:01.000Z', + missionId: 'M1', + }); + publishInbox({ + from: 'w2', + to: 'all', + subject: 'third update', + messageId: 'msg-digest-3', + file: '.tower/comms/inbox/third.md', + sentAt: '2026-09-30T00:00:02.000Z', + missionId: 'M2', + }); + await flushWake(); + await vi.waitFor(() => { + expect(loop.snapshot().hasPendingRequests).toBe(true); + }); + const messages = drainWakeMessages(); + + expect(messages).toHaveLength(1); + const text = wakeText(messages[0]!); + expect(text).toContain('messageId=msg-digest-1'); + expect(text).toContain('file=.tower/comms/inbox/first.md'); + expect(text).toContain('sentAt=2026-09-30T00:00:00.000Z'); + expect(text).toContain('mission=M1'); + expect(text).toContain('M1: gate=BLOCKED reasons=not-completed'); + expect(text).toContain('M2: gate=BLOCKED reasons=not-completed'); + expect(gate.mock.calls.filter(([, mission]) => mission.id === 'M1')).toHaveLength(1); + expect(gate.mock.calls.filter(([, mission]) => mission.id === 'M2')).toHaveLength(1); + expect(gate.mock.calls[0]?.[2]?.activeAgentIds).toEqual(new Set()); + } finally { + gate.mockRestore(); + await rm(repo, { recursive: true, force: true }); + } + }); + + it('does not notify a stale flush after exit and re-entry while gate evaluation is deferred', async () => { + const repo = await mkdtemp(join(tmpdir(), 'tower-wake-epoch-')); + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const gate = vi.spyOn(TowerStore.prototype, 'missionGate').mockImplementationOnce(async () => { + await pending; + return { ready: false, blocks: [], observations: {} } as never; + }); + 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 }); + const store = new TowerStore(repo); + await store.init('session-main'); + await store.plan([{ title: 'deferred mission', scope: ['src/deferred/**'] }]); + ix.stub(ISessionContext, { cwd: repo, sessionId: 'session-main' } as unknown as ISessionContext); + ix.stub(IAgentTaskService, { list: () => [] } as unknown as IAgentTaskService); + const tower = ix.get(IAgentTowerService); + await tower.enter(); + publishInbox({ + from: 'w1', + to: 'tower', + subject: 'old flush', + messageId: 'msg-old-flush', + missionId: 'M1', + }); + await vi.waitFor(() => { + expect(gate).toHaveBeenCalled(); + }); + + await tower.exit(); + await tower.enter(); + release(); + await flushWake(); + expect(drainWakeMessages()).toEqual([]); + + publishInbox({ from: 'w1', to: 'tower', subject: 'new flush', messageId: 'msg-new-flush' }); + await flushWake(); + const messages = drainWakeMessages(); + expect(messages).toHaveLength(1); + expect(wakeText(messages[0]!)).toContain('messageId=msg-new-flush'); + expect(wakeText(messages[0]!)).not.toContain('messageId=msg-old-flush'); + } finally { + release(); + gate.mockRestore(); + await rm(repo, { recursive: true, force: true }); + } + }); + it('ignores messages addressed to a specific agent and messages from the tower itself', async () => { const tower = ix.get(IAgentTowerService); await tower.enter(); @@ -2992,7 +3549,13 @@ describe('AgentTowerService', () => { const tower = ix.get(IAgentTowerService); await tower.enter(); - publishInbox({ from: 'w1', to: 'tower', subject: 'need review' }); + publishInbox({ + from: 'w1', + to: 'tower', + subject: 'need review', + messageId: 'msg-rearm', + file: '.tower/comms/inbox/rearm.md', + }); await flushWake(); expect(drainWakeMessages()).toHaveLength(1); @@ -3017,6 +3580,8 @@ describe('AgentTowerService', () => { expect(rearmed).toHaveLength(1); expect(wakeText(rearmed[0]!)).toContain('1 new tower inbox message'); expect(wakeText(rearmed[0]!)).toContain('w1'); + expect(wakeText(rearmed[0]!)).toContain('messageId=msg-rearm'); + expect(wakeText(rearmed[0]!)).toContain('file=.tower/comms/inbox/rearm.md'); }); }); @@ -3037,6 +3602,7 @@ describe('AgentTowerService', () => { agentId: 'agent-w1', sessionId: 'session-main', missionId: 'M1', + branch: 'feat/leased', spawnedAt: new Date().toISOString(), }); ix.stub(ISessionContext, { cwd: repo, sessionId: 'session-main' } as unknown as ISessionContext); @@ -3103,6 +3669,28 @@ describe('AgentTowerService', () => { expect(formatDenyMessage).not.toHaveBeenCalled(); }); + it('vetoes even a background resume while its branch is leased', async () => { + const tower = ix.get(IAgentTowerService); + await tower.enter(); + + await tower.withBranchLease('feat/leased', async () => { + const decision = await fire( + agentHookContext({ + resume: 'agent-w1', + run_in_background: true, + prompt: 'keep going', + description: 'resume w1', + }), + ); + expect(decision?.veto?.isError).toBe(true); + expect(decision?.veto?.output).toContain('leased by a merge/rebase'); + expect(decision?.veto?.output).toContain('no agent was started'); + }); + + expect(permissionGateRan).toBe(false); + expect(formatDenyMessage).toHaveBeenCalledTimes(1); + }); + it('allows a foreground resume of an agent outside the roster', async () => { const tower = ix.get(IAgentTowerService); await tower.enter(); @@ -3155,6 +3743,352 @@ describe('AgentTowerService', () => { expect(formatDenyMessage).not.toHaveBeenCalled(); }); }); + describe('bash destructive guard', () => { + let repo: string; + let slot: string; + + beforeEach(async () => { + repo = await mkdtemp(join(tmpdir(), 'tower-bash-guard-')); + 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-main'); + slot = join(repo, '.tower', 'worktrees', 'wt-1'); + await mkdir(slot, { recursive: true }); + ix.stub(ISessionContext, { cwd: repo, sessionId: 'session-main' } as unknown as ISessionContext); + ix.stub(IBashParserService, new BashParserService()); + }); + + afterEach(async () => { + await rm(repo, { recursive: true, force: true }); + }); + + function bashHookContext(command: string, cwd?: string): ResolvedToolExecutionHookContext { + const call = toolCall('Bash', 'call_bash'); + return { + turnId: 0, + signal, + toolCall: call, + toolCalls: [call], + args: cwd === undefined ? { command } : { command, cwd }, + execution: { approvalRule: 'Bash', execute: async () => ({ output: '' }) }, + }; + } + + it.each([ + 'git clean -fdx', + 'git clean -ffdx', + 'git clean -fx', + 'git clean -f -d -x', + 'git clean --force -dx', + 'git clean -fdx .', + 'git clean -fdx .tower', + 'git clean -fdx -- .tower/comms', + 'git clean -fdX -- .tower/comms', + 'git clean -fdx .tower/comms/state.json', + ])('vetoes "%s" at the main checkout without tower mode active (worker shape)', async (command) => { + ix.get(IAgentTowerService); + + const decision = await fire(bashHookContext(command)); + + expect(decision?.veto?.isError).toBe(true); + expect(decision?.veto?.output).toContain('.tower/comms'); + expect(decision?.veto?.output).toContain('worktree'); + expect(permissionGateRan).toBe(false); + expect(formatDenyMessage).toHaveBeenCalledTimes(1); + }); + + it('vetoes git clean at the main checkout while tower mode is active (main agent shape)', async () => { + const tower = ix.get(IAgentTowerService); + await tower.enter(); + + const decision = await fire(bashHookContext('git clean -fdx')); + + expect(decision?.veto?.isError).toBe(true); + expect(decision?.veto?.output).toContain('.tower/comms'); + expect(permissionGateRan).toBe(false); + }); + + it('passes dangerous commands once the tower workspace ownership is released', async () => { + const statePath = join(repo, '.tower/comms/state.json'); + const stored = JSON.parse(await readFile(statePath, 'utf8')) as TowerState; + await writeFile(statePath, JSON.stringify({ ...stored, sessionId: undefined })); + + ix.get(IAgentTowerService); + + const decision = await fire(bashHookContext('git clean -fdx')); + + expect(decision).toBeUndefined(); + expect(permissionGateRan).toBe(true); + }); + + it('vetoes git clean targeting the main checkout from outside via git -C', async () => { + ix.get(IAgentTowerService); + + const decision = await fire(bashHookContext(`git -C ${repo} clean -fdx`, tmpdir())); + + expect(decision?.veto?.isError).toBe(true); + expect(permissionGateRan).toBe(false); + }); + + it.each([ + 'git clean -ndx', + 'git clean -n', + 'git clean --dry-run -fdx', + 'git clean -fdxn', + 'git clean -f', + 'git clean -fd', + 'git clean -ffd', + 'git clean --force -d', + 'git clean -fd .tower', + 'git clean -fd -- .tower/comms', + 'git clean -fd .tower/comms/state.json', + 'git clean -fdx packages', + ])('allows "%s" (dry-run, no -x/-X, files-only, or out-of-scope pathspec)', async (command) => { + ix.get(IAgentTowerService); + + const decision = await fire(bashHookContext(command)); + + expect(decision).toBeUndefined(); + expect(permissionGateRan).toBe(true); + expect(formatDenyMessage).not.toHaveBeenCalled(); + }); + + it('allows git clean and git reset --hard inside a tower worktree', async () => { + ix.get(IAgentTowerService); + + for (const command of [ + `cd ${slot} && git clean -fdx`, + `cd ${slot} && git clean -fdx .`, + `cd ${slot} && git clean -fdx ./`, + `cd ${slot} && git clean -fdx -- .`, + `cd ${slot} && git clean -fdx node_modules`, + `cd ${join(slot, 'pkg')} && git clean -fdx ..`, + `git -C ${slot} clean -fdx`, + `git -C ${slot} clean -fdx .`, + `cd ${slot} && git reset --hard`, + `git -C ${slot} reset --hard HEAD~1`, + ]) { + const decision = await fire(bashHookContext(command)); + + expect(decision, command).toBeUndefined(); + } + expect(permissionGateRan).toBe(true); + }); + + it('allows git clean with a dot pathspec in a worktree via the Bash cwd argument', async () => { + ix.get(IAgentTowerService); + + for (const command of ['git clean -fdx', 'git clean -fdx .', 'git clean -fdx -- .']) { + const decision = await fire(bashHookContext(command, slot)); + + expect(decision, command).toBeUndefined(); + } + expect(permissionGateRan).toBe(true); + }); + + it('still vetoes git clean naming a worktree slot root from the main checkout', async () => { + ix.get(IAgentTowerService); + + for (const command of [ + 'git clean -fdx .tower/worktrees/wt-1', + 'git clean -ffdx .tower/worktrees/wt-1', + `git clean -fdx ${slot}`, + ]) { + const decision = await fire(bashHookContext(command)); + + expect(decision?.veto?.isError, command).toBe(true); + } + }); + + it('still vetoes git clean whose pathspec escapes a worktree slot into tower protocol paths', async () => { + ix.get(IAgentTowerService); + + const decision = await fire(bashHookContext(`cd ${slot} && git clean -fdx ../../comms`)); + + expect(decision?.veto?.isError).toBe(true); + expect(decision?.veto?.output).toContain('.tower/comms'); + }); + + it.each([ + 'git reset --hard', + 'git reset --hard HEAD~1', + 'git reset --hard origin/main', + ])('vetoes "%s" at the main checkout', async (command) => { + ix.get(IAgentTowerService); + + const decision = await fire(bashHookContext(command)); + + expect(decision?.veto?.isError).toBe(true); + expect(decision?.veto?.output).toContain('git reset --hard'); + expect(decision?.veto?.output).toContain('worktree'); + expect(permissionGateRan).toBe(false); + }); + + it.each(['git reset', 'git reset --soft HEAD~1', 'git reset --mixed'])( + 'allows "%s" without --hard', + async (command) => { + ix.get(IAgentTowerService); + + const decision = await fire(bashHookContext(command)); + + expect(decision).toBeUndefined(); + expect(permissionGateRan).toBe(true); + }, + ); + + it('vetoes git -C reset --hard whose target is the main checkout even from a worktree cwd', async () => { + ix.get(IAgentTowerService); + + const decision = await fire(bashHookContext(`git -C ${repo} reset --hard`, slot)); + + expect(decision?.veto?.isError).toBe(true); + expect(permissionGateRan).toBe(false); + }); + + it.each([ + 'rm -rf .tower', + 'rm -rf .tower/comms', + 'rm -f .tower/comms/state.json', + 'rm --recursive --force .tower', + 'rm -rf .tower/worktrees', + ])('vetoes "%s" on tower protocol paths', async (command) => { + ix.get(IAgentTowerService); + + const decision = await fire(bashHookContext(command)); + + expect(decision?.veto?.isError).toBe(true); + expect(decision?.veto?.output).toContain('.tower/comms'); + expect(permissionGateRan).toBe(false); + }); + + it('vetoes recursive rm of the main checkout root (an ancestor of .tower)', async () => { + ix.get(IAgentTowerService); + + const decision = await fire(bashHookContext(`rm -rf ${repo}`)); + + expect(decision?.veto?.isError).toBe(true); + expect(permissionGateRan).toBe(false); + }); + + it.each([ + 'rm -rf node_modules', + 'rm -f README.md', + 'rm -rf .tmp', + ])('allows "%s" on ordinary main-checkout paths', async (command) => { + ix.get(IAgentTowerService); + + const decision = await fire(bashHookContext(command)); + + expect(decision).toBeUndefined(); + expect(permissionGateRan).toBe(true); + }); + + it('allows rm -rf inside a worktree slot', async () => { + ix.get(IAgentTowerService); + + const decision = await fire(bashHookContext(`rm -rf ${join(slot, 'node_modules')}`)); + + expect(decision).toBeUndefined(); + expect(permissionGateRan).toBe(true); + }); + + it('tracks cd chains across && lists', async () => { + ix.get(IAgentTowerService); + + const allowed = await fire(bashHookContext('cd packages && git clean -fdx')); + const vetoed = await fire(bashHookContext('cd packages && cd .. && git clean -fdx')); + + expect(allowed).toBeUndefined(); + expect(vetoed?.veto?.isError).toBe(true); + }); + + it.each([ + "bash -c 'git clean -fdx'", + 'sudo rm -rf .tower', + 'env FOO=1 git reset --hard', + 'eval "git clean -fdx"', + ])('vetoes "%s" through wrappers and nested shells', async (command) => { + ix.get(IAgentTowerService); + + const decision = await fire(bashHookContext(command)); + + expect(decision?.veto?.isError).toBe(true); + expect(permissionGateRan).toBe(false); + }); + + it('allows a command that only mentions the keywords in string literals', async () => { + ix.get(IAgentTowerService); + + const decision = await fire(bashHookContext('echo "git clean -fdx" && printf "rm -rf"')); + + expect(decision).toBeUndefined(); + expect(permissionGateRan).toBe(true); + expect(formatDenyMessage).not.toHaveBeenCalled(); + }); + + it('passes commands without dangerous keywords with zero parse cost', async () => { + const parse = vi.fn(); + ix.stub(IBashParserService, { parse } as unknown as IBashParserService); + ix.get(IAgentTowerService); + + const first = await fire(bashHookContext('ls -la && git status')); + const second = await fire(bashHookContext('echo format armory cleanup resetting')); + + expect(first).toBeUndefined(); + expect(second).toBeUndefined(); + expect(parse).not.toHaveBeenCalled(); + }); + + it('allows dangerous-shaped commands when no tower state exists, without parsing', async () => { + const plain = await mkdtemp(join(tmpdir(), 'tower-bash-guard-plain-')); + try { + ix.stub(ISessionContext, { cwd: plain, sessionId: 'session-plain' } as unknown as ISessionContext); + const parse = vi.fn(); + ix.stub(IBashParserService, { parse } as unknown as IBashParserService); + ix.get(IAgentTowerService); + + const decision = await fire(bashHookContext('git clean -fdx')); + + expect(decision).toBeUndefined(); + expect(parse).not.toHaveBeenCalled(); + } finally { + await rm(plain, { recursive: true, force: true }); + } + }); + + it('vetoes conservatively when the command cannot be parsed', async () => { + const parse = vi.fn(() => ({ ok: false as const, reason: 'aborted' as const })); + ix.stub(IBashParserService, { parse } as unknown as IBashParserService); + ix.get(IAgentTowerService); + + const decision = await fire(bashHookContext('git clean -fdx')); + + expect(decision?.veto?.isError).toBe(true); + expect(decision?.veto?.output).toContain('could not parse'); + expect(permissionGateRan).toBe(false); + }); + + it('vetoes conservatively when the parsed tree carries errors', async () => { + const root = { + type: 'program', + text: 'git clean -fdx', + startIndex: 0, + endIndex: 'git clean -fdx'.length, + isNamed: true, + children: [], + }; + const parse = vi.fn(() => ({ ok: true as const, hasError: true, root })); + ix.stub(IBashParserService, { parse } as unknown as IBashParserService); + ix.get(IAgentTowerService); + + const decision = await fire(bashHookContext('git clean -fdx')); + + expect(decision?.veto?.isError).toBe(true); + expect(decision?.veto?.output).toContain('could not parse'); + }); + }); }); async function injectDynamic(ctx: TestAgentContext): Promise { @@ -3220,8 +4154,16 @@ describe('TowerModeInjection', () => { expect(text).toContain('Tower mode is active'); expect(text).toContain('TowerSpawn'); expect(text).toContain('TowerMerge'); - expect(text).toContain('TowerSend` is delivery, not interruption'); - expect(text).toContain('no silent miss is possible'); + expect(text).toContain('message references and a compact gate digest'); + expect(text).toContain('with `TowerInbox` only when it is needed'); + expect(text).toContain('structured reason and recovery details'); + expect(text).toContain('per reviewer'); + expect(text).toContain('merged or abandoned'); + expect(text).toContain('background execution is unavailable'); + expect(text).toContain('break-glass'); + expect(text).toContain('.tower/comms/'); + expect(text).not.toContain('This supersedes any other instructions'); + expect(text).not.toContain('never create or edit files under `.tower/` by hand'); }); it('injects the exit reminder when tower mode turns off after being active', async () => { @@ -3282,7 +4224,8 @@ describe('TowerModeInjection', () => { const text = lastTowerReminder(context); expect(text).toContain('Tower mode still active'); - expect(text).toContain('see full instructions earlier'); + expect(text).toContain('TodoList is code-denied'); + expect(text).not.toContain('see full instructions earlier'); }); it('refreshes the full reminder after the long assistant-turn threshold', async () => { @@ -3299,6 +4242,17 @@ describe('TowerModeInjection', () => { expect(text).not.toContain('Tower mode still active'); }); + it('does not refresh the full reminder for an internal injection user-role message', async () => { + await tower.enter(); + + await injectDynamic(ctx); + appendAssistantTurn(ctx, context, 'assistant one'); + ctx.appendSystemReminder('inbox wake', { kind: 'injection', variant: 'tower_inbox' }); + await injectDynamic(ctx); + + expect(towerReminderMessages(context)).toHaveLength(1); + }); + it('refreshes the full reminder when a user message follows at least one assistant turn', async () => { await tower.enter(); @@ -3338,6 +4292,7 @@ describe('TowerModeInjection', () => { expect(towerReminderMessages(context)).toHaveLength(3); expect(lastTowerReminder(context)).toContain('Tower mode is active'); }); + }); describe('towerEnterFailureMessage', () => { diff --git a/packages/agent-core-v2/test/features/tower/workerProfile.test.ts b/packages/agent-core-v2/test/features/tower/workerProfile.test.ts index f699dbb1577..4b87fdfb9bc 100644 --- a/packages/agent-core-v2/test/features/tower/workerProfile.test.ts +++ b/packages/agent-core-v2/test/features/tower/workerProfile.test.ts @@ -11,17 +11,18 @@ function builtinProfile(name: string) { } describe('tower-worker profile', () => { - it('gives tower-worker the coder tools (minus AgentSwarm) plus the six shared tower tools', () => { + it('gives tower-worker the coder tools (minus Agent and AgentSwarm) plus the seven shared tower tools', () => { const coder = builtinProfile('coder'); const tools = TOWER_WORKER_PROFILE_DEF.tools ?? []; for (const name of coder.tools ?? []) { - if (name === 'AgentSwarm') continue; + if (name === 'Agent' || name === 'AgentSwarm') continue; expect(tools).toContain(name); } + expect(tools).not.toContain('Agent'); expect(tools).not.toContain('AgentSwarm'); - expect(TOWER_WORKER_PROFILE_DEF.subagents).toEqual(['explore', 'plan']); for (const name of [ + 'TowerComplete', 'TowerSend', 'TowerInbox', 'TowerFinding', @@ -31,7 +32,7 @@ describe('tower-worker profile', () => { ]) { expect(tools).toContain(name); } - for (const name of ['TowerInit', 'TowerPlan', 'TowerSpawn', 'TowerMerge', 'TowerTeardown']) { + for (const name of ['TowerInit', 'TowerPlan', 'TowerSpawn', 'TowerMerge', 'TowerRebase', 'TowerTeardown']) { expect(tools).not.toContain(name); } }); diff --git a/packages/agent-core-v2/test/state/builtinReplayableKeys.ts b/packages/agent-core-v2/test/state/builtinReplayableKeys.ts index 006fbd3d15a..9be52b5a085 100644 --- a/packages/agent-core-v2/test/state/builtinReplayableKeys.ts +++ b/packages/agent-core-v2/test/state/builtinReplayableKeys.ts @@ -20,7 +20,7 @@ import { userToolKey } from '#/agent/userTool/userToolOps'; import { fileHistoryKey } from '#/features/fileHistory/fileHistoryOps'; import { planKey } from '#/features/plan/planOps'; import { swarmKey } from '#/features/swarm/swarmOps'; -import { towerBaseKey, towerKey, towerOwnerKey } from '#/features/tower/towerOps'; +import { towerKey, towerOwnerKey } from '#/features/tower/towerOps'; export const BUILTIN_REPLAYABLE_STATE_KEYS: readonly ReplayableStateKey[] = [ contextMemoryKey, @@ -45,5 +45,4 @@ export const BUILTIN_REPLAYABLE_STATE_KEYS: readonly ReplayableStateKey[] = swarmKey, towerKey, towerOwnerKey, - towerBaseKey, ]; diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 9eea2a14142..278cbe594d4 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -4,7 +4,7 @@ import type { SwarmModeTrigger } from '@moonshot-ai/agent-core-v2/features/swarm import type { Kaos } from '@moonshot-ai/kaos'; import type { AgentContextData } from '#/context'; -import { ErrorCodes, makeErrorPayload } from '#/errors'; +import { ErrorCodes, KimiError, makeErrorPayload } from '#/errors'; import type { ApprovalHandler, Event, @@ -370,6 +370,13 @@ export abstract class SDKRpcClientBase { abstract setTowerMode(input: SetSessionTowerModeRpcInput): Promise; + async getTowerStatus(_input: SessionIdRpcInput): Promise { + throw new KimiError( + ErrorCodes.NOT_IMPLEMENTED, + 'Tower status is not implemented for this engine', + ); + } + abstract getPlan(input: SessionIdRpcInput): Promise; abstract clearPlan(input: SessionIdRpcInput): Promise; diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 3fecd79aa04..64d76e11940 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -119,7 +119,8 @@ * `ISessionBtwService`; `setSwarmMode` / `swarm` → the agent scope's * `IAgentSwarmService` (the v2 port of v1's `SwarmMode`), with `swarm()` * recomposed over the `setSwarmMode` + `prompt` overrides; `setTowerMode` → - * the agent scope's `IAgentTowerService` (v2-only — the base class throws + * the agent scope's `IAgentTowerService`; `getTowerStatus` → the shared + * tower status summary reader (v2-only — the base class throws * `not_implemented`). * `createSessionWithKaos` / `resumeSessionWithKaos` deliberately keep the * base class's kaos-ignoring degradation (the v2 engine has no kaos @@ -132,6 +133,8 @@ import { join } from 'node:path'; import { encodeWorkDirKey } from '@moonshot-ai/agent-core-v2/_base/utils/workdir-slug'; import { McpConnectionManager } from '@moonshot-ai/agent-core-v2/mcpCore/connection-manager'; +import { ITowerRateLimitService } from '@moonshot-ai/agent-core-v2/features/tower/towerRateLimit'; +import { readTowerStatusSummary } from '@moonshot-ai/agent-core-v2/features/tower/tools/status/statusReader'; import { loadMcpServers } from '@moonshot-ai/agent-core-v2/app/mcpConfig/configLoader'; import { fsSuggestRequestSchema } from '@moonshot-ai/agent-core-v2/workspace/workspaceFs/fs'; import { IAppendLogStore } from '@moonshot-ai/agent-core-v2/persistence/interface/appendLogStore'; @@ -2245,6 +2248,18 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { await agent.accessor.get(IAgentReminderService).reconcileWhenIdle('tower_mode'); } + /** Reads the shared tower status summary; the main agent's tower service is authoritative. */ + override async getTowerStatus(input: SessionIdRpcInput): Promise { + const agent = await this.materializeMainAgent(this.requireLiveSession(input.sessionId)); + return readTowerStatusSummary({ + active: agent.accessor.get(IAgentTowerService).isActive, + cwd: agent.accessor.get(ISessionContext).cwd, + agentId: MAIN_AGENT_ID, + tasks: agent.accessor.get(IAgentTaskService), + concurrency: () => agent.accessor.get(ITowerRateLimitService).snapshot(), + }); + } + // ----------------------------------------------------------------------- // Goal / cron / background tasks / print policy // diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 9ea338f0d6d..8186844d107 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -355,6 +355,11 @@ export class Session { await this.rpc.setTowerMode({ sessionId: this.id, enabled, base }); } + async getTowerStatus(): Promise { + this.ensureOpen(); + return this.rpc.getTowerStatus({ sessionId: this.id }); + } + async getPlan(): Promise { this.ensureOpen(); return this.rpc.getPlan({ sessionId: this.id }); diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index 03ff1d07733..9427969c9aa 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -8,9 +8,11 @@ * Wiring: real v2 engine bootstrapped on a temp KIMI_CODE_HOME; remote provider calls are stubbed. * Run: pnpm exec vitest run test/sdk-rpc-client-v2.test.ts */ +import { execFile } from 'node:child_process'; import { mkdir, mkdtemp, readdir, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { promisify } from 'node:util'; import { FileTokenStorage, @@ -28,7 +30,9 @@ import { KimiHarness, limitAgentReplayByTurns, removeProviderFromConfig, + SDKRpcClientBase, SDKRpcClientV2, + Session, toKimiErrorPayload, type Event, type KimiConfig, @@ -59,6 +63,7 @@ import { } from '@moonshot-ai/agent-core-v2'; import { McpOAuthService as McpOAuthServiceV2 } from '@moonshot-ai/agent-core-v2/mcpCore/oauth/service'; +import { TowerStore } from '@moonshot-ai/agent-core-v2/features/tower/protocol/store'; import { TEST_IDENTITY } from './test-identity'; import { @@ -1390,6 +1395,15 @@ key = "${titleOAuthRef.key}" } }); + it('defaults getTowerStatus to not_implemented on backends without support', async () => { + const rpc = Object.create(SDKRpcClientBase.prototype) as SDKRpcClientBase; + const session = new Session({ id: 'ses_tower_unsupported', workDir: '/tmp/work', rpc }); + + await expect(session.getTowerStatus()).rejects.toMatchObject({ + code: ErrorCodes.NOT_IMPLEMENTED, + }); + }); + it('serves setTowerMode and getStatus towerMode through the agent scope tower service', async () => { vi.stubEnv('KIMI_CODE_EXPERIMENTAL_TOWER', '1'); const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); @@ -1455,7 +1469,7 @@ key = "${titleOAuthRef.key}" } }); - it('exposes Session.setTowerMode and getStatus().towerMode on the v2 harness', async () => { + it('exposes Session tower mode and status APIs on the v2 harness', async () => { vi.stubEnv('KIMI_CODE_EXPERIMENTAL_TOWER', '1'); const { harness } = await makeHarness(); const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); @@ -1463,12 +1477,15 @@ key = "${titleOAuthRef.key}" try { const session = await harness.createSession({ workDir }); expect((await session.getStatus()).towerMode).toBe(false); + expect(await session.getTowerStatus()).toBe('Tower mode: OFF'); await expect(session.setTowerMode(true)).resolves.toBeUndefined(); expect(typeof (await session.getStatus()).towerMode).toBe('boolean'); + expect(await session.getTowerStatus()).toBe('Tower mode: ON\nTower is not initialized.'); await expect(session.setTowerMode(false)).resolves.toBeUndefined(); expect((await session.getStatus()).towerMode).toBe(false); + expect(await session.getTowerStatus()).toBe('Tower mode: OFF'); await expect(session.setTowerMode('yes' as unknown as boolean)).rejects.toMatchObject({ code: ErrorCodes.REQUEST_INVALID, @@ -1478,6 +1495,61 @@ key = "${titleOAuthRef.key}" await harness.close(); } }); + + it('serves the initialized tower status as an engine-rendered text summary', async () => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_TOWER', '1'); + const { harness } = await makeHarness(); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + try { + const exec = promisify(execFile); + await exec('git', ['init', '-b', 'main'], { cwd: workDir }); + await exec( + 'git', + [ + '-c', + 'user.email=tower-test@example.com', + '-c', + 'user.name=Tower Test', + 'commit', + '--allow-empty', + '-m', + 'initial', + ], + { cwd: workDir }, + ); + const session = await harness.createSession({ id: 'ses_tower_status', workDir }); + await session.setTowerMode(true); + const store = new TowerStore(workDir); + await store.init(session.id, 'main'); + await store.plan([{ title: 'Build engine', scope: ['src/engine/**'] }]); + + const status = await session.getTowerStatus(); + + const [headline, ...bodyLines] = status.split('\n'); + expect(headline).toBe('Tower status — ON'); + const body = bodyLines.join('\n'); + expect(body).toContain('Base: main (mode: branch) · You are: tower'); + expect(body).toContain('Missions:\n M1 Build engine — planned · owner — · feat/build-engine'); + expect(body).toContain('Roster:\n (none)'); + expect(body).toContain('Review gate:\n M1 feat/build-engine — BLOCKED: not-completed'); + expect(body).toContain('Inbox: 0 message(s)'); + expect(body).toContain('Concurrency: budget: '); + expect(body).toContain('Recent activity:'); + expect(body).toContain(' tower init'); + + await session.setTowerMode(false); + const inactiveStatus = await session.getTowerStatus(); + const [inactiveHeadline, ...inactiveBodyLines] = inactiveStatus.split('\n'); + expect(inactiveHeadline).toBe('Tower mode: OFF'); + const inactiveBody = inactiveBodyLines.join('\n'); + expect(inactiveBody).toContain(body); + expect(inactiveBody).toContain(' tower release session=ses_tower_status'); + } finally { + vi.unstubAllEnvs(); + await harness.close(); + } + }); }); describe('SDKRpcClientV2 workspace trust', () => {