Skip to content

Commit a9e28f8

Browse files
brookscclaude
authored andcommitted
refactor(coordinator): address PR johannesjo#145 review feedback
- Move verification/summary write after validation in landSelf so rejected calls don't persist bogus data to the renderer - Comment the landing_escalated escape hatch in assertTaskCanBeMerged - Replace inline isLandedTaskState closures in TaskPanel, TaskTitleBar, TerminalView with the shared predicate from store/landing - Extract GIT_LOCK_RETRY_DELAY_MS constant for the index.lock retry sleep - Add comment explaining PROMPT_ECHO_HANDOFF_SUPPRESS_MS heuristic - Early-return in applyTaskMcpLaunchResult when task is missing (dead path) - Assert only preamble paths are staged before cleanup commit - Extract detachMcpState variable in syncLandingState for clarity - Remove wait_for_signal_done from coordinator preamble (noise; signalDoneAt is already visible in list_tasks/get_task_status) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 845c863 commit a9e28f8

7 files changed

Lines changed: 36 additions & 34 deletions

File tree

electron/mcp/coordinator.ts

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import { IPC } from '../ipc/channels.js';
6666

6767
const DEFAULT_WAIT_TIMEOUT_MS = 300_000; // 5 minutes
6868
const PROMPT_WRITE_DELAY_MS = 50;
69+
const GIT_LOCK_RETRY_DELAY_MS = 2_000;
6970
const REST_COORDINATOR_SENTINEL = 'api';
7071
const PREAMBLE_ARTIFACT_PATHS = new Set([
7172
'AGENTS.md',
@@ -1000,6 +1001,11 @@ export class Coordinator {
10001001
}
10011002

10021003
private syncLandingState(task: CoordinatedTask): void {
1004+
// Detach renderer MCP state only after a successful merge (landed states);
1005+
// escalation/failure states leave it attached so the task stays reachable.
1006+
const detachMcpState =
1007+
task.landingState === 'landed_pending_review' ||
1008+
task.landingState === 'landed_cleanup_failed';
10031009
this.notifyRenderer(IPC.MCP_TaskStateSync, {
10041010
taskId: task.id,
10051011
verification: task.verification,
@@ -1012,21 +1018,9 @@ export class Coordinator {
10121018
task.landingState === 'landed_cleanup_failed' ||
10131019
task.landingState === 'landing_escalated' ||
10141020
task.landingState === 'landing_failed',
1015-
controlledBy:
1016-
task.landingState === 'landed_pending_review' ||
1017-
task.landingState === 'landed_cleanup_failed'
1018-
? null
1019-
: undefined,
1020-
mcpConfigPath:
1021-
task.landingState === 'landed_pending_review' ||
1022-
task.landingState === 'landed_cleanup_failed'
1023-
? null
1024-
: undefined,
1025-
mcpStartupStatus:
1026-
task.landingState === 'landed_pending_review' ||
1027-
task.landingState === 'landed_cleanup_failed'
1028-
? null
1029-
: undefined,
1021+
controlledBy: detachMcpState ? null : undefined,
1022+
mcpConfigPath: detachMcpState ? null : undefined,
1023+
mcpStartupStatus: detachMcpState ? null : undefined,
10301024
});
10311025
}
10321026

@@ -1082,6 +1076,15 @@ export class Coordinator {
10821076
}
10831077

10841078
if (dirtyPaths.length > 0) {
1079+
// nonPreamblePaths check above guarantees every path here is a preamble artifact.
1080+
const unexpectedPaths = dirtyPaths.filter(
1081+
(p) => !preambleFiles.has(p) || !PREAMBLE_ARTIFACT_PATHS.has(p),
1082+
);
1083+
if (unexpectedPaths.length > 0) {
1084+
throw new Error(
1085+
`Unexpected non-preamble paths staged before cleanup commit: ${unexpectedPaths.join(', ')}`,
1086+
);
1087+
}
10851088
await execAsync('git', ['add', '-A', '--', ...dirtyPaths], { cwd: task.worktreePath });
10861089
try {
10871090
await execAsync('git', ['commit', '-m', 'Remove Parallel Code sub-task preamble'], {
@@ -1122,7 +1125,7 @@ export class Coordinator {
11221125
} catch (err) {
11231126
const msg = err instanceof Error ? err.message : String(err);
11241127
if (msg.includes('Another git process') || msg.includes('index.lock')) {
1125-
await new Promise((r) => setTimeout(r, 2000));
1128+
await new Promise((r) => setTimeout(r, GIT_LOCK_RETRY_DELAY_MS));
11261129
result = await runMerge();
11271130
} else {
11281131
throw err;
@@ -1210,9 +1213,6 @@ export class Coordinator {
12101213
const task = this.tasks.get(taskId);
12111214
if (!task) throw new Error(`Task not found: ${taskId}`);
12121215

1213-
task.verification = input.verification;
1214-
task.landingSummary = input.summary;
1215-
12161216
if (!this.coordinators.has(task.coordinatorTaskId)) {
12171217
const reason = `Cannot self-land orphaned task: coordinator ${task.coordinatorTaskId} is not registered`;
12181218
this.escalateLanding(task, 'landing_escalated', reason);
@@ -1225,6 +1225,11 @@ export class Coordinator {
12251225
throw new Error(reason);
12261226
}
12271227

1228+
// Only persist verification/summary after all validation passes so rejected
1229+
// calls don't leave bogus data visible in the renderer.
1230+
task.verification = input.verification;
1231+
task.landingSummary = input.summary;
1232+
12281233
try {
12291234
await this.prepareCleanSelfLandingWorktree(task);
12301235
} catch (err) {
@@ -1366,6 +1371,10 @@ export class Coordinator {
13661371
}
13671372

13681373
const hasManualReviewSignal = task.signalDoneAt !== undefined;
1374+
// landing_escalated unlocks merge_task as an escape hatch for the coordinator.
1375+
// This is not an authority leak: sub-task tokens can only reach land_self (via
1376+
// the /api/tasks/:id/land endpoint); merge_task is a coordinator-only MCP tool,
1377+
// so only a coordinator agent can invoke it here.
13691378
const hasLandingEscalation =
13701379
task.landingState === 'landing_escalated' || task.landingState === 'landing_failed';
13711380
if (task.status === 'running' && !hasManualReviewSignal && !hasLandingEscalation) {

src/components/PromptInput.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ const AUTOSEND_MAX_WAIT_MS = 45_000;
8181
const PROMPT_VERIFY_TIMEOUT_MS = 5_000;
8282
const PROMPT_VERIFY_POLL_MS = 250;
8383
const PROMPT_MARKER_SCAN_CHARS = 500;
84+
// 5s covers the typical round-trip from send → pty echo; 40 chars is enough to
85+
// uniquely identify the prompt without risking a false match on short snippets.
8486
const PROMPT_ECHO_HANDOFF_SUPPRESS_MS = 5_000;
8587

8688
/** True when auto-send should be blocked by a question in the output.

src/components/TaskPanel.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { theme } from '../lib/theme';
4242
import { isMac } from '../lib/platform';
4343
import type { Task } from '../store/types';
4444
import type { CommitInfo } from '../ipc/types';
45+
import { isLandedTaskState } from '../store/landing';
4546

4647
interface TaskPanelProps {
4748
task: Task;
@@ -78,10 +79,7 @@ export function TaskPanel(props: TaskPanelProps) {
7879
const [showPushConfirm, setShowPushConfirm] = createSignal(false);
7980
const [pushSuccess, setPushSuccess] = createSignal(false);
8081
const [pushing, setPushing] = createSignal(false);
81-
const isLandedTask = () =>
82-
props.task.landingState === 'landed_pending_review' ||
83-
props.task.landingState === 'landed_cleanup_failed' ||
84-
props.task.landingState === 'reviewed';
82+
const isLandedTask = () => isLandedTaskState(props.task.landingState);
8583
let pushSuccessTimer: ReturnType<typeof setTimeout> | undefined;
8684
onCleanup(() => clearTimeout(pushSuccessTimer));
8785
const [diffScrollTarget, setDiffScrollTarget] = createSignal<string | null>(null);

src/components/TaskTitleBar.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { handleDragReorder } from '../lib/dragReorder';
1919
import { getTaskDockerBadgeLabel } from '../lib/docker';
2020
import { displayTaskNameFromPrompt, shouldUsePromptDerivedTaskName } from '../lib/clean-task-name';
2121
import type { Task } from '../store/types';
22+
import { isLandedTaskState } from '../store/landing';
2223

2324
interface TaskTitleBarProps {
2425
task: Task;
@@ -33,10 +34,7 @@ interface TaskTitleBarProps {
3334

3435
export function TaskTitleBar(props: TaskTitleBarProps) {
3536
const dockerBadgeLabel = () => getTaskDockerBadgeLabel(props.task.dockerSource);
36-
const isLandedTask = () =>
37-
props.task.landingState === 'landed_pending_review' ||
38-
props.task.landingState === 'landed_cleanup_failed' ||
39-
props.task.landingState === 'reviewed';
37+
const isLandedTask = () => isLandedTaskState(props.task.landingState);
4038
const landingBadge = () => {
4139
switch (props.task.landingState) {
4240
case 'landed_pending_review':

src/components/TerminalView.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -657,12 +657,7 @@ export function TerminalView(props: TerminalViewProps) {
657657
function startSpawn() {
658658
if (!term || spawnStarted) return;
659659
const landingState = store.tasks[taskId]?.landingState;
660-
if (
661-
landingState === 'landed_pending_review' ||
662-
landingState === 'landed_cleanup_failed' ||
663-
landingState === 'reviewed'
664-
)
665-
return;
660+
if (isLandedTaskState(landingState)) return;
666661
spawnStarted = true;
667662
invoke(IPC.SpawnAgent, {
668663
taskId,

src/store/coordinator-preamble.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ You have MCP tools to coordinate work across isolated git worktree tasks:
1010
- list_tasks — List all coordinated tasks with status
1111
- get_task_status — Detailed status of a task
1212
- send_prompt — Send follow-up instructions to a task's agent
13-
- wait_for_signal_done — Legacy/manual-review wait for ANY sub-task to call signal_done. Normal self-landing tasks do not call it.
1413
- wait_for_idle — Wait until an agent is idle at its prompt (use for send_prompt follow-ups)
1514
- get_task_diff — Get changed files and diff for a task
1615
- get_task_output — Get recent terminal output from a task

src/store/tasks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1328,6 +1328,7 @@ export function applyTaskMcpLaunchResult(
13281328
taskId: string,
13291329
result: { mcpLaunchArgs?: string[] } | undefined,
13301330
): boolean {
1331+
if (!store.tasks[taskId]) return false;
13311332
const args = result?.mcpLaunchArgs;
13321333
if ((!Array.isArray(args) || args.length === 0) && taskRequiresMcpLaunchArgs(taskId)) {
13331334
markTaskMcpError(taskId, 'MCP startup returned no launch args');

0 commit comments

Comments
 (0)