Skip to content

Commit 2cdb90f

Browse files
author
konghuanjun
committed
feat(tower): interrupt tower wake turns on user input and harden tower operations
- tower mode: a message sent while the agent is running aborts the in-progress inbox-wake turn and runs as its own turn; notification-seeded turn waiters now settle launched/completion - TowerTeardown: keeps worktrees of live agents (not overridable with force), supports exclude and dry_run, and treats large gitignored data as dirty - tower roster: died/revived store writes are guarded to the active tower session and attributed with session+pid; agents stopped by the user are marked dead by intent with no revival nag - tests: pin background panel registration for resumed background agents
1 parent be7d5f5 commit 2cdb90f

18 files changed

Lines changed: 1228 additions & 47 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": minor
3+
---
4+
5+
Tower mode: a message sent while the agent is running now aborts the in-progress turn and runs as its own turn; TowerTeardown now protects running agents' worktrees and accepts exclude and dry_run parameters; user-stopped workers are no longer suggested for revival.

‎apps/kimi-code/test/tui/controllers/subagent-event-handler.test.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ function makeSwarmHandler() {
2929
getTurnContext: vi.fn(() => ({ turnId: 1, step: 0 })),
3030
removeToolComponentIfInactive: vi.fn(),
3131
finalizeLiveTextBuffers: vi.fn(),
32+
applyBackgroundTaskTerminalStatus: vi.fn(),
33+
markSubagentBackgrounded: vi.fn(),
3234
},
3335
appendTranscriptEntry: vi.fn(),
3436
btwPanelController: { routeEvent: vi.fn(() => false) },
@@ -181,3 +183,81 @@ describe('SubAgentEventHandler — swarm grid height measurement', () => {
181183
handler.clearAgentSwarmProgress();
182184
});
183185
});
186+
187+
describe('SubAgentEventHandler — background agent resume', () => {
188+
function backgroundLifecycleEvent(
189+
type: 'subagent.spawned' | 'subagent.completed' | 'subagent.cancelled',
190+
subagentId: string,
191+
parentToolCallId: string,
192+
): SubagentLifecycleEvent {
193+
return {
194+
sessionId: 's1',
195+
agentId: 'main',
196+
type,
197+
subagentId,
198+
subagentName: 'explore',
199+
parentToolCallId,
200+
description: `task ${subagentId}`,
201+
runInBackground: true,
202+
resultSummary: type === 'subagent.completed' ? 'done' : undefined,
203+
} as unknown as SubagentLifecycleEvent;
204+
}
205+
206+
function backgroundChildDelta(subagentId: string): Event {
207+
return {
208+
sessionId: 's1',
209+
agentId: subagentId,
210+
type: 'assistant.delta',
211+
delta: 'progress',
212+
} as unknown as Event;
213+
}
214+
215+
it('re-registers a resumed background agent and flips its activity record back to running', () => {
216+
const { handler, host } = makeSwarmHandler();
217+
handler.handleLifecycleEvent(backgroundLifecycleEvent('subagent.spawned', 'agent-2', 'tc-first'));
218+
handler.routeChildAgentEvent(backgroundChildDelta('agent-2'));
219+
handler.handleLifecycleEvent(
220+
backgroundLifecycleEvent('subagent.cancelled', 'agent-2', 'tc-first'),
221+
);
222+
expect(handler.backgroundAgentMetadata.has('agent-2')).toBe(false);
223+
expect(handler.activityStore.get('agent-2')?.status).toBe('failed');
224+
225+
handler.handleLifecycleEvent(
226+
backgroundLifecycleEvent('subagent.spawned', 'agent-2', 'tc-resume'),
227+
);
228+
229+
expect(handler.backgroundAgentMetadata.get('agent-2')).toMatchObject({
230+
agentId: 'agent-2',
231+
parentToolCallId: 'tc-resume',
232+
agentName: 'explore',
233+
});
234+
expect(handler.activityStore.get('agent-2')?.status).toBe('running');
235+
const startedEntries = host.appendTranscriptEntry.mock.calls.filter(
236+
([entry]) => entry.backgroundAgentStatus?.phase === 'started',
237+
);
238+
expect(startedEntries).toHaveLength(2);
239+
240+
handler.routeChildAgentEvent(backgroundChildDelta('agent-2'));
241+
expect(handler.activityStore.get('agent-2')?.steps.length).toBeGreaterThan(0);
242+
});
243+
244+
it('transitions the resumed background agent to its terminal state', () => {
245+
const { handler, host } = makeSwarmHandler();
246+
handler.handleLifecycleEvent(
247+
backgroundLifecycleEvent('subagent.spawned', 'agent-2', 'tc-resume'),
248+
);
249+
handler.routeChildAgentEvent(backgroundChildDelta('agent-2'));
250+
251+
handler.handleLifecycleEvent(
252+
backgroundLifecycleEvent('subagent.completed', 'agent-2', 'tc-resume'),
253+
);
254+
255+
expect(handler.backgroundAgentMetadata.has('agent-2')).toBe(false);
256+
expect(handler.activityStore.get('agent-2')?.status).toBe('completed');
257+
expect(host.appendTranscriptEntry).toHaveBeenCalledWith(
258+
expect.objectContaining({
259+
backgroundAgentStatus: expect.objectContaining({ phase: 'completed' }),
260+
}),
261+
);
262+
});
263+
});

