Skip to content

Commit 845c863

Browse files
brookscjohannesjo
authored andcommitted
feat(coordinator): add subtask self landing
1 parent 202132b commit 845c863

45 files changed

Lines changed: 2952 additions & 229 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

electron/ipc/channels.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ export enum IPC {
157157
MCP_TaskCreated = 'mcp_task_created',
158158
MCP_TaskClosed = 'mcp_task_closed',
159159
MCP_TaskStateSync = 'mcp_task_state_sync',
160+
MCP_TaskLandingReviewCleared = 'mcp_task_landing_review_cleared',
160161
MCP_ControlChanged = 'mcp_control_changed',
161162
// Coordinator notifications (main → renderer)
162163
MCP_CoordinatorNotificationStaged = 'mcp_coordinator_notification_staged',

electron/ipc/register-mcp.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@ const VALID_ARGS = {
310310
coordinatorTaskId: TEST_COORDINATOR_ID,
311311
projectId: 'proj-1',
312312
projectRoot: '/absolute/project',
313+
coordinatorBranch: 'task/coordinator-work',
313314
worktreePath: '/absolute/worktree',
314315
agentArgs: ['--flag', 'value'],
315316
dockerContainerName: 'my-container',
@@ -356,6 +357,18 @@ describe('Layer 4 — StartMCPServer input validation', () => {
356357
expect(copyFileSpy).not.toHaveBeenCalled();
357358
});
358359

360+
it('rejects invalid coordinatorBranch', () => {
361+
const writeFileSpy = vi.spyOn(fs, 'writeFileSync');
362+
const copyFileSpy = vi.spyOn(fs, 'copyFileSync');
363+
364+
expect(() =>
365+
validateStartMCPServerArgs({ ...VALID_ARGS, coordinatorBranch: 'bad branch' }),
366+
).toThrow('coordinatorBranch');
367+
368+
expect(writeFileSpy).not.toHaveBeenCalled();
369+
expect(copyFileSpy).not.toHaveBeenCalled();
370+
});
371+
359372
it('rejects agentArgs containing a non-string element', () => {
360373
const writeFileSpy = vi.spyOn(fs, 'writeFileSync');
361374
const copyFileSpy = vi.spyOn(fs, 'copyFileSync');

electron/ipc/register.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,9 @@ export function validateStartMCPServerArgs(args: Record<string, unknown>): void
177177
assertString(args.projectId, 'projectId');
178178
validatePath(args.projectRoot, 'projectRoot');
179179
if (args.worktreePath !== undefined) validatePath(args.worktreePath, 'worktreePath');
180+
if (args.coordinatorBranch !== undefined) {
181+
validateBranchName(args.coordinatorBranch, 'coordinatorBranch');
182+
}
180183
if (args.agentCommand !== undefined) assertString(args.agentCommand, 'agentCommand');
181184
if (args.agentArgs !== undefined) assertStringArray(args.agentArgs, 'agentArgs');
182185
assertOptionalBoolean(args.skipPermissions, 'skipPermissions');
@@ -1181,10 +1184,22 @@ export function registerAllHandlers(win: BrowserWindow): void {
11811184

11821185
ipcMain.handle(
11831186
IPC.MCP_CoordinatorRegistered,
1184-
(_e, args: { coordinatorTaskId: string; projectId: string; worktreePath?: string }) => {
1187+
(
1188+
_e,
1189+
args: {
1190+
coordinatorTaskId: string;
1191+
projectId: string;
1192+
coordinatorBranch?: string;
1193+
worktreePath?: string;
1194+
},
1195+
) => {
11851196
assertString(args.coordinatorTaskId, 'coordinatorTaskId');
11861197
assertString(args.projectId, 'projectId');
1198+
if (args.coordinatorBranch !== undefined) {
1199+
validateBranchName(args.coordinatorBranch, 'coordinatorBranch');
1200+
}
11871201
coordinator?.registerCoordinator(args.coordinatorTaskId, args.projectId, {
1202+
branchName: args.coordinatorBranch,
11881203
worktreePath: args.worktreePath,
11891204
});
11901205
},
@@ -1255,6 +1270,11 @@ export function registerAllHandlers(win: BrowserWindow): void {
12551270
},
12561271
);
12571272

1273+
ipcMain.handle(IPC.MCP_TaskLandingReviewCleared, (_e, args: { taskId: string }) => {
1274+
assertString(args.taskId, 'taskId');
1275+
coordinator?.markTaskReviewed(args.taskId);
1276+
});
1277+
12581278
ipcMain.handle(
12591279
IPC.MCP_CoordinatedTaskClosed,
12601280
(_e, args: { taskId: string; coordinatorTaskId: string }) => {
@@ -1281,7 +1301,13 @@ export function registerAllHandlers(win: BrowserWindow): void {
12811301
agentId?: string;
12821302
signalDoneAt?: string;
12831303
signalDoneConsumed?: boolean;
1304+
verification?: import('../mcp/types.js').SubtaskVerification;
1305+
landingState?: import('../mcp/types.js').LandingState;
1306+
landingReason?: string;
1307+
landingSummary?: string;
1308+
landedMetadata?: import('../mcp/types.js').LandedMetadata;
12841309
mcpConfigPath?: string;
1310+
agentCommand?: string;
12851311
preambleFileExistedBefore?: boolean;
12861312
},
12871313
) => {
@@ -1297,7 +1323,8 @@ export function registerAllHandlers(win: BrowserWindow): void {
12971323
assertString(args.coordinatorTaskId, 'coordinatorTaskId');
12981324
validateUUID(args.coordinatorTaskId, 'coordinatorTaskId');
12991325
if (!coordinator) throw new Error('coordinator mode not initialized');
1300-
coordinator.hydrateTask({
1326+
if (args.agentCommand !== undefined) assertString(args.agentCommand, 'agentCommand');
1327+
const result = coordinator.hydrateTask({
13011328
id: args.id,
13021329
name: args.name,
13031330
projectId: args.projectId,
@@ -1310,13 +1337,20 @@ export function registerAllHandlers(win: BrowserWindow): void {
13101337
controlledBy: args.controlledBy,
13111338
signalDoneAt: args.signalDoneAt,
13121339
signalDoneConsumed: args.signalDoneConsumed,
1340+
verification: args.verification,
1341+
landingState: args.landingState,
1342+
landingReason: args.landingReason,
1343+
landingSummary: args.landingSummary,
1344+
landedMetadata: args.landedMetadata,
13131345
mcpConfigPath: args.mcpConfigPath,
1346+
agentCommand: args.agentCommand,
13141347
preambleFileExistedBefore: args.preambleFileExistedBefore,
13151348
});
13161349
// Signal to renderer that MCP hydration is complete — gates TerminalView auto-spawn.
13171350
if (!win.isDestroyed()) {
13181351
win.webContents.send(IPC.MCP_TaskHydrated, { taskId: args.id });
13191352
}
1353+
return result;
13201354
},
13211355
);
13221356
}
@@ -1362,6 +1396,7 @@ export function registerAllHandlers(win: BrowserWindow): void {
13621396
coordinatorTaskId: string;
13631397
projectId: string;
13641398
projectRoot: string;
1399+
coordinatorBranch?: string;
13651400
worktreePath?: string;
13661401
skipPermissions?: boolean;
13671402
propagateSkipPermissions?: boolean;
@@ -1406,6 +1441,7 @@ export function registerAllHandlers(win: BrowserWindow): void {
14061441
// so create_task / list_tasks know about it. Idempotent — safe to call on restore.
14071442
coordinator.setDefaultProject(args.projectId, args.projectRoot, args.coordinatorTaskId);
14081443
coordinator.registerCoordinator(args.coordinatorTaskId, args.projectId, {
1444+
branchName: args.coordinatorBranch,
14091445
worktreePath: args.worktreePath,
14101446
skipPermissions: Boolean(args.skipPermissions && args.propagateSkipPermissions),
14111447
});

electron/mcp/client.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import type {
88
ApiDiffResult,
99
ApiMergeResult,
1010
ApiReviewAndMergeResult,
11+
ApiLandSelfResult,
12+
LandSelfInput,
1113
WaitForSignalDoneResult,
1214
} from './types.js';
1315

@@ -106,19 +108,32 @@ export class MCPClient {
106108
}
107109

108110
async signalDone(taskId: string): Promise<void> {
109-
const url = `${this.baseUrl}/api/tasks/${encodeURIComponent(taskId)}/done`;
111+
await this.taskOwnerRequest('POST', `/api/tasks/${encodeURIComponent(taskId)}/done`, {});
112+
}
113+
114+
async landSelf(taskId: string, input: LandSelfInput): Promise<ApiLandSelfResult> {
115+
return this.taskOwnerRequest<ApiLandSelfResult>(
116+
'POST',
117+
`/api/tasks/${encodeURIComponent(taskId)}/land`,
118+
input,
119+
);
120+
}
121+
122+
private async taskOwnerRequest<T>(method: string, path: string, body: unknown): Promise<T> {
123+
const url = `${this.baseUrl}${path}`;
110124
const headers: Record<string, string> = {
111125
Authorization: `Bearer ${this.token}`,
112126
'Content-Type': 'application/json',
113127
};
114128
// Per-task done token is sent as X-Done-Token so the server can verify task ownership
115129
// without needing per-task bearer token classification.
116130
if (this.doneToken) headers['X-Done-Token'] = this.doneToken;
117-
const res = await fetch(url, { method: 'POST', headers, body: '{}' });
131+
const res = await fetch(url, { method, headers, body: JSON.stringify(body) });
118132
if (!res.ok) {
119133
const text = await res.text().catch(() => '');
120-
throw new Error(`API POST /api/tasks/.../done failed (${res.status}): ${text}`);
134+
throw new Error(`API ${method} ${path} failed (${res.status}): ${text}`);
121135
}
136+
return (await res.json()) as T;
122137
}
123138

124139
async waitForSignalDone(

0 commit comments

Comments
 (0)