orquesta-cli 0.1.27 → 0.2.0

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.
@@ -0,0 +1,22 @@
1
+ import { LLMClient } from '../core/llm/llm-client.js';
2
+ import { Message, TodoItem } from '../types/index.js';
3
+ import type { StateCallbacks } from './types.js';
4
+ export interface ParallelRunResult {
5
+ newMessages: Message[];
6
+ todos: TodoItem[];
7
+ workerOutputs: Record<string, string>;
8
+ }
9
+ export declare function shouldUseParallelOrchestrator(todos: TodoItem[]): boolean;
10
+ export declare function planWaves(todos: TodoItem[]): TodoItem[][];
11
+ export interface RunGraphOptions {
12
+ llmClient: LLMClient;
13
+ todos: TodoItem[];
14
+ baseSystemPrompt: string;
15
+ sessionId: string;
16
+ callbacks: StateCallbacks;
17
+ isInterruptedRef: {
18
+ current: boolean;
19
+ };
20
+ }
21
+ export declare function runParallelGraph(opts: RunGraphOptions): Promise<ParallelRunResult>;
22
+ //# sourceMappingURL=parallel-orchestrator.d.ts.map
@@ -0,0 +1,234 @@
1
+ import { configManager } from '../core/config/config-manager.js';
2
+ import { toolRegistry } from '../tools/registry.js';
3
+ import { logger } from '../utils/logger.js';
4
+ import { worktreeManager } from './worktree-manager.js';
5
+ import { memoryStore } from './memory-store.js';
6
+ import { auditLog } from './audit-log.js';
7
+ export function shouldUseParallelOrchestrator(todos) {
8
+ if (todos.length < 2)
9
+ return false;
10
+ const hasExplicitDeps = todos.some(t => Array.isArray(t.dependsOn) && t.dependsOn.length > 0);
11
+ if (hasExplicitDeps)
12
+ return true;
13
+ const fsTouching = todos.filter(t => t.requiresFilesystem).length;
14
+ if (fsTouching >= 2)
15
+ return true;
16
+ const allIndependent = todos.every(t => !t.dependsOn || t.dependsOn.length === 0);
17
+ if (allIndependent && todos.length >= 3)
18
+ return true;
19
+ return false;
20
+ }
21
+ export function planWaves(todos) {
22
+ const remaining = new Map(todos.map(t => [t.id, t]));
23
+ const completed = new Set();
24
+ const waves = [];
25
+ while (remaining.size > 0) {
26
+ const wave = [];
27
+ for (const todo of remaining.values()) {
28
+ const deps = todo.dependsOn ?? [];
29
+ if (deps.every(d => completed.has(d))) {
30
+ wave.push(todo);
31
+ }
32
+ }
33
+ if (wave.length === 0) {
34
+ logger.warn('Dependency cycle or missing reference detected; dispatching remaining TODOs as final wave', {
35
+ remaining: Array.from(remaining.keys()),
36
+ });
37
+ wave.push(...remaining.values());
38
+ }
39
+ for (const t of wave) {
40
+ remaining.delete(t.id);
41
+ completed.add(t.id);
42
+ }
43
+ waves.push(wave);
44
+ }
45
+ return waves;
46
+ }
47
+ function buildWorkerMessages(ctx) {
48
+ const upstreamSection = Object.keys(ctx.upstreamOutputs).length > 0
49
+ ? `\n\nUpstream task outputs (your dependencies):\n${Object.entries(ctx.upstreamOutputs)
50
+ .map(([id, out]) => `- [${id}] ${truncate(out, 800)}`)
51
+ .join('\n')}`
52
+ : '';
53
+ const cwdNote = ctx.workingDirectory && ctx.workingDirectory !== process.cwd()
54
+ ? `\n\nIMPORTANT: Run all filesystem and shell operations from this directory: ${ctx.workingDirectory}\nDO NOT cd elsewhere.`
55
+ : '';
56
+ return [
57
+ { role: 'system', content: ctx.baseSystemPrompt },
58
+ {
59
+ role: 'user',
60
+ content: `Execute ONLY this single task and then call final_response with a short summary of what you did. Do not start other tasks.
61
+
62
+ Task [${ctx.todo.id}]: ${ctx.todo.title}${upstreamSection}${cwdNote}`,
63
+ },
64
+ ];
65
+ }
66
+ function truncate(s, n) {
67
+ if (!s || s.length <= n)
68
+ return s;
69
+ return s.slice(0, n) + '… [truncated]';
70
+ }
71
+ async function runWorker(ctx) {
72
+ const tools = toolRegistry.getLLMToolDefinitions();
73
+ const messages = buildWorkerMessages(ctx);
74
+ logger.flow('Worker dispatch', {
75
+ todoId: ctx.todo.id,
76
+ model: ctx.executorModel ?? '(default)',
77
+ cwd: ctx.workingDirectory ?? process.cwd(),
78
+ });
79
+ auditLog.emit(ctx.sessionId, 'worker.start', {
80
+ todoId: ctx.todo.id,
81
+ model: ctx.executorModel,
82
+ upstreamCount: Object.keys(ctx.upstreamOutputs).length,
83
+ isolated: !!ctx.workingDirectory && ctx.workingDirectory !== process.cwd(),
84
+ });
85
+ const startedAt = Date.now();
86
+ const result = await ctx.llmClient.chatCompletionWithTools(messages, tools, {
87
+ ...(ctx.executorModel ? { model: ctx.executorModel } : {}),
88
+ });
89
+ const extractSummary = () => {
90
+ for (let i = result.allMessages.length - 1; i >= 0; i--) {
91
+ const m = result.allMessages[i];
92
+ if (m?.role === 'assistant' && Array.isArray(m.tool_calls)) {
93
+ for (const tc of m.tool_calls) {
94
+ if (tc?.function?.name === 'final_response') {
95
+ try {
96
+ const args = JSON.parse(tc.function.arguments || '{}');
97
+ const msg = args.message ?? args.response ?? args.summary ?? args.content;
98
+ if (typeof msg === 'string' && msg.trim())
99
+ return msg.trim();
100
+ }
101
+ catch { }
102
+ }
103
+ }
104
+ }
105
+ }
106
+ for (let i = result.allMessages.length - 1; i >= 0; i--) {
107
+ const m = result.allMessages[i];
108
+ if (m?.role === 'assistant' && m.content && m.content.trim()) {
109
+ return m.content.trim();
110
+ }
111
+ }
112
+ return `(no summary from worker ${ctx.todo.id})`;
113
+ };
114
+ const summary = extractSummary();
115
+ await memoryStore.set(ctx.sessionId, `worker:${ctx.todo.id}`, {
116
+ title: ctx.todo.title,
117
+ summary,
118
+ completedAt: new Date().toISOString(),
119
+ }, 'worker');
120
+ auditLog.emit(ctx.sessionId, 'worker.complete', {
121
+ todoId: ctx.todo.id,
122
+ model: ctx.executorModel,
123
+ durationMs: Date.now() - startedAt,
124
+ summaryLength: summary.length,
125
+ success: true,
126
+ });
127
+ return { summary, messages: result.allMessages };
128
+ }
129
+ export async function runParallelGraph(opts) {
130
+ const { llmClient, callbacks, isInterruptedRef, sessionId, baseSystemPrompt } = opts;
131
+ const executorModel = configManager.getRoleModel('executor') ?? undefined;
132
+ const maxParallel = configManager.getMaxParallelWorkers();
133
+ const useWorktrees = configManager.isWorktreeIsolationEnabled();
134
+ const todos = opts.todos.map(t => ({ ...t }));
135
+ const todoById = new Map(todos.map(t => [t.id, t]));
136
+ const outputs = {};
137
+ const allNewMessages = [];
138
+ const waves = planWaves(todos);
139
+ logger.flow('Parallel orchestrator plan', {
140
+ waveCount: waves.length,
141
+ waveSizes: waves.map(w => w.length),
142
+ maxParallel,
143
+ });
144
+ for (const [waveIdx, wave] of waves.entries()) {
145
+ if (isInterruptedRef.current)
146
+ throw new Error('INTERRUPTED');
147
+ callbacks.setCurrentActivity(`Wave ${waveIdx + 1}/${waves.length} (${wave.length} task${wave.length === 1 ? '' : 's'})`);
148
+ const waveStartedAt = Date.now();
149
+ for (let i = 0; i < wave.length; i += maxParallel) {
150
+ const batch = wave.slice(i, i + maxParallel);
151
+ for (const t of batch) {
152
+ const item = todoById.get(t.id);
153
+ item.status = 'in_progress';
154
+ }
155
+ callbacks.setTodos([...todos]);
156
+ const allocations = new Map();
157
+ if (useWorktrees) {
158
+ for (const t of batch) {
159
+ if (t.requiresFilesystem) {
160
+ try {
161
+ const alloc = await worktreeManager.allocate(t.id);
162
+ allocations.set(t.id, alloc.path);
163
+ }
164
+ catch (error) {
165
+ logger.warn('Worktree allocation failed; worker will run in host cwd', { todoId: t.id, error: error.message });
166
+ }
167
+ }
168
+ }
169
+ }
170
+ const settled = await Promise.allSettled(batch.map(t => runWorker({
171
+ todo: todoById.get(t.id),
172
+ sessionId,
173
+ llmClient,
174
+ baseSystemPrompt,
175
+ executorModel,
176
+ upstreamOutputs: (t.dependsOn ?? []).reduce((acc, depId) => {
177
+ if (outputs[depId])
178
+ acc[depId] = outputs[depId];
179
+ return acc;
180
+ }, {}),
181
+ workingDirectory: allocations.get(t.id),
182
+ })));
183
+ for (let k = 0; k < settled.length; k++) {
184
+ const t = batch[k];
185
+ const item = todoById.get(t.id);
186
+ const res = settled[k];
187
+ if (res.status === 'fulfilled') {
188
+ outputs[t.id] = res.value.summary;
189
+ item.status = 'completed';
190
+ item.result = res.value.summary;
191
+ allNewMessages.push(...res.value.messages);
192
+ }
193
+ else {
194
+ const err = res.reason;
195
+ item.status = 'failed';
196
+ item.error = err?.message || String(err);
197
+ logger.error('Worker failed', err);
198
+ auditLog.emit(sessionId, 'worker.complete', {
199
+ todoId: t.id,
200
+ model: executorModel,
201
+ success: false,
202
+ error: err?.message,
203
+ });
204
+ }
205
+ }
206
+ callbacks.setTodos([...todos]);
207
+ auditLog.emit(sessionId, 'wave.complete', {
208
+ waveIdx,
209
+ size: batch.length,
210
+ durationMs: Date.now() - waveStartedAt,
211
+ succeeded: batch.filter((_, k) => settled[k].status === 'fulfilled').length,
212
+ failed: batch.filter((_, k) => settled[k].status === 'rejected').length,
213
+ });
214
+ if (useWorktrees) {
215
+ for (const [todoId] of allocations) {
216
+ const item = todoById.get(todoId);
217
+ if (item.status === 'completed') {
218
+ const merge = await worktreeManager.mergeBack(todoId);
219
+ if (!merge.success) {
220
+ logger.warn('Worktree merge had conflicts; left in place for manual resolution', {
221
+ todoId,
222
+ conflicts: merge.conflicts,
223
+ });
224
+ continue;
225
+ }
226
+ }
227
+ await worktreeManager.release(todoId);
228
+ }
229
+ }
230
+ }
231
+ }
232
+ return { newMessages: allNewMessages, todos, workerOutputs: outputs };
233
+ }
234
+ //# sourceMappingURL=parallel-orchestrator.js.map
@@ -22,6 +22,7 @@ export declare class PlanExecutor {
22
22
  percent: number;
23
23
  };