‎packages/agent-core-v2/src/agent/loop/loopService.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1093,7 +1093,8 @@ export class AgentLoopService extends Disposable implements IAgentLoopService {
10931093
message: { role: 'user', content: [...seededMessage.content] },
10941094
meta: { promptId: waiter.id, origin: seededMessage.origin, tracked: false },
10951095
};
1096-
this.beginActiveTurn(waiter, entry, pending.id);
1096+
const seededTurn = this.beginActiveTurn(waiter, entry, pending.id);
1097+
this.settlePromptLaunched(waiter, seededTurn);
10971098
return true;
10981099
}
10991100

‎packages/agent-core-v2/src/features/tower/injection/tower-mode-full-reminder.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,17 @@ Working principles:
1717
## Tower workflow
1818

1919
1. **Init** — `TowerInit` creates `.tower/` and records the base branch (already set up when the human enabled tower mode with `/tower <base>`). 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.
20-
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. 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.
20+
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. 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.
2121
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.
2222
4. **Supervise** — on every wake (worker completion, human message): `TowerInbox` and `TowerStatus`, then act:
2323
- 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.
2424
- 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).
2525
- Blocker → answer or reassign if you can; if it genuinely needs the human, inform them and keep the rest moving.
2626
- Finding → triage: assign to a mission, plan a new one, or backlog — the disposition is your call; tell the human.
2727
- Completion report with a suspicious diff (🟢 claimed, zero changed files) → investigate before accepting.
28+
- Dead roster agent → an unintended death (failed, lost, timed out) is yours to recover: resume with `Agent(resume=..., run_in_background=true)` or reassign the mission. 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.
2829
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.
29-
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 dirty worktrees are protected — only disk is freed. 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.
30+
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 or large gitignored assets (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. Only disk is freed. 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.
3031

3132
## Hard rules for the tower
3233

‎packages/agent-core-v2/src/features/tower/protocol/git.ts‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { execFile } from 'node:child_process';
2-
import { realpath } from 'node:fs/promises';
2+
import { lstat, readdir, realpath } from 'node:fs/promises';
33
import { isAbsolute, join, relative, resolve } from 'node:path';
44

55
const GIT_TIMEOUT_MS = 60_000;
@@ -158,6 +158,38 @@ export async function isWorktreeDirty(path: string): Promise<boolean> {
158158
return status !== null && status.trim().length > 0;
159159
}
160160

161+
export async function ignoredContentBytes(path: string): Promise<number> {
162+
const status = await tryGit(path, ['status', '--porcelain', '-z', '--ignored']);
163+
if (status === null) return 0;
164+
const paths = status
165+
.split('\0')
166+
.filter((entry) => entry.startsWith('!! '))
167+
.map((entry) => entry.slice(3).replace(/\/+$/, ''));
168+
let total = 0;
169+
for (const rel of paths) {
170+
total += await entrySizeBytes(join(path, rel));
171+
}
172+
return total;
173+
}
174+
175+
async function entrySizeBytes(abs: string): Promise<number> {
176+
let stats;
177+
try {
178+
stats = await lstat(abs);
179+
} catch {
180+
return 0;
181+
}
182+
if (!stats.isDirectory()) return stats.size;
183+
let children: string[];
184+
try {
185+
children = await readdir(abs);
186+
} catch {
187+
return 0;
188+
}
189+
const sizes = await Promise.all(children.map((child) => entrySizeBytes(join(abs, child))));
190+
return sizes.reduce((sum, size) => sum + size, 0);
191+
}
192+
161193
export async function mergeNoFf(cwd: string, branch: string): Promise<string> {
162194
await git(cwd, ['merge', '--no-ff', branch]);
163195
return branchTip(cwd, 'HEAD');

0 commit comments

Comments
 (0)