orquesta-cli 0.2.91 → 0.2.93

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');
@@ -152,6 +153,8 @@ program
152
153
  setAppendedSystemPrompt(options.appendSystemPrompt);
153
154
  }
154
155
  if (options.endpoint) {
156
+ await configManager.initialize();
157
+ await ensureBatutaFromEnv();
155
158
  const wanted = String(options.endpoint).toLowerCase();
156
159
  const all = configManager.getAllEndpoints();
157
160
  const ep = wanted === 'batuta'
@@ -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();
@@ -672,6 +672,7 @@ export class LLMClient {
672
672
  let finalResponseFailures = 0;
673
673
  const MAX_NO_TOOL_CALL_RETRIES = 3;
674
674
  const MAX_FINAL_RESPONSE_FAILURES = 3;
675
+ const MAX_ITERATIONS = Number(process.env['ORQUESTA_MAX_TOOL_ITERATIONS']) || 50;
675
676
  const recentToolSignatures = [];
676
677
  const recentNormalizedSignatures = [];
677
678
  const LOOP_WINDOW = 5;
@@ -684,6 +685,11 @@ export class LLMClient {
684
685
  throw new Error('INTERRUPTED');
685
686
  }
686
687
  iterations++;
688
+ if (iterations > MAX_ITERATIONS) {
689
+ logger.error('Tool-call iteration budget exhausted — aborting', new Error(`MAX_ITERATIONS: ${iterations}`));
690
+ throw new Error(`MAX_ITERATIONS: exceeded ${MAX_ITERATIONS} tool iterations without producing a final answer. ` +
691
+ `Aborting to protect the session. Raise ORQUESTA_MAX_TOOL_ITERATIONS for genuinely long tasks.`);
692
+ }
687
693
  if (options?.getPendingMessage && options?.clearPendingMessage) {
688
694
  const pendingMsg = options.getPendingMessage();
689
695
  if (pendingMsg) {
@@ -1,51 +1,85 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { logger } from '../utils/logger.js';
4
- const MAX_BYTES = 32_000;
5
- const CANDIDATE_PATHS = [
4
+ const PER_FILE_MAX_BYTES = 32_000;
5
+ const TOTAL_MAX_BYTES = 48_000;
6
+ const CANDIDATE_FILES = [
6
7
  'CLAUDE.md',
7
8
  '.claude/CLAUDE.md',
9
+ 'AGENTS.md',
8
10
  'AGENT.md',
9
11
  'agent.md',
12
+ 'GEMINI.md',
13
+ 'QWEN.md',
14
+ 'KIRO.md',
15
+ ];
16
+ const CANDIDATE_DIRS = [
17
+ '.kiro/steering',
10
18
  ];
11
19
  let cachedContext;
12
- function readFirstAvailable(cwd) {
13
- for (const rel of CANDIDATE_PATHS) {
20
+ function capFile(content) {
21
+ if (Buffer.byteLength(content, 'utf-8') > PER_FILE_MAX_BYTES) {
22
+ return content.slice(0, PER_FILE_MAX_BYTES) + '\n\n[...truncated for context window]';
23
+ }
24
+ return content;
25
+ }
26
+ function collectContextFiles(cwd) {
27
+ const out = [];
28
+ const seen = new Set();
29
+ const tryAdd = (rel) => {
14
30
  const abs = path.join(cwd, rel);
15
31
  try {
16
32
  const stat = fs.statSync(abs);
17
33
  if (!stat.isFile())
18
- continue;
19
- let content = fs.readFileSync(abs, 'utf-8');
20
- if (Buffer.byteLength(content, 'utf-8') > MAX_BYTES) {
21
- content = content.slice(0, MAX_BYTES) + '\n\n[...truncated for context window]';
22
- }
23
- return { path: rel, content };
34
+ return;
35
+ const content = capFile(fs.readFileSync(abs, 'utf-8'));
36
+ const key = content.trim();
37
+ if (!key || seen.has(key))
38
+ return;
39
+ seen.add(key);
40
+ out.push({ path: rel, content });
41
+ }
42
+ catch {
43
+ }
44
+ };
45
+ for (const rel of CANDIDATE_FILES)
46
+ tryAdd(rel);
47
+ for (const dir of CANDIDATE_DIRS) {
48
+ try {
49
+ const entries = fs.readdirSync(path.join(cwd, dir)).filter(f => f.toLowerCase().endsWith('.md')).sort();
50
+ for (const f of entries)
51
+ tryAdd(path.join(dir, f));
24
52
  }
25
53
  catch {
26
- continue;
27
54
  }
28
55
  }
29
- return null;
56
+ return out;
30
57
  }
31
58
  export function getProjectContext(cwd = process.cwd()) {
32
59
  if (cachedContext !== undefined)
33
60
  return cachedContext ?? '';
34
- const hit = readFirstAvailable(cwd);
35
- if (!hit) {
61
+ const files = collectContextFiles(cwd);
62
+ if (files.length === 0) {
36
63
  cachedContext = null;
37
- logger.debug('No CLAUDE.md / AGENT.md found in cwd', { cwd });
64
+ logger.debug('No agent-instruction files (CLAUDE.md / AGENTS.md / …) found in cwd', { cwd });
38
65
  return '';
39
66
  }
40
- logger.info(`Loaded project context from ${hit.path} (${hit.content.length} chars)`);
41
- cachedContext = [
42
- '',
43
- '## PROJECT CONTEXT',
44
- `(from ${hit.path} — treat as authoritative project conventions)`,
45
- '',
46
- hit.content,
47
- '',
48
- ].join('\n');
67
+ const sections = ['', '## PROJECT CONTEXT'];
68
+ let total = 0;
69
+ const used = [];
70
+ for (const f of files) {
71
+ const block = `\n### From ${f.path} (authoritative project conventions)\n\n${f.content}\n`;
72
+ if (total + Buffer.byteLength(block, 'utf-8') > TOTAL_MAX_BYTES) {
73
+ logger.debug(`Skipping ${f.path}: merged project context would exceed ${TOTAL_MAX_BYTES} bytes`);
74
+ continue;
75
+ }
76
+ total += Buffer.byteLength(block, 'utf-8');
77
+ sections.push(block);
78
+ used.push(f.path);
79
+ }
80
+ sections.push('');
81
+ logger.info(`Loaded project context from ${used.length} file(s): ${used.join(', ')} (${total} bytes)`);
82
+ cachedContext = sections.join('\n');
49
83
  return cachedContext;
50
84
  }
51
85
  export function invalidateProjectContext() {
@@ -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;
@@ -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
  }
@@ -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(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orquesta-cli",
3
- "version": "0.2.91",
3
+ "version": "0.2.93",
4
4
  "description": "Orquesta CLI - AI-powered coding assistant with team collaboration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",