24
24
  private setupTodoCallbacks;
25
+ private maybeRunRefiner;
25
26
  private checkAndPerformAutoCompact;
26
27
  }
27
28
  export declare const planExecutor: PlanExecutor;
@@ -14,6 +14,9 @@ import { logger } from '../utils/logger.js';
14
14
  import { getStreamLogger } from '../utils/json-stream-logger.js';
15
15
  import { detectGitRepo } from '../utils/git-utils.js';
16
16
  import { formatErrorMessage, buildTodoContext, findActiveTodo, getTodoStats } from './utils.js';
17
+ import { runParallelGraph, shouldUseParallelOrchestrator } from './parallel-orchestrator.js';
18
+ import { memoryStore } from './memory-store.js';
19
+ import { auditLog } from './audit-log.js';
17
20
  function buildSystemPrompt() {
18
21
  const isGitRepo = detectGitRepo();
19
22
  const projectContext = getProjectContext();
@@ -41,17 +44,36 @@ export class PlanExecutor {
41
44
  callbacks.setExecutionPhase('planning');
42
45
  callbacks.setCurrentActivity('Planning tasks');
43
46
  let currentTodos = [];
47
+ const auditSid = sessionManager.getCurrentSessionId() || 'no-session';
48
+ const runId = auditLog.startRun(auditSid, {
49
+ currentModel: configManager.getConfig().currentModel,
50
+ plannerModel: configManager.getRoleModel('planner'),
51
+ executorModel: configManager.getRoleModel('executor'),
52
+ refinerModel: configManager.getRoleModel('refiner'),
53
+ refinerEnabled: configManager.isRefinerEnabled(),
54
+ worktreeEnabled: configManager.isWorktreeIsolationEnabled(),
55
+ maxParallel: configManager.getMaxParallelWorkers(),
56
+ });
44
57
  try {
45
58
  if (isInterruptedRef.current) {
46
59
  throw new Error('INTERRUPTED');
47
60
  }
48
61
  let currentMessages = messages;
49
62
  callbacks.setCurrentActivity('Thinking');
50
- const planningLLM = new PlanningLLM(llmClient);
63
+ const plannerModel = configManager.getRoleModel('planner');
64
+ const planningLLM = new PlanningLLM(llmClient, plannerModel ?? undefined);
65
+ const plannerStartedAt = Date.now();
51
66
  if (callbacks.askUser) {
52
67
  planningLLM.setAskUserCallback(callbacks.askUser);
53
68
  }
54
69
  const planResult = await planningLLM.generateTODOListWithDocsDecision(userMessage, currentMessages);
70
+ auditLog.emit(auditSid, 'planner.complete', {
71
+ runId,
72
+ model: plannerModel,
73
+ durationMs: Date.now() - plannerStartedAt,
74
+ todoCount: planResult.todos.length,
75
+ directResponse: !!planResult.directResponse,
76
+ });
55
77
  if (planResult.clarificationMessages?.length) {
56
78
  currentMessages = [...currentMessages, ...planResult.clarificationMessages];
57
79
  callbacks.setMessages([...currentMessages]);
@@ -119,28 +141,69 @@ export class PlanExecutor {
119
141
  }
120
142
  const activeTodo = findActiveTodo(currentTodos);
121
143
  callbacks.setCurrentActivity(activeTodo?.title || 'Working on tasks');
122
- const todoContext = buildTodoContext(currentTodos);
123
- const lastUserMsgIndex = currentMessages.map(m => m.role).lastIndexOf('user');
124
- const messagesForLLM = lastUserMsgIndex >= 0
125
- ? currentMessages.map((m, i) => i === lastUserMsgIndex
126
- ? { ...m, content: m.content + todoContext }
127
- : m)
128
- : [...currentMessages, { role: 'user', content: `Execute the TODO list.${todoContext}` }];
129
- const result = await llmClient.chatCompletionWithTools(messagesForLLM, tools, {
130
- getPendingMessage: callbacks.getPendingMessage,
131
- clearPendingMessage: callbacks.clearPendingMessage,
144
+ const sessionId = sessionManager.getCurrentSessionId();
145
+ if (sessionId) {
146
+ await memoryStore.set(sessionId, 'plan', {
147
+ todos: currentTodos.map(t => ({
148
+ id: t.id, title: t.title, dependsOn: t.dependsOn, requiresFilesystem: t.requiresFilesystem,
149
+ })),
150
+ userMessage,
151
+ }, 'planner');
152
+ }
153
+ const useParallel = shouldUseParallelOrchestrator(currentTodos);
154
+ auditLog.emit(auditSid, 'orchestrator.decision', {
155
+ runId,
156
+ parallel: useParallel,
157
+ todoCount: currentTodos.length,
158
+ explicitDeps: currentTodos.filter(t => Array.isArray(t.dependsOn) && t.dependsOn.length > 0).length,
159
+ requiresFilesystem: currentTodos.filter(t => t.requiresFilesystem).length,
132
160
  });
133
- const newMessages = result.allMessages.slice(currentMessages.length);
134
- currentMessages = [...currentMessages, ...newMessages];
135
- callbacks.setMessages([...currentMessages]);
136
- sessionManager.autoSaveCurrentSession(currentMessages);
161
+ if (useParallel && sessionId) {
162
+ logger.flow('Dispatching parallel orchestrator', { todoCount: currentTodos.length });
163
+ const baseSystem = currentMessages.find(m => m.role === 'system')?.content || buildSystemPrompt();
164
+ const graphResult = await runParallelGraph({
165
+ llmClient,
166
+ todos: currentTodos,
167
+ baseSystemPrompt: baseSystem,
168
+ sessionId,
169
+ callbacks,
170
+ isInterruptedRef,
171
+ });
172
+ currentTodos = graphResult.todos;
173
+ callbacks.setTodos([...currentTodos]);
174
+ currentMessages = [...currentMessages, ...graphResult.newMessages];
175
+ callbacks.setMessages([...currentMessages]);
176
+ sessionManager.autoSaveCurrentSession(currentMessages);
177
+ }
178
+ else {
179
+ const todoContext = buildTodoContext(currentTodos);
180
+ const lastUserMsgIndex = currentMessages.map(m => m.role).lastIndexOf('user');
181
+ const messagesForLLM = lastUserMsgIndex >= 0
182
+ ? currentMessages.map((m, i) => i === lastUserMsgIndex
183
+ ? { ...m, content: m.content + todoContext }
184
+ : m)
185
+ : [...currentMessages, { role: 'user', content: `Execute the TODO list.${todoContext}` }];
186
+ const executorModel = configManager.getRoleModel('executor');
187
+ const result = await llmClient.chatCompletionWithTools(messagesForLLM, tools, {
188
+ getPendingMessage: callbacks.getPendingMessage,
189
+ clearPendingMessage: callbacks.clearPendingMessage,
190
+ ...(executorModel ? { model: executorModel } : {}),
191
+ });
192
+ const newMessages = result.allMessages.slice(currentMessages.length);
193
+ currentMessages = [...currentMessages, ...newMessages];
194
+ callbacks.setMessages([...currentMessages]);
195
+ sessionManager.autoSaveCurrentSession(currentMessages);
196
+ }
137
197
  await this.checkAndPerformAutoCompact(llmClient, currentMessages, currentTodos, callbacks, (updated) => { currentMessages = updated; });
198
+ currentMessages = await this.maybeRunRefiner(llmClient, currentMessages, currentTodos, callbacks);
138
199
  const stats = getTodoStats(currentTodos);
139
200
  sessionManager.autoSaveCurrentSession(currentMessages);
201
+ auditLog.emit(auditSid, 'run.complete', { runId, ...stats });
140
202
  logger.exit('PlanExecutor.executePlanMode', { success: true, ...stats });
141
203
  }
142
204
  catch (error) {
143
205
  if (error instanceof Error && error.message === 'INTERRUPTED') {
206
+ auditLog.emit(auditSid, 'run.error', { runId, reason: 'interrupted' });
144
207
  logger.flow('Plan mode interrupted by user');
145
208
  callbacks.setMessages((prev) => {
146
209
  const updatedMessages = [
@@ -152,6 +215,7 @@ export class PlanExecutor {
152
215
  });
153
216
  return;
154
217
  }
218
+ auditLog.emit(auditSid, 'run.error', { runId, message: error?.message });
155
219
  logger.error('Plan mode execution failed', error);
156
220
  const errorMessage = formatErrorMessage(error);
157
221
  callbacks.setMessages((prev) => {
@@ -211,9 +275,11 @@ export class PlanExecutor {
211
275
  ? { ...m, content: m.content + todoContext }
212
276
  : m)
213
277
  : [...currentMessages, { role: 'user', content: `Resume the TODO list.${todoContext}` }];
278
+ const executorModel = configManager.getRoleModel('executor');
214
279
  const result = await llmClient.chatCompletionWithTools(messagesForLLM, tools, {
215
280
  getPendingMessage: callbacks.getPendingMessage,
216
281
  clearPendingMessage: callbacks.clearPendingMessage,
282
+ ...(executorModel ? { model: executorModel } : {}),
217
283
  });
218
284
  const newMessages = result.allMessages.slice(currentMessages.length);
219
285
  currentMessages = [...currentMessages, ...newMessages];
@@ -350,6 +416,81 @@ export class PlanExecutor {
350
416
  emitAssistantResponse(message);
351
417
  });
352
418
  }
419
+ async maybeRunRefiner(llmClient, currentMessages, currentTodos, callbacks) {
420
+ if (!configManager.isRefinerEnabled())
421
+ return currentMessages;
422
+ const refinerModel = configManager.getRoleModel('refiner');
423
+ if (!refinerModel)
424
+ return currentMessages;
425
+ const lastAssistantIdx = [...currentMessages].map(m => m.role).lastIndexOf('assistant');
426
+ if (lastAssistantIdx < 0)
427
+ return currentMessages;
428
+ const draft = currentMessages[lastAssistantIdx].content || '';
429
+ if (!draft.trim())
430
+ return currentMessages;
431
+ callbacks.setExecutionPhase('executing');
432
+ callbacks.setCurrentActivity('Refining response');
433
+ try {
434
+ const todoSummary = currentTodos
435
+ .map(t => `- [${t.status}] ${t.title}`)
436
+ .join('\n');
437
+ const sessionId = sessionManager.getCurrentSessionId();
438
+ let workerSummariesBlock = '';
439
+ if (sessionId) {
440
+ await memoryStore.load(sessionId);
441
+ const workerEntries = memoryStore.bySource(sessionId, 'worker');
442
+ if (workerEntries.length > 0) {
443
+ workerSummariesBlock = '\n\nPer-task worker outputs:\n' + workerEntries
444
+ .map(e => {
445
+ const v = e.value;
446
+ return `- ${v.title ?? '(untitled)'}: ${(v.summary ?? '').slice(0, 600)}`;
447
+ })
448
+ .join('\n');
449
+ }
450
+ }
451
+ const refinerSystem = `You are a refiner reviewing another model's final answer.
452
+ Tasks completed:
453
+ ${todoSummary || '(none)'}${workerSummariesBlock}
454
+
455
+ Read the draft response below. If it is coherent, accurate, and well-structured, repeat it verbatim. If it is unclear, contradicts the completed tasks, or could be tighter, rewrite it concisely. Output ONLY the final user-facing message — no commentary, no preface.`;
456
+ const response = await llmClient.chatCompletion({
457
+ messages: [
458
+ { role: 'system', content: refinerSystem },
459
+ { role: 'user', content: `Draft response:\n\n${draft}` },
460
+ ],
461
+ model: refinerModel,
462
+ temperature: 0.2,
463
+ max_tokens: 2000,
464
+ });
465
+ const refined = response.choices?.[0]?.message?.content?.trim();
466
+ const auditSid = sessionManager.getCurrentSessionId() || 'no-session';
467
+ if (refined && refined !== draft.trim()) {
468
+ const next = [...currentMessages];
469
+ next[lastAssistantIdx] = { ...next[lastAssistantIdx], content: refined };
470
+ callbacks.setMessages([...next]);
471
+ logger.flow('Refiner rewrote final response', {
472
+ originalLength: draft.length,
473
+ refinedLength: refined.length,
474
+ });
475
+ auditLog.emit(auditSid, 'refiner.complete', {
476
+ model: refinerModel,
477
+ rewrote: true,
478
+ originalLength: draft.length,
479
+ refinedLength: refined.length,
480
+ });
481
+ return next;
482
+ }
483
+ logger.flow('Refiner kept original response');
484
+ auditLog.emit(auditSid, 'refiner.complete', { model: refinerModel, rewrote: false });
485
+ return currentMessages;
486
+ }
487
+ catch (error) {
488
+ logger.warn('Refiner pass failed, keeping original draft', { error: error.message });
489
+ const auditSid = sessionManager.getCurrentSessionId() || 'no-session';
490
+ auditLog.emit(auditSid, 'refiner.complete', { model: refinerModel, rewrote: false, error: error.message });
491
+ return currentMessages;
492
+ }
493
+ }
353
494
  async checkAndPerformAutoCompact(llmClient, currentMessages, currentTodos, callbacks, updateLocalMessages) {
354
495
  const model = configManager.getCurrentModel();
355
496
  const maxTokens = model?.maxTokens || 128000;
@@ -0,0 +1,25 @@
1
+ export type WorktreeBackend = 'git' | 'copy';
2
+ export interface WorktreeAllocation {
3
+ id: string;
4
+ path: string;
5
+ backend: WorktreeBackend;
6
+ branch?: string;
7
+ }
8
+ export interface MergeResult {
9
+ success: boolean;
10
+ filesChanged: string[];
11
+ conflicts: string[];
12
+ error?: string;
13
+ }
14
+ export declare class WorktreeManager {
15
+ private allocations;
16
+ private readonly tmpRoot;
17
+ constructor(tmpRoot?: string);
18
+ allocate(id: string, hostCwd?: string): Promise<WorktreeAllocation>;
19
+ mergeBack(id: string, hostCwd?: string): Promise<MergeResult>;
20
+ release(id: string, hostCwd?: string): Promise<void>;
21
+ releaseAll(hostCwd?: string): Promise<void>;
22
+ listActive(): WorktreeAllocation[];
23
+ }
24
+ export declare const worktreeManager: WorktreeManager;
25
+ //# sourceMappingURL=worktree-manager.d.ts.map
@@ -0,0 +1,124 @@
1
+ import { exec } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import { promises as fs } from 'fs';
4
+ import * as path from 'path';
5
+ import * as os from 'os';
6
+ import { logger } from '../utils/logger.js';
7
+ const execAsync = promisify(exec);
8
+ async function isGitRepo(cwd) {
9
+ try {
10
+ await execAsync('git rev-parse --is-inside-work-tree', { cwd });
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ async function gitRepoRoot(cwd) {
18
+ const { stdout } = await execAsync('git rev-parse --show-toplevel', { cwd });
19
+ return stdout.trim();
20
+ }
21
+ export class WorktreeManager {
22
+ allocations = new Map();
23
+ tmpRoot;
24
+ constructor(tmpRoot) {
25
+ this.tmpRoot = tmpRoot ?? path.join(os.tmpdir(), 'orquesta-cli-worktrees');
26
+ }
27
+ async allocate(id, hostCwd = process.cwd()) {
28
+ if (this.allocations.has(id)) {
29
+ throw new Error(`Worktree already allocated for id=${id}`);
30
+ }
31
+ const gitMode = await isGitRepo(hostCwd);
32
+ let allocation;
33
+ if (gitMode) {
34
+ const repoRoot = await gitRepoRoot(hostCwd);
35
+ const branch = `orquesta/worker-${id}-${Date.now()}`;
36
+ const worktreeDir = path.join(this.tmpRoot, 'git', id);
37
+ await fs.mkdir(path.dirname(worktreeDir), { recursive: true });
38
+ await execAsync(`git worktree add -b ${branch} "${worktreeDir}" HEAD`, { cwd: repoRoot });
39
+ allocation = { id, path: worktreeDir, backend: 'git', branch };
40
+ logger.flow('Allocated git worktree', { id, branch, path: worktreeDir });
41
+ }
42
+ else {
43
+ const copyDir = path.join(this.tmpRoot, 'copy', id);
44
+ await fs.mkdir(copyDir, { recursive: true });
45
+ try {
46
+ await execAsync(`rsync -a --exclude='.git' "${hostCwd}/" "${copyDir}/"`);
47
+ }
48
+ catch {
49
+ await execAsync(`cp -a "${hostCwd}/." "${copyDir}/"`);
50
+ }
51
+ allocation = { id, path: copyDir, backend: 'copy' };
52
+ logger.flow('Allocated copy worktree', { id, path: copyDir });
53
+ }
54
+ this.allocations.set(id, allocation);
55
+ return allocation;
56
+ }
57
+ async mergeBack(id, hostCwd = process.cwd()) {
58
+ const alloc = this.allocations.get(id);
59
+ if (!alloc) {
60
+ return { success: false, filesChanged: [], conflicts: [], error: `No allocation for id=${id}` };
61
+ }
62
+ try {
63
+ if (alloc.backend === 'git' && alloc.branch) {
64
+ const repoRoot = await gitRepoRoot(hostCwd);
65
+ const { stdout: diffStat } = await execAsync(`git diff --name-only HEAD ${alloc.branch}`, { cwd: repoRoot });
66
+ const filesChanged = diffStat.split('\n').map(s => s.trim()).filter(Boolean);
67
+ try {
68
+ await execAsync(`git merge --no-ff ${alloc.branch}`, { cwd: repoRoot });
69
+ return { success: true, filesChanged, conflicts: [] };
70
+ }
71
+ catch (mergeError) {
72
+ const { stdout: conflicts } = await execAsync('git diff --name-only --diff-filter=U', { cwd: repoRoot }).catch(() => ({ stdout: '' }));
73
+ await execAsync('git merge --abort', { cwd: repoRoot }).catch(() => { });
74
+ return {
75
+ success: false,
76
+ filesChanged,
77
+ conflicts: conflicts.split('\n').map(s => s.trim()).filter(Boolean),
78
+ error: mergeError.message,
79
+ };
80
+ }
81
+ }
82
+ const { stdout: rawDiff } = await execAsync(`rsync -avc --dry-run --exclude='.git' "${alloc.path}/" "${hostCwd}/"`).catch(() => ({ stdout: '' }));
83
+ const filesChanged = rawDiff
84
+ .split('\n')
85
+ .filter(line => line && !line.startsWith('sending') && !line.startsWith('total') && !line.startsWith('sent ') && !line.includes('xfer#'))
86
+ .map(s => s.trim())
87
+ .filter(Boolean);
88
+ await execAsync(`rsync -a --exclude='.git' "${alloc.path}/" "${hostCwd}/"`);
89
+ return { success: true, filesChanged, conflicts: [] };
90
+ }
91
+ catch (error) {
92
+ return { success: false, filesChanged: [], conflicts: [], error: error.message };
93
+ }
94
+ }
95
+ async release(id, hostCwd = process.cwd()) {
96
+ const alloc = this.allocations.get(id);
97
+ if (!alloc)
98
+ return;
99
+ try {
100
+ if (alloc.backend === 'git') {
101
+ const repoRoot = await gitRepoRoot(hostCwd);
102
+ await execAsync(`git worktree remove --force "${alloc.path}"`, { cwd: repoRoot }).catch(() => { });
103
+ if (alloc.branch) {
104
+ await execAsync(`git branch -D ${alloc.branch}`, { cwd: repoRoot }).catch(() => { });
105
+ }
106
+ }
107
+ else {
108
+ await fs.rm(alloc.path, { recursive: true, force: true }).catch(() => { });
109
+ }
110
+ }
111
+ finally {
112
+ this.allocations.delete(id);
113
+ }
114
+ }
115
+ async releaseAll(hostCwd = process.cwd()) {
116
+ const ids = Array.from(this.allocations.keys());
117
+ await Promise.all(ids.map(id => this.release(id, hostCwd)));
118
+ }
119
+ listActive() {
120
+ return Array.from(this.allocations.values());
121
+ }
122
+ }
123
+ export const worktreeManager = new WorktreeManager();
124
+ //# sourceMappingURL=worktree-manager.js.map