orquesta-cli 0.2.92 → 0.2.94

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.
package/dist/cli.js CHANGED
@@ -45,6 +45,7 @@ async function ensureBatutaFromEnv() {
45
45
  const token = process.env['ORQUESTA_TOKEN'];
46
46
  if (!token)
47
47
  return;
48
+ await configManager.initialize();
48
49
  const apiUrl = (process.env['ORQUESTA_API_URL'] || 'https://getorquesta.com').replace(/\/+$/, '');
49
50
  if (configManager.getAllEndpoints().some((e) => e.id === 'batuta-proxy')) {
50
51
  await configManager.removeEndpoint('batuta-proxy');
@@ -151,6 +152,8 @@ program
151
152
  if (options.appendSystemPrompt) {
152
153
  setAppendedSystemPrompt(options.appendSystemPrompt);
153
154
  }
155
+ await configManager.initialize();
156
+ await ensureBatutaFromEnv();
154
157
  if (options.endpoint) {
155
158
  const wanted = String(options.endpoint).toLowerCase();
156
159
  const all = configManager.getAllEndpoints();
@@ -164,6 +167,9 @@ program
164
167
  if (m)
165
168
  await configManager.setCurrentModel(m.id);
166
169
  }
170
+ else {
171
+ logger.warn(`--endpoint "${options.endpoint}" matched no configured endpoint (have: ${all.map(e => e.id).join(', ') || 'none'}); keeping current selection`);
172
+ }
167
173
  }
168
174
  if (options.eval) {
169
175
  await runEvalMode();
@@ -182,8 +188,6 @@ program
182
188
  disableHooks();
183
189
  return;
184
190
  }
185
- await configManager.initialize();
186
- await ensureBatutaFromEnv();
187
191
  if (shouldShowOnboarding()) {
188
192
  await runOnboarding();
189
193
  }
@@ -424,9 +424,10 @@ export class LLMClient {
424
424
  currentAttempt: currentAttempt + 1,
425
425
  });
426
426
  }
