oxe-cc 1.8.3 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,8 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { appendEvent } from '../events/bus';
4
4
  import type { ExecutionGraph, GraphNode } from '../compiler/graph-compiler';
5
- import type { WorkspaceManager } from '../workspace/workspace-manager';
5
+ import type { WorkspaceManager, WorkspaceRequest } from '../workspace/workspace-manager';
6
+ import type { WorkspaceLease, SnapshotRef } from '../models/workspace';
6
7
  import type { TaskExecutor, TaskResult, SchedulerContext, RunResult } from './scheduler';
7
8
  import { Scheduler } from './scheduler';
8
9
  import { buildHandoff } from './agent-roles';
@@ -27,6 +28,7 @@ export interface CoordinationOptions {
27
28
  runId: string;
28
29
  onEvent?: SchedulerContext['onEvent'];
29
30
  heartbeatTimeoutMs?: number;
31
+ applyWorkspaceMerges?: boolean;
30
32
  }
31
33
 
32
34
  export interface ArbitrationRecord {
@@ -40,10 +42,39 @@ export interface ArbitrationRecord {
40
42
  recorded_at: string;
41
43
  }
42
44
 
43
- export interface MultiAgentOwnership {
44
- work_item_id: string;
45
- owner_agent_id: string;
46
- }
45
+ export interface MultiAgentOwnership {
46
+ work_item_id: string;
47
+ owner_agent_id: string;
48
+ }
49
+
50
+ export interface WorkspaceMergeRecord {
51
+ work_item_id: string;
52
+ agent_id: string;
53
+ workspace_id: string;
54
+ strategy: string;
55
+ isolation_level: 'shared' | 'isolated';
56
+ branch: string | null;
57
+ base_commit: string | null;
58
+ root_path: string | null;
59
+ mutation_scope: string[];
60
+ diff_paths: string[];
61
+ evidence_count: number;
62
+ verify_status: 'pass' | 'fail' | 'partial';
63
+ status: 'ready' | 'merged' | 'blocked' | 'skipped';
64
+ blocker: string | null;
65
+ recorded_at: string;
66
+ }
67
+
68
+ export interface WorkspaceMergeReport {
69
+ schema_version: 1;
70
+ run_id: string;
71
+ generated_at: string;
72
+ workspace_isolation: 'git_worktree';
73
+ merge_readiness: 'ready' | 'blocked' | 'partial';
74
+ arbitration_required: boolean;
75
+ blockers: string[];
76
+ records: WorkspaceMergeRecord[];
77
+ }
47
78
 
48
79
  export interface MultiAgentStatusSnapshot {
49
80
  run_id: string;
@@ -64,6 +95,10 @@ export interface MultiAgentStatusSnapshot {
64
95
  timed_out: boolean;
65
96
  reassigned_task_ids: string[];
66
97
  }>;
98
+ worktrees: WorkspaceMergeRecord[];
99
+ workspace_merge: WorkspaceMergeReport;
100
+ merge_blockers: string[];
101
+ arbitration_required: boolean;
67
102
  orphan_reassignments: Array<{ from_agent_id: string; to_agent_id: string; work_item_ids: string[] }>;
68
103
  timed_out_agents: Array<{ agent_id: string; work_item_ids: string[]; detected_at: string }>;
69
104
  updated_at: string;
@@ -83,6 +118,10 @@ export interface MultiAgentOperationalSummary {
83
118
  orphan_reassignment_count: number;
84
119
  timeout_count: number;
85
120
  participating_agents: string[];
121
+ workspace_isolation: 'git_worktree';
122
+ merge_readiness: WorkspaceMergeReport['merge_readiness'];
123
+ arbitration_required: boolean;
124
+ merge_blocker_count: number;
86
125
  health: 'healthy' | 'degraded';
87
126
  updated_at: string;
88
127
  }
@@ -96,6 +135,7 @@ export interface CoordinationResult {
96
135
  agent_results: Array<{ agent_id: string; completed: string[]; failed: string[] }>;
97
136
  handoffs?: CooperativeHandoff[];
98
137
  arbitration_results?: ArbitrationRecord[];
138
+ workspace_merge_report?: WorkspaceMergeReport;
99
139
  state?: MultiAgentStatusSnapshot;
100
140
  summary?: MultiAgentOperationalSummary;
101
141
  }
@@ -111,12 +151,14 @@ function persistMultiAgentArtifacts(
111
151
  runId: string,
112
152
  state: MultiAgentStatusSnapshot,
113
153
  handoffs: CooperativeHandoff[] = [],
114
- arbitrationResults: ArbitrationRecord[] = []
154
+ arbitrationResults: ArbitrationRecord[] = [],
155
+ workspaceMergeReport: WorkspaceMergeReport = emptyWorkspaceMergeReport(runId)
115
156
  ): void {
116
157
  const runDir = ensureRunDir(projectRoot, runId);
117
158
  fs.writeFileSync(path.join(runDir, 'multi-agent-state.json'), JSON.stringify(state, null, 2), 'utf8');
118
159
  fs.writeFileSync(path.join(runDir, 'handoffs.json'), JSON.stringify(handoffs, null, 2), 'utf8');
119
160
  fs.writeFileSync(path.join(runDir, 'arbitration-results.json'), JSON.stringify(arbitrationResults, null, 2), 'utf8');
161
+ fs.writeFileSync(path.join(runDir, 'workspace-merge-report.json'), JSON.stringify(workspaceMergeReport, null, 2), 'utf8');
120
162
  const summary: MultiAgentOperationalSummary = {
121
163
  run_id: state.run_id,
122
164
  mode: state.mode,
@@ -131,13 +173,17 @@ function persistMultiAgentArtifacts(
131
173
  orphan_reassignment_count: state.orphan_reassignments.length,
132
174
  timeout_count: state.timed_out_agents.length,
133
175
  participating_agents: state.agent_results.map((entry) => entry.agent_id),
176
+ workspace_isolation: 'git_worktree',
177
+ merge_readiness: workspaceMergeReport.merge_readiness,
178
+ arbitration_required: workspaceMergeReport.arbitration_required,
179
+ merge_blocker_count: workspaceMergeReport.blockers.length,
134
180
  health: state.timed_out_agents.length > 0 || state.failed.length > 0 ? 'degraded' : 'healthy',
135
181
  updated_at: state.updated_at,
136
182
  };
137
183
  fs.writeFileSync(path.join(runDir, 'multi-agent-summary.json'), JSON.stringify(summary, null, 2), 'utf8');
138
184
  }
139
185
 
140
- function ensureIsolatedAgents(agents: AgentSpec[]): void {
186
+ function ensureIsolatedAgents(agents: AgentSpec[]): void {
141
187
  const shared = agents.filter((agent) => agent.workspaceManager.isolation_level !== 'isolated');
142
188
  if (shared.length === 0) return;
143
189
  const ids = shared.map((agent) => `${agent.id}:${agent.workspaceManager.isolation_level}`).join(', ');
@@ -167,7 +213,8 @@ function makeState(
167
213
  failed: string[],
168
214
  blocked: string[],
169
215
  orphanReassignments: Array<{ from_agent_id: string; to_agent_id: string; work_item_ids: string[] }>,
170
- timedOutAgents: Array<{ agent_id: string; work_item_ids: string[]; detected_at: string }>
216
+ timedOutAgents: Array<{ agent_id: string; work_item_ids: string[]; detected_at: string }>,
217
+ workspaceMergeReport: WorkspaceMergeReport = emptyWorkspaceMergeReport(runId)
171
218
  ): MultiAgentStatusSnapshot {
172
219
  return {
173
220
  run_id: runId,
@@ -179,7 +226,7 @@ function makeState(
179
226
  completed,
180
227
  failed,
181
228
  blocked,
182
- agent_results: agents.map((agent, idx) => {
229
+ agent_results: agents.map((agent, idx) => {
183
230
  const result = agentResults.find((entry) => entry.agent_id === agent.id);
184
231
  return {
185
232
  agent_id: agent.id,
@@ -191,12 +238,150 @@ function makeState(
191
238
  reassigned_task_ids: result?.reassigned_task_ids ?? [],
192
239
  };
193
240
  }),
241
+ worktrees: workspaceMergeReport.records,
242
+ workspace_merge: workspaceMergeReport,
243
+ merge_blockers: workspaceMergeReport.blockers,
244
+ arbitration_required: workspaceMergeReport.arbitration_required,
194
245
  orphan_reassignments: orphanReassignments,
195
246
  timed_out_agents: timedOutAgents,
196
247
  updated_at: new Date().toISOString(),
197
248
  };
198
249
  }
199
250
 
251
+ function emptyWorkspaceMergeReport(runId: string): WorkspaceMergeReport {
252
+ return {
253
+ schema_version: 1,
254
+ run_id: runId,
255
+ generated_at: new Date().toISOString(),
256
+ workspace_isolation: 'git_worktree',
257
+ merge_readiness: 'ready',
258
+ arbitration_required: false,
259
+ blockers: [],
260
+ records: [],
261
+ };
262
+ }
263
+
264
+ function ensureGitWorktreeLease(agent: AgentSpec, lease: WorkspaceLease): void {
265
+ if (lease.isolation_level !== 'isolated' || lease.strategy !== 'git_worktree') {
266
+ throw new Error(`Multi-agent real requires git_worktree isolated workspace. Agent ${agent.id} received ${lease.strategy}/${lease.isolation_level}.`);
267
+ }
268
+ }
269
+
270
+ function mutationScopeOf(node?: GraphNode): string[] {
271
+ return Array.isArray(node?.mutation_scope) ? node!.mutation_scope.map(String).filter(Boolean) : [];
272
+ }
273
+
274
+ function createMergeRecord(
275
+ agent: AgentSpec,
276
+ node: GraphNode,
277
+ lease: WorkspaceLease,
278
+ result?: TaskResult
279
+ ): WorkspaceMergeRecord {
280
+ const evidenceCount = Array.isArray(result?.evidence) ? result!.evidence.length : 0;
281
+ return {
282
+ work_item_id: node.id,
283
+ agent_id: agent.id,
284
+ workspace_id: lease.workspace_id,
285
+ strategy: lease.strategy,
286
+ isolation_level: lease.isolation_level,
287
+ branch: lease.branch ?? null,
288
+ base_commit: lease.base_commit ?? null,
289
+ root_path: lease.root_path ?? null,
290
+ mutation_scope: mutationScopeOf(node),
291
+ diff_paths: mutationScopeOf(node),
292
+ evidence_count: evidenceCount,
293
+ verify_status: result ? (result.success ? 'pass' : 'fail') : 'partial',
294
+ status: result ? (result.success ? 'ready' : 'blocked') : 'ready',
295
+ blocker: result && !result.success ? `verify_failed:${result.failure_class}` : null,
296
+ recorded_at: new Date().toISOString(),
297
+ };
298
+ }
299
+
300
+ function buildWorkspaceMergeReport(
301
+ runId: string,
302
+ records: WorkspaceMergeRecord[],
303
+ extraBlockers: string[] = [],
304
+ arbitrationRequired = false
305
+ ): WorkspaceMergeReport {
306
+ const blockers = [
307
+ ...extraBlockers,
308
+ ...records.filter((record) => record.status === 'blocked' && record.blocker).map((record) => `${record.work_item_id}:${record.blocker}`),
309
+ ];
310
+ return {
311
+ schema_version: 1,
312
+ run_id: runId,
313
+ generated_at: new Date().toISOString(),
314
+ workspace_isolation: 'git_worktree',
315
+ merge_readiness: blockers.length > 0 ? 'blocked' : records.some((record) => record.status === 'ready') ? 'partial' : 'ready',
316
+ arbitration_required: arbitrationRequired,
317
+ blockers,
318
+ records,
319
+ };
320
+ }
321
+
322
+ function detectMutationConflicts(graph: ExecutionGraph, agents: AgentSpec[], partitions: string[][]): string[] {
323
+ const owners = new Map<string, string>();
324
+ const conflicts: string[] = [];
325
+ for (let idx = 0; idx < agents.length; idx += 1) {
326
+ for (const nodeId of partitions[idx] ?? []) {
327
+ const node = graph.nodes.get(nodeId);
328
+ for (const scope of mutationScopeOf(node)) {
329
+ const previous = owners.get(scope);
330
+ if (previous && previous !== agents[idx].id) {
331
+ conflicts.push(`mutation_scope_overlap:${scope}:${previous}:${agents[idx].id}`);
332
+ } else {
333
+ owners.set(scope, agents[idx].id);
334
+ }
335
+ }
336
+ }
337
+ }
338
+ return conflicts;
339
+ }
340
+
341
+ function applyWorkspaceRecord(projectRoot: string, record: WorkspaceMergeRecord): WorkspaceMergeRecord {
342
+ if (!record.root_path || record.status !== 'ready') return record;
343
+ for (const relativePath of record.mutation_scope) {
344
+ const source = path.join(record.root_path, relativePath);
345
+ const target = path.join(projectRoot, relativePath);
346
+ if (!fs.existsSync(source) || fs.statSync(source).isDirectory()) {
347
+ return { ...record, status: 'blocked', blocker: `missing_output:${relativePath}` };
348
+ }
349
+ fs.mkdirSync(path.dirname(target), { recursive: true });
350
+ fs.copyFileSync(source, target);
351
+ }
352
+ return { ...record, status: 'merged', blocker: null };
353
+ }
354
+
355
+ function createTrackingWorkspaceManager(
356
+ base: WorkspaceManager,
357
+ agent: AgentSpec,
358
+ graph: ExecutionGraph,
359
+ records: WorkspaceMergeRecord[]
360
+ ): WorkspaceManager & { disposeDeferred(): Promise<void> } {
361
+ const leases = new Map<string, WorkspaceLease>();
362
+ return {
363
+ isolation_level: base.isolation_level,
364
+ allocate: async (req: WorkspaceRequest) => {
365
+ const lease = await base.allocate(req);
366
+ ensureGitWorktreeLease(agent, lease);
367
+ leases.set(lease.workspace_id, lease);
368
+ const node = graph.nodes.get(req.work_item_id);
369
+ if (node) records.push(createMergeRecord(agent, node, lease));
370
+ return lease;
371
+ },
372
+ snapshot: (id: string): Promise<SnapshotRef> => base.snapshot(id),
373
+ reset: (id: string, snapRef: SnapshotRef): Promise<void> => base.reset(id, snapRef),
374
+ dispose: async () => {
375
+ // Scheduler calls dispose before the coordinator can reconcile diffs.
376
+ // Defer real cleanup until the merge report has been produced.
377
+ },
378
+ disposeDeferred: async () => {
379
+ await Promise.all([...leases.keys()].map((id) => base.dispose(id).catch(() => {})));
380
+ leases.clear();
381
+ },
382
+ };
383
+ }
384
+
200
385
  async function runGraphForAgent(
201
386
  graph: ExecutionGraph,
202
387
  nodeIds: string[],
@@ -211,8 +396,11 @@ async function runGraphForAgent(
211
396
  timed_out: boolean;
212
397
  assigned_task_ids: string[];
213
398
  reassigned_task_ids: string[];
399
+ workspace_records: WorkspaceMergeRecord[];
400
+ cleanup: () => Promise<void>;
214
401
  }> {
215
402
  const subGraph = subGraphFor(graph, nodeIds);
403
+ const workspaceRecords: WorkspaceMergeRecord[] = [];
216
404
  if (subGraph.nodes.size === 0) {
217
405
  return {
218
406
  agent_id: agent.id,
@@ -221,14 +409,17 @@ async function runGraphForAgent(
221
409
  timed_out: false,
222
410
  assigned_task_ids: nodeIds,
223
411
  reassigned_task_ids: [],
412
+ workspace_records: workspaceRecords,
413
+ cleanup: async () => {},
224
414
  };
225
415
  }
416
+ const trackingWorkspaceManager = createTrackingWorkspaceManager(agent.workspaceManager, agent, graph, workspaceRecords);
226
417
  const ctx: SchedulerContext = {
227
418
  projectRoot: opts.projectRoot,
228
419
  sessionId: opts.sessionId,
229
420
  runId: `${opts.runId}-agent${idx}`,
230
421
  executor: agent.executor,
231
- workspaceManager: agent.workspaceManager,
422
+ workspaceManager: trackingWorkspaceManager,
232
423
  onEvent: opts.onEvent,
233
424
  };
234
425
  const scheduler = new Scheduler();
@@ -242,6 +433,8 @@ async function runGraphForAgent(
242
433
  timed_out: false,
243
434
  assigned_task_ids: nodeIds,
244
435
  reassigned_task_ids: [],
436
+ workspace_records: workspaceRecords,
437
+ cleanup: () => trackingWorkspaceManager.disposeDeferred(),
245
438
  };
246
439
  }
247
440
  let timer: NodeJS.Timeout | null = null;
@@ -260,6 +453,8 @@ async function runGraphForAgent(
260
453
  timed_out: true,
261
454
  assigned_task_ids: nodeIds,
262
455
  reassigned_task_ids: [],
456
+ workspace_records: workspaceRecords,
457
+ cleanup: () => trackingWorkspaceManager.disposeDeferred(),
263
458
  };
264
459
  }
265
460
  const result = raced.result;
@@ -270,6 +465,8 @@ async function runGraphForAgent(
270
465
  timed_out: false,
271
466
  assigned_task_ids: nodeIds,
272
467
  reassigned_task_ids: [],
468
+ workspace_records: workspaceRecords,
469
+ cleanup: () => trackingWorkspaceManager.disposeDeferred(),
273
470
  };
274
471
  }
275
472
 
@@ -290,6 +487,30 @@ async function runParallel(
290
487
  partitions[index % agents.length].push(id);
291
488
  });
292
489
  }
490
+ const mutationConflicts = detectMutationConflicts(graph, agents, partitions);
491
+ if (mutationConflicts.length > 0) {
492
+ const blocked = mutationConflicts;
493
+ const workspaceMergeReport = buildWorkspaceMergeReport(runId, [], blocked);
494
+ const state = makeState('parallel', runId, agents, partitions, [], [], [], blocked, [], [], workspaceMergeReport);
495
+ persistMultiAgentArtifacts(projectRoot, runId, state, [], [], workspaceMergeReport);
496
+ appendEvent(projectRoot, sessionId, {
497
+ type: 'WorkItemBlocked',
498
+ run_id: runId,
499
+ payload: { mode: 'parallel', blockers: blocked },
500
+ });
501
+ return {
502
+ mode: 'parallel',
503
+ run_id: runId,
504
+ completed: [],
505
+ failed: [],
506
+ blocked,
507
+ agent_results: [],
508
+ arbitration_results: [],
509
+ workspace_merge_report: workspaceMergeReport,
510
+ state,
511
+ summary: loadMultiAgentSummary(projectRoot, runId) || undefined,
512
+ };
513
+ }
293
514
  const registry = new AgentRegistry(heartbeatTimeoutMs == null ? 30_000 : heartbeatTimeoutMs);
294
515
  agents.forEach((agent, idx) => {
295
516
  registry.register(agent.id, agent.executor, agent.workspaceManager, partitions[idx] ?? []);
@@ -331,7 +552,13 @@ async function runParallel(
331
552
  const rerun = await runGraphForAgent(graph, timedOut.assigned_task_ids, agents[fallbackIdx], fallbackIdx, opts, null);
332
553
  fallback.completed.push(...rerun.completed);
333
554
  fallback.failed.push(...rerun.failed);
555
+ fallback.workspace_records.push(...rerun.workspace_records);
334
556
  fallback.reassigned_task_ids.push(...timedOut.assigned_task_ids);
557
+ const previousCleanup = fallback.cleanup;
558
+ fallback.cleanup = async () => {
559
+ await previousCleanup?.();
560
+ await rerun.cleanup?.();
561
+ };
335
562
  partitions[fallbackIdx] = [...partitions[fallbackIdx], ...timedOut.assigned_task_ids];
336
563
  partitions[timeoutIdx] = [];
337
564
  orphanReassignments.push({
@@ -343,8 +570,14 @@ async function runParallel(
343
570
 
344
571
  const completed = Array.from(new Set(agentResults.flatMap((result) => result.completed)));
345
572
  const failed = Array.from(new Set(agentResults.flatMap((result) => result.failed)));
346
- const state = makeState('parallel', runId, agents, partitions, agentResults, completed, failed, blocked, orphanReassignments, timedOutAgents);
347
- persistMultiAgentArtifacts(projectRoot, runId, state);
573
+ const rawRecords = agentResults.flatMap((result) => result.workspace_records || []);
574
+ const mergedRecords = opts.applyWorkspaceMerges
575
+ ? rawRecords.map((record) => applyWorkspaceRecord(projectRoot, record))
576
+ : rawRecords;
577
+ await Promise.all(agentResults.map((result) => result.cleanup?.().catch(() => {})));
578
+ const workspaceMergeReport = buildWorkspaceMergeReport(runId, mergedRecords, blocked);
579
+ const state = makeState('parallel', runId, agents, partitions, agentResults, completed, failed, blocked, orphanReassignments, timedOutAgents, workspaceMergeReport);
580
+ persistMultiAgentArtifacts(projectRoot, runId, state, [], [], workspaceMergeReport);
348
581
 
349
582
  appendEvent(projectRoot, sessionId, {
350
583
  type: 'RunCompleted',
@@ -357,9 +590,10 @@ async function runParallel(
357
590
  run_id: runId,
358
591
  completed,
359
592
  failed,
360
- blocked,
593
+ blocked,
361
594
  agent_results: agentResults,
362
595
  arbitration_results: [],
596
+ workspace_merge_report: workspaceMergeReport,
363
597
  state,
364
598
  summary: loadMultiAgentSummary(projectRoot, runId) || undefined,
365
599
  };
@@ -387,12 +621,13 @@ async function runCompetitive(
387
621
  const completed: string[] = [];
388
622
  const failed: string[] = [];
389
623
  const blocked: string[] = [];
390
- const arbitrationResults: ArbitrationRecord[] = [];
624
+ const arbitrationResults: ArbitrationRecord[] = [];
625
+ const workspaceRecords: WorkspaceMergeRecord[] = [];
391
626
 
392
627
  for (const wave of graph.waves) {
393
628
  for (const nodeId of wave.node_ids) {
394
629
  const node = graph.nodes.get(nodeId)!;
395
- const result = await competeTwoAgents(nodeId, node, agentA, agentB, opts, arbitrationResults);
630
+ const result = await competeTwoAgents(nodeId, node, agentA, agentB, opts, arbitrationResults, workspaceRecords);
396
631
  if (result.success) completed.push(nodeId);
397
632
  else failed.push(nodeId);
398
633
  if (failed.length > 0) break;
@@ -401,6 +636,7 @@ async function runCompetitive(
401
636
  }
402
637
 
403
638
  const partitions = [Array.from(graph.nodes.keys()), Array.from(graph.nodes.keys())];
639
+ const workspaceMergeReport = buildWorkspaceMergeReport(runId, workspaceRecords, blocked, true);
404
640
  const state = makeState(
405
641
  'competitive',
406
642
  runId,
@@ -414,9 +650,10 @@ async function runCompetitive(
414
650
  failed,
415
651
  blocked,
416
652
  [],
417
- []
653
+ [],
654
+ workspaceMergeReport
418
655
  );
419
- persistMultiAgentArtifacts(projectRoot, runId, state, [], arbitrationResults);
656
+ persistMultiAgentArtifacts(projectRoot, runId, state, [], arbitrationResults, workspaceMergeReport);
420
657
 
421
658
  appendEvent(projectRoot, sessionId, {
422
659
  type: 'RunCompleted',
@@ -435,6 +672,7 @@ async function runCompetitive(
435
672
  { agent_id: agentB.id, completed: [], failed: [] },
436
673
  ],
437
674
  arbitration_results: arbitrationResults,
675
+ workspace_merge_report: workspaceMergeReport,
438
676
  state,
439
677
  summary: loadMultiAgentSummary(projectRoot, runId) || undefined,
440
678
  };
@@ -443,19 +681,22 @@ async function runCompetitive(
443
681
  async function competeTwoAgents(
444
682
  nodeId: string,
445
683
  node: GraphNode,
446
- agentA: AgentSpec,
447
- agentB: AgentSpec,
448
- opts: CoordinationOptions,
449
- arbitrationResults: ArbitrationRecord[]
450
- ): Promise<TaskResult> {
684
+ agentA: AgentSpec,
685
+ agentB: AgentSpec,
686
+ opts: CoordinationOptions,
687
+ arbitrationResults: ArbitrationRecord[],
688
+ workspaceRecords: WorkspaceMergeRecord[]
689
+ ): Promise<TaskResult> {
451
690
  const { projectRoot, sessionId, runId } = opts;
452
691
 
453
- const allocA = await agentA.workspaceManager.allocate({
454
- work_item_id: nodeId, attempt_number: 1, strategy: node.workspace_strategy, mutation_scope: node.mutation_scope,
455
- });
456
- const allocB = await agentB.workspaceManager.allocate({
457
- work_item_id: nodeId, attempt_number: 1, strategy: node.workspace_strategy, mutation_scope: node.mutation_scope,
458
- });
692
+ const allocA = await agentA.workspaceManager.allocate({
693
+ work_item_id: nodeId, attempt_number: 1, strategy: node.workspace_strategy, mutation_scope: node.mutation_scope,
694
+ });
695
+ ensureGitWorktreeLease(agentA, allocA);
696
+ const allocB = await agentB.workspaceManager.allocate({
697
+ work_item_id: nodeId, attempt_number: 1, strategy: node.workspace_strategy, mutation_scope: node.mutation_scope,
698
+ });
699
+ ensureGitWorktreeLease(agentB, allocB);
459
700
 
460
701
  appendEvent(projectRoot, sessionId, {
461
702
  type: 'AttemptStarted',
@@ -473,13 +714,24 @@ async function competeTwoAgents(
473
714
  })),
474
715
  ]);
475
716
 
476
- await Promise.all([
477
- agentA.workspaceManager.dispose(allocA.workspace_id).catch(() => {}),
478
- agentB.workspaceManager.dispose(allocB.workspace_id).catch(() => {}),
479
- ]);
480
-
481
- const winner = resultA.success ? resultA : resultB.success ? resultB : resultA;
482
- const winnerAgentId = resultA.success ? agentA.id : resultB.success ? agentB.id : agentA.id;
717
+ const candidates = [
718
+ { agent: agentA, alloc: allocA, result: resultA },
719
+ { agent: agentB, alloc: allocB, result: resultB },
720
+ ].sort((left, right) => {
721
+ const leftScore = (left.result.success ? 10_000 : 0) + left.result.evidence.length * 100 - String(left.result.output || '').length;
722
+ const rightScore = (right.result.success ? 10_000 : 0) + right.result.evidence.length * 100 - String(right.result.output || '').length;
723
+ return rightScore - leftScore;
724
+ });
725
+ const winnerCandidate = candidates[0];
726
+ const winner = winnerCandidate.result;
727
+ const winnerAgentId = winnerCandidate.agent.id;
728
+ const winnerRecord = createMergeRecord(winnerCandidate.agent, node, winnerCandidate.alloc, winner);
729
+ workspaceRecords.push(opts.applyWorkspaceMerges ? applyWorkspaceRecord(projectRoot, winnerRecord) : winnerRecord);
730
+
731
+ await Promise.all([
732
+ agentA.workspaceManager.dispose(allocA.workspace_id).catch(() => {}),
733
+ agentB.workspaceManager.dispose(allocB.workspace_id).catch(() => {}),
734
+ ]);
483
735
  arbitrationResults.push({
484
736
  work_item_id: nodeId,
485
737
  mode: 'competitive',
@@ -512,7 +764,8 @@ async function runCooperative(
512
764
  ensureIsolatedAgents(opts.agents);
513
765
  const [planner, executor] = opts.agents;
514
766
  const { projectRoot, sessionId, runId } = opts;
515
- const handoffs: CooperativeHandoff[] = [];
767
+ const handoffs: CooperativeHandoff[] = [];
768
+ const workspaceRecords: WorkspaceMergeRecord[] = [];
516
769
 
517
770
  appendEvent(projectRoot, sessionId, {
518
771
  type: 'RunStarted',
@@ -528,13 +781,14 @@ async function runCooperative(
528
781
  for (const nodeId of wave.node_ids) {
529
782
  const node = graph.nodes.get(nodeId)!;
530
783
 
531
- const planAlloc = await planner.workspaceManager.allocate({
784
+ const planAlloc = await planner.workspaceManager.allocate({
532
785
  work_item_id: nodeId,
533
786
  attempt_number: 1,
534
787
  strategy: node.workspace_strategy,
535
- mutation_scope: node.mutation_scope,
536
- });
537
- await planner.workspaceManager.dispose(planAlloc.workspace_id).catch(() => {});
788
+ mutation_scope: node.mutation_scope,
789
+ });
790
+ ensureGitWorktreeLease(planner, planAlloc);
791
+ await planner.workspaceManager.dispose(planAlloc.workspace_id).catch(() => {});
538
792
 
539
793
  const handoff = buildHandoff({
540
794
  from_agent_id: planner.id,
@@ -553,12 +807,13 @@ async function runCooperative(
553
807
  payload: { mode: 'cooperative', handoff_id: handoff.handoff_id },
554
808
  });
555
809
 
556
- const execAlloc = await executor.workspaceManager.allocate({
810
+ const execAlloc = await executor.workspaceManager.allocate({
557
811
  work_item_id: nodeId,
558
812
  attempt_number: 1,
559
813
  strategy: node.workspace_strategy,
560
- mutation_scope: node.mutation_scope,
561
- });
814
+ mutation_scope: node.mutation_scope,
815
+ });
816
+ ensureGitWorktreeLease(executor, execAlloc);
562
817
 
563
818
  let result: TaskResult;
564
819
  try {
@@ -566,7 +821,9 @@ async function runCooperative(
566
821
  } catch (error) {
567
822
  result = { success: false, failure_class: 'env', evidence: [], output: String(error) };
568
823
  }
569
- await executor.workspaceManager.dispose(execAlloc.workspace_id).catch(() => {});
824
+ const mergeRecord = createMergeRecord(executor, node, execAlloc, result);
825
+ workspaceRecords.push(opts.applyWorkspaceMerges ? applyWorkspaceRecord(projectRoot, mergeRecord) : mergeRecord);
826
+ await executor.workspaceManager.dispose(execAlloc.workspace_id).catch(() => {});
570
827
 
571
828
  if (result.success) {
572
829
  completed.push(nodeId);
@@ -581,6 +838,7 @@ async function runCooperative(
581
838
  }
582
839
 
583
840
  const partitions = [Array.from(graph.nodes.keys()), Array.from(graph.nodes.keys())];
841
+ const workspaceMergeReport = buildWorkspaceMergeReport(runId, workspaceRecords, blocked);
584
842
  const state = makeState(
585
843
  'cooperative',
586
844
  runId,
@@ -594,9 +852,10 @@ async function runCooperative(
594
852
  failed,
595
853
  blocked,
596
854
  [],
597
- []
855
+ [],
856
+ workspaceMergeReport
598
857
  );
599
- persistMultiAgentArtifacts(projectRoot, runId, state, handoffs, []);
858
+ persistMultiAgentArtifacts(projectRoot, runId, state, handoffs, [], workspaceMergeReport);
600
859
 
601
860
  appendEvent(projectRoot, sessionId, {
602
861
  type: 'RunCompleted',
@@ -616,6 +875,7 @@ async function runCooperative(
616
875
  ],
617
876
  handoffs,
618
877
  arbitration_results: [],
878
+ workspace_merge_report: workspaceMergeReport,
619
879
  state,
620
880
  summary: loadMultiAgentSummary(projectRoot, runId) || undefined,
621
881
  };
@@ -643,6 +903,10 @@ export function multiAgentSummaryPath(projectRoot: string, runId: string): strin
643
903
  return path.join(projectRoot, '.oxe', 'runs', runId, 'multi-agent-summary.json');
644
904
  }
645
905
 
906
+ export function workspaceMergeReportPath(projectRoot: string, runId: string): string {
907
+ return path.join(projectRoot, '.oxe', 'runs', runId, 'workspace-merge-report.json');
908
+ }
909
+
646
910
  export function loadMultiAgentState(projectRoot: string, runId: string): MultiAgentStatusSnapshot | null {
647
911
  const statePath = multiAgentStatePath(projectRoot, runId);
648
912
  if (!fs.existsSync(statePath)) return null;
@@ -662,6 +926,16 @@ export function loadMultiAgentSummary(projectRoot: string, runId: string): Multi
662
926
  return null;
663
927
  }
664
928
  }
929
+
930
+ export function loadWorkspaceMergeReport(projectRoot: string, runId: string): WorkspaceMergeReport | null {
931
+ const reportPath = workspaceMergeReportPath(projectRoot, runId);
932
+ if (!fs.existsSync(reportPath)) return null;
933
+ try {
934
+ return JSON.parse(fs.readFileSync(reportPath, 'utf8')) as WorkspaceMergeReport;
935
+ } catch {
936
+ return null;
937
+ }
938
+ }
665
939
 
666
940
  // ─── Helpers ─────────────────────────────────────────────────────────────────
667
941