427
- if (!this.triedBatutaFallback && this.isConnectionError(error) && this.switchToBatutaProxy()) {
427
+ const upstream5xx = axios.isAxiosError(error) && (error.response?.status ?? 0) >= 500;
428
+ if (!this.triedBatutaFallback && (this.isConnectionError(error) || upstream5xx) && this.switchToBatutaProxy()) {
428
429
  this.triedBatutaFallback = true;
429
- logger.flow('Primary endpoint unreachable — falling back to Batuta');
430
+ logger.flow('Primary endpoint failed — falling back to Batuta');
430
431
  return this.chatCompletion(options, { maxRetries, currentAttempt: 1 });
431
432
  }
432
433
  logger.flow('API call failed - Error handling');
@@ -672,6 +673,7 @@ export class LLMClient {
672
673
  let finalResponseFailures = 0;
673
674
  const MAX_NO_TOOL_CALL_RETRIES = 3;
674
675
  const MAX_FINAL_RESPONSE_FAILURES = 3;
676
+ const MAX_ITERATIONS = Number(process.env['ORQUESTA_MAX_TOOL_ITERATIONS']) || 50;
675
677
  const recentToolSignatures = [];
676
678
  const recentNormalizedSignatures = [];
677
679
  const LOOP_WINDOW = 5;
@@ -684,6 +686,11 @@ export class LLMClient {
684
686
  throw new Error('INTERRUPTED');
685
687
  }
686
688
  iterations++;
689
+ if (iterations > MAX_ITERATIONS) {
690
+ logger.error('Tool-call iteration budget exhausted — aborting', new Error(`MAX_ITERATIONS: ${iterations}`));
691
+ throw new Error(`MAX_ITERATIONS: exceeded ${MAX_ITERATIONS} tool iterations without producing a final answer. ` +
692
+ `Aborting to protect the session. Raise ORQUESTA_MAX_TOOL_ITERATIONS for genuinely long tasks.`);
693
+ }
687
694
  if (options?.getPendingMessage && options?.clearPendingMessage) {
688
695
  const pendingMsg = options.getPendingMessage();
689
696
  if (pendingMsg) {
@@ -1,5 +1,5 @@
1
1
  declare const SCHEMA_VERSION = 1;
2
- export type AuditEventKind = 'run.start' | 'planner.complete' | 'orchestrator.decision' | 'worker.start' | 'worker.complete' | 'wave.complete' | 'refiner.complete' | 'run.complete' | 'run.error';
2
+ export type AuditEventKind = 'run.start' | 'planner.complete' | 'orchestrator.decision' | 'worker.start' | 'worker.complete' | 'worker.blocked' | 'wave.complete' | 'refiner.complete' | 'run.complete' | 'run.stalled' | 'run.error';
3
3
  export interface AuditEvent {
4
4
  schema: typeof SCHEMA_VERSION;
5
5
  timestamp: string;
@@ -15,6 +15,7 @@ declare class AuditLogger {
15
15
  startRun(sessionId: string, data?: Record<string, unknown>): string;
16
16
  emit(sessionId: string, kind: AuditEventKind, data?: Record<string, unknown>): void;
17
17
  private write;
18
+ private rotateIfNeeded;
18
19
  tail(n?: number): Promise<AuditEvent[]>;
19
20
  stats(opts?: {
20
21
  sinceDays?: number;
@@ -31,12 +31,24 @@ class AuditLogger {
31
31
  await fs.mkdir(LOCAL_HOME_DIR, { recursive: true });
32
32
  this.initialized = true;
33
33
  }
34
+ await this.rotateIfNeeded();
34
35
  await fs.appendFile(AUDIT_LOG_PATH, JSON.stringify(event) + '\n', 'utf8');
35
36
  }
36
37
  catch (error) {
37
38
  logger.warn('Audit log write failed', { error: error.message });
38
39
  }
39
40
  }
41
+ async rotateIfNeeded() {
42
+ try {
43
+ const max = Number(process.env['ORQUESTA_AUDIT_MAX_BYTES']) || 10 * 1024 * 1024;
44
+ const { size } = await fs.stat(AUDIT_LOG_PATH);
45
+ if (size < max)
46
+ return;
47
+ await fs.rename(AUDIT_LOG_PATH, `${AUDIT_LOG_PATH}.1`).catch(() => undefined);
48
+ }
49
+ catch {
50
+ }
51
+ }
40
52
  async tail(n = 50) {
41
53
  try {
42
54
  const raw = await fs.readFile(AUDIT_LOG_PATH, 'utf8');
@@ -4,6 +4,7 @@ import { logger } from '../utils/logger.js';
4
4
  import { worktreeManager } from './worktree-manager.js';
5
5
  import { memoryStore } from './memory-store.js';
6
6
  import { auditLog } from './audit-log.js';
7
+ const WORKER_TIMEOUT_MS = Number(process.env['ORQUESTA_WORKER_TIMEOUT_MS']) || 15 * 60 * 1000;
7
8
  export function shouldUseParallelOrchestrator(todos) {
8
9
  if (todos.length < 2)
9
10
  return false;
@@ -11,8 +12,8 @@ export function shouldUseParallelOrchestrator(todos) {
11
12
  if (hasExplicitDeps)
12
13
  return true;
13
14
  const fsTouching = todos.filter(t => t.requiresFilesystem).length;
14
- if (fsTouching >= 2)
15
- return true;
15
+ if (fsTouching > 0)
16
+ return false;
16
17
  const allIndependent = todos.every(t => !t.dependsOn || t.dependsOn.length === 0);
17
18
  if (allIndependent && todos.length >= 3)
18
19
  return true;
@@ -83,9 +84,17 @@ async function runWorker(ctx) {
83
84
  isolated: !!ctx.workingDirectory && ctx.workingDirectory !== process.cwd(),
84
85
  });
85
86
  const startedAt = Date.now();
86
- const result = await ctx.llmClient.chatCompletionWithTools(messages, tools, {
87
- ...(ctx.executorModel ? { model: ctx.executorModel } : {}),
88
- });
87
+ let watchdog;
88
+ const result = await Promise.race([
89
+ ctx.llmClient.chatCompletionWithTools(messages, tools, {
90
+ ...(ctx.executorModel ? { model: ctx.executorModel } : {}),
91
+ }),
92
+ new Promise((_, reject) => {
93
+ watchdog = setTimeout(() => reject(new Error(`Worker ${ctx.todo.id} exceeded ${Math.round(WORKER_TIMEOUT_MS / 1000)}s watchdog — task aborted`)), WORKER_TIMEOUT_MS);
94
+ watchdog.unref?.();
95
+ }),
96
+ ]).finally(() => { if (watchdog)
97
+ clearTimeout(watchdog); });
89
98
  const extractSummary = () => {
90
99
  for (let i = result.allMessages.length - 1; i >= 0; i--) {
91
100
  const m = result.allMessages[i];
@@ -135,6 +144,7 @@ export async function runParallelGraph(opts) {
135
144
  const todoById = new Map(todos.map(t => [t.id, t]));
136
145
  const outputs = {};
137
146
  const allNewMessages = [];
147
+ const failed = new Set();
138
148
  const waves = planWaves(todos);
139
149
  logger.flow('Parallel orchestrator plan', {
140
150
  waveCount: waves.length,
@@ -147,7 +157,27 @@ export async function runParallelGraph(opts) {
147
157
  callbacks.setCurrentActivity(`Wave ${waveIdx + 1}/${waves.length} (${wave.length} task${wave.length === 1 ? '' : 's'})`);
148
158
  const waveStartedAt = Date.now();
149
159
  for (let i = 0; i < wave.length; i += maxParallel) {
150
- const batch = wave.slice(i, i + maxParallel);
160
+ const fullBatch = wave.slice(i, i + maxParallel);
161
+ const batch = [];
162
+ for (const t of fullBatch) {
163
+ const item = todoById.get(t.id);
164
+ const deadDeps = (item.dependsOn ?? []).filter(d => failed.has(d));
165
+ if (deadDeps.length > 0) {
166
+ item.status = 'failed';
167
+ item.error = `Blocked: prerequisite ${deadDeps.join(', ')} did not complete`;
168
+ failed.add(t.id);
169
+ await memoryStore.set(sessionId, `worker:${t.id}`, {
170
+ title: item.title,
171
+ summary: `⛔ ${item.error}`,
172
+ blocked: true,
173
+ completedAt: new Date().toISOString(),
174
+ }, 'worker');
175
+ auditLog.emit(sessionId, 'worker.blocked', { todoId: t.id, deadDeps });
176
+ }
177
+ else {
178
+ batch.push(t);
179
+ }
180
+ }
151
181
  for (const t of batch) {
152
182
  const item = todoById.get(t.id);
153
183
  item.status = 'in_progress';
@@ -194,7 +224,14 @@ export async function runParallelGraph(opts) {
194
224
  const err = res.reason;
195
225
  item.status = 'failed';
196
226
  item.error = err?.message || String(err);
227
+ failed.add(t.id);
197
228
  logger.error('Worker failed', err);
229
+ await memoryStore.set(sessionId, `worker:${t.id}`, {
230
+ title: item.title,
231
+ summary: `❌ Failed: ${item.error}`,
232
+ failed: true,
233
+ completedAt: new Date().toISOString(),
234
+ }, 'worker');
198
235
  auditLog.emit(sessionId, 'worker.complete', {
199
236
  todoId: t.id,
200
237
  model: executorModel,
@@ -78,6 +78,29 @@ export class PlanExecutor {
78
78
  });
79
79
  let runError = null;
80
80
  let runInterrupted = false;
81
+ let runHadFailures = false;
82
+ const STALL_TIMEOUT_MS = Number(process.env['ORQUESTA_STALL_TIMEOUT_MS']) || 20 * 60 * 1000;
83
+ let lastActivityAt = Date.now();
84
+ const bumpActivity = () => { lastActivityAt = Date.now(); };
85
+ const baseCb = callbacks;
86
+ callbacks = {
87
+ ...baseCb,
88
+ setTodos: (t) => { bumpActivity(); baseCb.setTodos(t); },
89
+ setMessages: (m) => { bumpActivity(); baseCb.setMessages(m); },
90
+ setCurrentActivity: (a) => { bumpActivity(); baseCb.setCurrentActivity(a); },
91
+ setCurrentTodoId: (id) => { bumpActivity(); baseCb.setCurrentTodoId(id); },
92
+ setExecutionPhase: (p) => { bumpActivity(); baseCb.setExecutionPhase(p); },
93
+ };
94
+ const stallTimer = setInterval(() => {
95
+ const idleMs = Date.now() - lastActivityAt;
96
+ if (!isInterruptedRef.current && idleMs > STALL_TIMEOUT_MS) {
97
+ logger.warn(`Stall watchdog: no progress for ${Math.round(idleMs / 1000)}s — aborting run`);
98
+ auditLog.emit(auditSid, 'run.stalled', { runId, idleMs });
99
+ isInterruptedRef.current = true;
100
+ baseCb.setIsInterrupted(true);
101
+ }
102
+ }, 30_000);
103
+ stallTimer.unref?.();
81
104
  try {
82
105
  if (isInterruptedRef.current) {
83
106
  throw new Error('INTERRUPTED');
@@ -221,9 +244,24 @@ export class PlanExecutor {
221
244
  await this.checkAndPerformAutoCompact(llmClient, currentMessages, currentTodos, callbacks, (updated) => { currentMessages = updated; });
222
245
  currentMessages = await this.maybeRunRefiner(llmClient, currentMessages, currentTodos, callbacks);
223
246
  const stats = getTodoStats(currentTodos);
247
+ const failedTodos = currentTodos.filter(t => t.status === 'failed');
248
+ const runSucceeded = failedTodos.length === 0;
249
+ runHadFailures = !runSucceeded;
224
250
  sessionManager.autoSaveCurrentSession(currentMessages);
225
- auditLog.emit(auditSid, 'run.complete', { runId, ...stats });
226
- logger.exit('PlanExecutor.executePlanMode', { success: true, ...stats });
251
+ if (failedTodos.length > 0) {
252
+ const lines = failedTodos
253
+ .map(t => ` • [${t.id}] ${t.title} — ${t.error || 'failed'}`)
254
+ .join('\n');
255
+ callbacks.setMessages((prev) => [
256
+ ...prev,
257
+ {
258
+ role: 'assistant',
259
+ content: `⚠️ Run did NOT fully succeed — ${failedTodos.length} task(s) failed or were blocked:\n${lines}\n\nDo not treat this as a successful deploy/run until these are resolved.`,
260
+ },
261
+ ]);
262
+ }
263
+ auditLog.emit(auditSid, 'run.complete', { runId, success: runSucceeded, ...stats });
264
+ logger.exit('PlanExecutor.executePlanMode', { success: runSucceeded, ...stats });
227
265
  }
228
266
  catch (error) {
229
267
  if (error instanceof Error && error.message === 'INTERRUPTED') {
@@ -259,6 +297,7 @@ export class PlanExecutor {
259
297
  });
260
298
  }
261
299
  finally {
300
+ clearInterval(stallTimer);
262
301
  if (orquestaTrackingId) {
263
302
  try {
264
303
  if (runInterrupted) {
@@ -267,8 +306,8 @@ export class PlanExecutor {
267
306
  else {
268
307
  const usage = usageTracker.getSessionUsage();
269
308
  await reportPromptComplete(orquestaTrackingId, {
270
- success: !runError,
271
- error: runError ? formatErrorMessage(runError) : undefined,
309
+ success: !runError && !runHadFailures,
310
+ error: runError ? formatErrorMessage(runError) : (runHadFailures ? 'One or more tasks failed or were blocked' : undefined),
272
311
  tokensUsed: usage.totalTokens || undefined,
273
312
  });
274
313
  }
@@ -53,10 +53,11 @@ Use this ONLY for pure questions that need NO action:
53
53
  ## Guidelines
54
54
 
55
55
  ### For create_todos:
56
- 1. **1-5 high-level TODOs** - Even 1 TODO is fine! Don't be too granular, let Execution LLM handle details
57
- 2. **Actionable titles** - Clear what needs to be done
58
- 3. **Sequential order** - Execution order matters
59
- 4. **User's language** - Write titles in the same language as the user
56
+ 1. **Create the MINIMUM number of TODOs.** 1 task is the ideal and most common case. Only add more when the work is genuinely separable AND benefits from it. Debugging, investigation, and "figure out / fix X" are SEQUENTIAL by nature — each finding informs the next step — so they should be a SINGLE task, never split. Add multiple tasks only for genuinely independent, parallelizable implementation work (e.g. "build feature A" + "build unrelated feature B"). When unsure, prefer fewer.
57
+ 2. **dependsOn for ordered steps** - If task B needs task A's result (build → upload → restart), set B.dependsOn=[A]. Tasks that touch the filesystem with no declared deps run sequentially in listed order.
58
+ 3. **Actionable titles** - Clear what needs to be done
59
+ 4. **Sequential order** - List tasks in execution order
60
+ 5. **User's language** - Write titles in the same language as the user
60
61
 
61
62
  ### For respond_to_user:
62
63
  1. **Clear and helpful** - Answer the question directly
@@ -12,37 +12,65 @@ async function executeBash(command, cwd, timeout = 30000, explicitEnv) {
12
12
  const child = spawn(shell, shellArgs, {
13
13
  cwd: cwd || process.cwd(),
14
14
  env: filterSafeEnv(process.env, userSafeVars, explicitEnv || {}),
15
+ detached: true,
15
16
  });
16
17
  let stdout = '';
17
18
  let stderr = '';
18
19
  let killed = false;
19
- child.stdout?.on('data', (data) => {
20
- stdout += data.toString();
21
- });
22
- child.stderr?.on('data', (data) => {
23
- stderr += data.toString();
24
- });
25
- child.on('close', (code) => {
20
+ let settled = false;
21
+ const finish = (code) => {
22
+ if (settled)
23
+ return;
24
+ settled = true;
26
25
  clearTimeout(timer);
26
+ clearTimeout(hardTimer);
27
27
  resolve({
28
28
  stdout: stdout.trim(),
29
29
  stderr: stderr.trim(),
30
30
  exitCode: code ?? (killed ? 124 : 0),
31
31
  timedOut: killed,
32
32
  });
33
+ };
34
+ const killGroup = (sig) => {
35
+ try {
36
+ if (child.pid)
37
+ process.kill(-child.pid, sig);
38
+ else
39
+ child.kill(sig);
40
+ }
41
+ catch {
42
+ try {
43
+ child.kill(sig);
44
+ }
45
+ catch { }
46
+ }
47
+ };
48
+ child.stdout?.on('data', (data) => {
49
+ stdout += data.toString();
33
50
  });
51
+ child.stderr?.on('data', (data) => {
52
+ stderr += data.toString();
53
+ });
54
+ child.on('close', (code) => finish(code));
34
55
  child.on('error', (error) => {
56
+ if (settled)
57
+ return;
58
+ settled = true;
35
59
  clearTimeout(timer);
60
+ clearTimeout(hardTimer);
36
61
  reject(error);
37
62
  });
38
63
  const timer = setTimeout(() => {
39
64
  killed = true;
40
- child.kill('SIGTERM');
41
- setTimeout(() => { try {
42
- child.kill('SIGKILL');
43
- }
44
- catch { } }, 2000).unref?.();
65
+ killGroup('SIGTERM');
66
+ setTimeout(() => killGroup('SIGKILL'), 2000).unref?.();
45
67
  }, timeout);
68
+ const hardTimer = setTimeout(() => {
69
+ killed = true;
70
+ killGroup('SIGKILL');
71
+ finish(124);
72
+ }, timeout + 5000);
73
+ hardTimer.unref?.();
46
74
  });
47
75
  }
48
76
  const BASH_TOOL_DEFINITION = {
@@ -459,9 +459,10 @@ export const PlanExecuteApp = ({ llmClient: initialLlmClient, modelInfo, resumeL
459
459
  }
460
460
  const interval = setInterval(() => {
461
461
  const sessionUsage = usageTracker.getSessionUsage();
462
- setSessionTokens(sessionUsage.totalTokens);
463
- setSessionElapsed(usageTracker.getSessionElapsedSeconds());
464
- }, 500);
462
+ setSessionTokens(prev => (prev === sessionUsage.totalTokens ? prev : sessionUsage.totalTokens));
463
+ const elapsed = usageTracker.getSessionElapsedSeconds();
464
+ setSessionElapsed(prev => (prev === elapsed ? prev : elapsed));
465
+ }, 1000);
465
466
  return () => clearInterval(interval);
466
467
  }, [isProcessing]);
467
468
  useEffect(() => {
@@ -4,7 +4,10 @@ import { promisify } from 'util';
4
4
  const mkdir = promisify(fs.mkdir);
5
5
  const readFile = promisify(fs.readFile);
6
6
  const writeFile = promisify(fs.writeFile);
7
+ const rename = promisify(fs.rename);
8
+ const unlink = promisify(fs.unlink);
7
9
  const access = promisify(fs.access);
10
+ let tmpCounter = 0;
8
11
  const stat = promisify(fs.stat);
9
12
  export async function directoryExists(dirPath) {
10
13
  try {
@@ -51,7 +54,18 @@ export async function writeJsonFile(filePath, data) {
51
54
  const dirPath = path.dirname(filePath);
52
55
  await ensureDirectory(dirPath);
53
56
  const content = JSON.stringify(data, null, 2);
54
- await writeFile(filePath, content, 'utf-8');
57
+ const tmpPath = `${filePath}.tmp.${process.pid}.${tmpCounter++}`;
58
+ try {
59
+ await writeFile(tmpPath, content, 'utf-8');
60
+ await rename(tmpPath, filePath);
61
+ }
62
+ catch (err) {
63
+ try {
64
+ await unlink(tmpPath);
65
+ }
66
+ catch { }
67
+ throw err;
68
+ }
55
69
  }
56
70
  catch (error) {
57
71
  if (error instanceof Error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orquesta-cli",
3
- "version": "0.2.92",
3
+ "version": "0.2.94",
4
4
  "description": "Orquesta CLI - AI-powered coding assistant with team collaboration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",