@covibes/zeroshot 5.3.0 → 5.4.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.
Files changed (119) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +8 -385
  3. package/cli/index.js +108 -4615
  4. package/package.json +16 -125
  5. package/CHANGELOG.md +0 -537
  6. package/cli/commands/providers.js +0 -150
  7. package/cli/lib/first-run.js +0 -211
  8. package/cli/lib/update-checker.js +0 -281
  9. package/cli/message-formatter-utils.js +0 -75
  10. package/cli/message-formatters-normal.js +0 -253
  11. package/cli/message-formatters-watch.js +0 -181
  12. package/cluster-templates/base-templates/debug-workflow.json +0 -375
  13. package/cluster-templates/base-templates/full-workflow.json +0 -697
  14. package/cluster-templates/base-templates/single-worker.json +0 -63
  15. package/cluster-templates/base-templates/worker-validator.json +0 -152
  16. package/cluster-templates/conductor-bootstrap.json +0 -124
  17. package/docker/zeroshot-cluster/Dockerfile +0 -152
  18. package/docker/zeroshot-cluster/pre-baked-deps.json +0 -28
  19. package/lib/completion.js +0 -174
  20. package/lib/docker-config.js +0 -212
  21. package/lib/id-detector.js +0 -53
  22. package/lib/provider-detection.js +0 -59
  23. package/lib/provider-names.js +0 -56
  24. package/lib/settings.js +0 -405
  25. package/lib/stream-json-parser.js +0 -10
  26. package/scripts/fix-node-pty-permissions.js +0 -75
  27. package/scripts/record-demo.sh +0 -279
  28. package/scripts/test-install.sh +0 -40
  29. package/scripts/validate-templates.js +0 -100
  30. package/src/agent/agent-config.js +0 -193
  31. package/src/agent/agent-context-builder.js +0 -335
  32. package/src/agent/agent-hook-executor.js +0 -402
  33. package/src/agent/agent-lifecycle.js +0 -683
  34. package/src/agent/agent-stuck-detector.js +0 -256
  35. package/src/agent/agent-task-executor.js +0 -1431
  36. package/src/agent/agent-trigger-evaluator.js +0 -67
  37. package/src/agent/output-extraction.js +0 -200
  38. package/src/agent/output-reformatter.js +0 -175
  39. package/src/agent/schema-utils.js +0 -111
  40. package/src/agent-wrapper.js +0 -570
  41. package/src/agents/git-pusher-agent.json +0 -20
  42. package/src/attach/attach-client.js +0 -438
  43. package/src/attach/attach-server.js +0 -543
  44. package/src/attach/index.js +0 -35
  45. package/src/attach/protocol.js +0 -220
  46. package/src/attach/ring-buffer.js +0 -121
  47. package/src/attach/socket-discovery.js +0 -267
  48. package/src/claude-task-runner.js +0 -523
  49. package/src/config-router.js +0 -80
  50. package/src/config-validator.js +0 -1660
  51. package/src/copy-worker.js +0 -43
  52. package/src/github.js +0 -139
  53. package/src/isolation-manager.js +0 -1381
  54. package/src/ledger.js +0 -614
  55. package/src/logic-engine.js +0 -223
  56. package/src/message-bus-bridge.js +0 -139
  57. package/src/message-bus.js +0 -249
  58. package/src/name-generator.js +0 -232
  59. package/src/orchestrator.js +0 -2519
  60. package/src/preflight.js +0 -485
  61. package/src/process-metrics.js +0 -566
  62. package/src/providers/anthropic/cli-builder.js +0 -45
  63. package/src/providers/anthropic/index.js +0 -134
  64. package/src/providers/anthropic/models.js +0 -23
  65. package/src/providers/anthropic/output-parser.js +0 -159
  66. package/src/providers/base-provider.js +0 -181
  67. package/src/providers/capabilities.js +0 -51
  68. package/src/providers/google/cli-builder.js +0 -55
  69. package/src/providers/google/index.js +0 -116
  70. package/src/providers/google/models.js +0 -24
  71. package/src/providers/google/output-parser.js +0 -92
  72. package/src/providers/index.js +0 -75
  73. package/src/providers/openai/cli-builder.js +0 -122
  74. package/src/providers/openai/index.js +0 -135
  75. package/src/providers/openai/models.js +0 -21
  76. package/src/providers/openai/output-parser.js +0 -129
  77. package/src/schemas/sub-cluster.js +0 -156
  78. package/src/status-footer.js +0 -980
  79. package/src/sub-cluster-wrapper.js +0 -560
  80. package/src/task-runner.js +0 -30
  81. package/src/template-resolver.js +0 -369
  82. package/src/tui/CHANGES.txt +0 -133
  83. package/src/tui/LAYOUT.md +0 -261
  84. package/src/tui/README.txt +0 -192
  85. package/src/tui/TWO-LEVEL-NAVIGATION.md +0 -186
  86. package/src/tui/data-poller.js +0 -325
  87. package/src/tui/demo.js +0 -208
  88. package/src/tui/formatters.js +0 -128
  89. package/src/tui/index.js +0 -193
  90. package/src/tui/keybindings.js +0 -383
  91. package/src/tui/layout.js +0 -334
  92. package/src/tui/renderer.js +0 -194
  93. package/task-lib/attachable-watcher.js +0 -272
  94. package/task-lib/claude-recovery.js +0 -119
  95. package/task-lib/commands/clean.js +0 -50
  96. package/task-lib/commands/get-log-path.js +0 -23
  97. package/task-lib/commands/kill.js +0 -32
  98. package/task-lib/commands/list.js +0 -105
  99. package/task-lib/commands/logs.js +0 -411
  100. package/task-lib/commands/resume.js +0 -42
  101. package/task-lib/commands/run.js +0 -57
  102. package/task-lib/commands/schedule.js +0 -105
  103. package/task-lib/commands/scheduler-cmd.js +0 -96
  104. package/task-lib/commands/schedules.js +0 -98
  105. package/task-lib/commands/status.js +0 -44
  106. package/task-lib/commands/unschedule.js +0 -16
  107. package/task-lib/completion.js +0 -9
  108. package/task-lib/config.js +0 -10
  109. package/task-lib/name-generator.js +0 -230
  110. package/task-lib/package.json +0 -3
  111. package/task-lib/runner.js +0 -144
  112. package/task-lib/scheduler.js +0 -252
  113. package/task-lib/store.js +0 -230
  114. package/task-lib/tui/formatters.js +0 -166
  115. package/task-lib/tui/index.js +0 -197
  116. package/task-lib/tui/layout.js +0 -111
  117. package/task-lib/tui/renderer.js +0 -119
  118. package/task-lib/tui.js +0 -384
  119. package/task-lib/watcher.js +0 -188
@@ -1,1431 +0,0 @@
1
- // @ts-nocheck
2
- /**
3
- * AgentTaskExecutor - Claude CLI spawning and monitoring
4
- *
5
- * Provides:
6
- * - Claude CLI task spawning (normal and isolated modes)
7
- * - Log streaming and real-time output broadcasting
8
- * - Task lifecycle management (wait, kill)
9
- * - Output parsing and validation
10
- * - Vibe-specific Claude config with AskUserQuestion blocked
11
- */
12
-
13
- const { spawn } = require('child_process');
14
- const path = require('path');
15
- const fs = require('fs');
16
- const os = require('os');
17
- const { getProvider, parseChunkWithProvider } = require('../providers');
18
-
19
- // Schema utilities for normalizing LLM output
20
- const { normalizeEnumValues } = require('./schema-utils');
21
-
22
- /**
23
- * Validate and sanitize error messages.
24
- * Detects TypeScript type annotations that may have leaked into error storage.
25
- *
26
- * @param {string|null} error - Error message to validate
27
- * @returns {string|null} Sanitized error or original if valid
28
- */
29
- function sanitizeErrorMessage(error) {
30
- if (!error) return null;
31
-
32
- // Patterns that look like TypeScript type annotations (not real error messages)
33
- const typeAnnotationPatterns = [
34
- /^string\s*\|\s*null$/i,
35
- /^number\s*\|\s*undefined$/i,
36
- /^boolean\s*\|\s*null$/i,
37
- /^any$/i,
38
- /^unknown$/i,
39
- /^void$/i,
40
- /^never$/i,
41
- /^[A-Z][a-zA-Z]*\s*\|\s*(null|undefined)$/, // e.g., "Error | null"
42
- /^[a-z]+(\s*\|\s*[a-z]+)+$/i, // e.g., "string | number | boolean"
43
- ];
44
-
45
- for (const pattern of typeAnnotationPatterns) {
46
- if (pattern.test(error.trim())) {
47
- console.warn(
48
- `[agent-task-executor] WARNING: Error message looks like a TypeScript type annotation: "${error}". ` +
49
- `This indicates corrupted data. Replacing with generic error.`
50
- );
51
- return `Task failed with corrupted error data (original: "${error}")`;
52
- }
53
- }
54
-
55
- return error;
56
- }
57
-
58
- /**
59
- * Extract error context from task output.
60
- * Shared by both isolated and non-isolated modes.
61
- *
62
- * @param {Object} params - Extraction parameters
63
- * @param {string} params.output - Full task output
64
- * @param {string} [params.statusOutput] - Status command output (non-isolated only)
65
- * @param {string} params.taskId - Task ID for error messages
66
- * @param {boolean} [params.isNotFound=false] - True if task was not found
67
- * @returns {string|null} Sanitized error context or null if extraction failed
68
- */
69
- function extractErrorContext({ output, statusOutput, taskId, isNotFound = false }) {
70
- // Task not found - explicit error
71
- if (isNotFound) {
72
- return sanitizeErrorMessage(`Task ${taskId} not found (may have crashed or been killed)`);
73
- }
74
-
75
- // Try status output first (only available in non-isolated mode)
76
- if (statusOutput) {
77
- const statusErrorMatch = statusOutput.match(/Error:\s*(.+)/);
78
- if (statusErrorMatch) {
79
- return sanitizeErrorMessage(statusErrorMatch[1].trim());
80
- }
81
- }
82
-
83
- // KNOWN CLAUDE CODE LIMITATIONS - detect and provide actionable guidance
84
- const fullOutput = output || '';
85
-
86
- // 256KB file limit error
87
- if (fullOutput.includes('exceeds maximum allowed size') || fullOutput.includes('256KB')) {
88
- return sanitizeErrorMessage(
89
- `FILE TOO LARGE (Claude Code 256KB limit). ` +
90
- `Use offset and limit parameters when reading large files. ` +
91
- `Example: Read tool with offset=0, limit=1000 to read first 1000 lines.`
92
- );
93
- }
94
-
95
- // Streaming mode error (interactive tools in non-interactive mode)
96
- if (fullOutput.includes('only prompt commands are supported in streaming mode')) {
97
- return sanitizeErrorMessage(
98
- `STREAMING MODE ERROR: Agent tried to use interactive tools in streaming mode. ` +
99
- `This usually happens with AskUserQuestion or interactive prompts. ` +
100
- `Zeroshot agents must run non-interactively.`
101
- );
102
- }
103
-
104
- // Fall back to extracting from output (last 500 chars)
105
- const lastOutput = fullOutput.slice(-500).trim();
106
- if (!lastOutput) {
107
- return sanitizeErrorMessage(
108
- 'Task failed with no output (check if task was interrupted or timed out)'
109
- );
110
- }
111
-
112
- // Extract non-JSON lines only (JSON lines contain "is_error": true which falsely matches)
113
- const nonJsonLines = lastOutput
114
- .split('\n')
115
- .filter((line) => {
116
- const trimmed = line.trim();
117
- // Skip JSON objects and JSON-like content
118
- return trimmed && !trimmed.startsWith('{') && !trimmed.startsWith('"');
119
- })
120
- .join('\n');
121
-
122
- // Common error patterns - match against non-JSON content
123
- const textToSearch = nonJsonLines || lastOutput;
124
- const errorPatterns = [
125
- /Error:\s*(.+)/i,
126
- /error:\s*(.+)/i,
127
- /failed:\s*(.+)/i,
128
- /Exception:\s*(.+)/i,
129
- /panic:\s*(.+)/i,
130
- ];
131
-
132
- for (const pattern of errorPatterns) {
133
- const match = textToSearch.match(pattern);
134
- if (match) {
135
- return sanitizeErrorMessage(match[1].slice(0, 200));
136
- }
137
- }
138
-
139
- // No pattern matched - include last portion of output
140
- return sanitizeErrorMessage(`Task failed. Last output: ${lastOutput.slice(-200)}`);
141
- }
142
-
143
- // Track if we've already ensured the AskUserQuestion hook is installed
144
- let askUserQuestionHookInstalled = false;
145
-
146
- // Track if we've already ensured the dangerous git hook is installed
147
- let dangerousGitHookInstalled = false;
148
-
149
- /**
150
- * Extract token usage from NDJSON output.
151
- * Looks for the 'result' event line which contains usage data.
152
- *
153
- * @param {string} output - Full NDJSON output from Claude CLI
154
- * @returns {Object|null} Token usage data or null if not found
155
- */
156
- function extractTokenUsage(output, providerName = 'claude') {
157
- if (!output) return null;
158
-
159
- const provider = getProvider(providerName);
160
- const events = parseChunkWithProvider(provider, output);
161
- const resultEvent = events.find((event) => event.type === 'result');
162
-
163
- if (!resultEvent) {
164
- return null;
165
- }
166
-
167
- return {
168
- inputTokens: resultEvent.inputTokens || 0,
169
- outputTokens: resultEvent.outputTokens || 0,
170
- cacheReadInputTokens: resultEvent.cacheReadInputTokens || 0,
171
- cacheCreationInputTokens: resultEvent.cacheCreationInputTokens || 0,
172
- totalCostUsd: resultEvent.cost || null,
173
- durationMs: resultEvent.duration || null,
174
- modelUsage: resultEvent.modelUsage || null,
175
- };
176
- }
177
-
178
- /**
179
- * Ensure the AskUserQuestion blocking hook is installed in user's Claude config.
180
- * This adds defense-in-depth by blocking the tool at the Claude CLI level.
181
- * Modifies ~/.claude/settings.json and copies hook script to ~/.claude/hooks/
182
- *
183
- * Safe to call multiple times - only modifies config once per process.
184
- */
185
- function ensureAskUserQuestionHook() {
186
- if (askUserQuestionHookInstalled) {
187
- return; // Already installed this session
188
- }
189
-
190
- const userClaudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
191
- const hooksDir = path.join(userClaudeDir, 'hooks');
192
- const settingsPath = path.join(userClaudeDir, 'settings.json');
193
- const hookScriptName = 'block-ask-user-question.py';
194
- const hookScriptDst = path.join(hooksDir, hookScriptName);
195
-
196
- // Ensure hooks directory exists
197
- if (!fs.existsSync(hooksDir)) {
198
- fs.mkdirSync(hooksDir, { recursive: true });
199
- }
200
-
201
- // Copy hook script if not present or outdated
202
- const hookScriptSrc = path.join(__dirname, '..', '..', 'hooks', hookScriptName);
203
- if (fs.existsSync(hookScriptSrc)) {
204
- // Always copy to ensure latest version
205
- fs.copyFileSync(hookScriptSrc, hookScriptDst);
206
- fs.chmodSync(hookScriptDst, 0o755);
207
- }
208
-
209
- // Read existing settings or create new
210
- let settings = {};
211
- if (fs.existsSync(settingsPath)) {
212
- try {
213
- settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
214
- } catch (e) {
215
- console.warn(`[AgentTaskExecutor] Could not parse settings.json, creating new: ${e.message}`);
216
- settings = {};
217
- }
218
- }
219
-
220
- // Ensure hooks structure exists
221
- if (!settings.hooks) {
222
- settings.hooks = {};
223
- }
224
- if (!settings.hooks.PreToolUse) {
225
- settings.hooks.PreToolUse = [];
226
- }
227
-
228
- // Check if AskUserQuestion hook already exists
229
- const hasHook = settings.hooks.PreToolUse.some(
230
- (entry) =>
231
- entry.matcher === 'AskUserQuestion' ||
232
- (entry.hooks && entry.hooks.some((h) => h.command && h.command.includes(hookScriptName)))
233
- );
234
-
235
- if (!hasHook) {
236
- // Add the hook
237
- settings.hooks.PreToolUse.push({
238
- matcher: 'AskUserQuestion',
239
- hooks: [
240
- {
241
- type: 'command',
242
- command: hookScriptDst,
243
- },
244
- ],
245
- });
246
-
247
- // Write updated settings
248
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
249
- console.log(`[AgentTaskExecutor] Installed AskUserQuestion blocking hook in ${settingsPath}`);
250
- }
251
-
252
- askUserQuestionHookInstalled = true;
253
- }
254
-
255
- /**
256
- * Ensure the dangerous git blocking hook is installed in user's Claude config.
257
- * This blocks dangerous git commands like stash, checkout --, reset --hard, etc.
258
- * Modifies ~/.claude/settings.json and copies hook script to ~/.claude/hooks/
259
- *
260
- * Only used in worktree mode - Docker isolation mode has its own git-safe.sh wrapper.
261
- * Safe to call multiple times - only modifies config once per process.
262
- */
263
- function ensureDangerousGitHook() {
264
- if (dangerousGitHookInstalled) {
265
- return; // Already installed this session
266
- }
267
-
268
- const userClaudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
269
- const hooksDir = path.join(userClaudeDir, 'hooks');
270
- const settingsPath = path.join(userClaudeDir, 'settings.json');
271
- const hookScriptName = 'block-dangerous-git.py';
272
- const hookScriptDst = path.join(hooksDir, hookScriptName);
273
-
274
- // Ensure hooks directory exists
275
- if (!fs.existsSync(hooksDir)) {
276
- fs.mkdirSync(hooksDir, { recursive: true });
277
- }
278
-
279
- // Copy hook script if not present or outdated
280
- const hookScriptSrc = path.join(__dirname, '..', '..', 'hooks', hookScriptName);
281
- if (fs.existsSync(hookScriptSrc)) {
282
- // Always copy to ensure latest version
283
- fs.copyFileSync(hookScriptSrc, hookScriptDst);
284
- fs.chmodSync(hookScriptDst, 0o755);
285
- }
286
-
287
- // Read existing settings or create new
288
- let settings = {};
289
- if (fs.existsSync(settingsPath)) {
290
- try {
291
- settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
292
- } catch (e) {
293
- console.warn(`[AgentTaskExecutor] Could not parse settings.json, creating new: ${e.message}`);
294
- settings = {};
295
- }
296
- }
297
-
298
- // Ensure hooks structure exists
299
- if (!settings.hooks) {
300
- settings.hooks = {};
301
- }
302
- if (!settings.hooks.PreToolUse) {
303
- settings.hooks.PreToolUse = [];
304
- }
305
-
306
- // Check if dangerous git hook already exists
307
- const hasHook = settings.hooks.PreToolUse.some(
308
- (entry) =>
309
- entry.matcher === 'Bash' &&
310
- entry.hooks &&
311
- entry.hooks.some((h) => h.command && h.command.includes(hookScriptName))
312
- );
313
-
314
- if (!hasHook) {
315
- // Add the hook - matches Bash tool to check for dangerous git commands
316
- settings.hooks.PreToolUse.push({
317
- matcher: 'Bash',
318
- hooks: [
319
- {
320
- type: 'command',
321
- command: hookScriptDst,
322
- },
323
- ],
324
- });
325
-
326
- // Write updated settings
327
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
328
- console.log(`[AgentTaskExecutor] Installed dangerous git blocking hook in ${settingsPath}`);
329
- }
330
-
331
- dangerousGitHookInstalled = true;
332
- }
333
-
334
- /**
335
- * Spawn claude-zeroshots process and stream output via message bus
336
- * @param {Object} agent - Agent instance
337
- * @param {String} context - Context to pass to Claude
338
- * @returns {Promise<Object>} Result object { success, output, error }
339
- */
340
- async function spawnClaudeTask(agent, context) {
341
- const providerName = agent._resolveProvider ? agent._resolveProvider() : 'claude';
342
- const modelSpec = agent._resolveModelSpec
343
- ? agent._resolveModelSpec()
344
- : { model: agent._selectModel() };
345
-
346
- const ctPath = getClaudeTasksPath();
347
- const cwd = agent.config.cwd || process.cwd();
348
-
349
- // Build zeroshot task run args.
350
- // CRITICAL: Default to strict schema validation to prevent cluster crashes from parse failures
351
- // strictSchema=true uses Claude CLI's native --json-schema enforcement (no streaming but guaranteed structure)
352
- // strictSchema=false uses stream-json with post-run validation (live logs but fragile)
353
- const desiredOutputFormat = agent.config.outputFormat || 'json';
354
- const strictSchema = agent.config.strictSchema !== false; // DEFAULT TO TRUE
355
- const runOutputFormat =
356
- agent.config.jsonSchema && desiredOutputFormat === 'json' && !strictSchema
357
- ? 'stream-json'
358
- : desiredOutputFormat;
359
- const args = ['task', 'run', '--output-format', runOutputFormat, '--provider', providerName];
360
-
361
- if (modelSpec?.model) {
362
- args.push('--model', modelSpec.model);
363
- }
364
-
365
- if (modelSpec?.reasoningEffort) {
366
- args.push('--reasoning-effort', modelSpec.reasoningEffort);
367
- }
368
-
369
- // Add verification mode flag if configured
370
- if (agent.config.verificationMode) {
371
- args.push('-v');
372
- }
373
-
374
- // NOTE: maxRetries is handled by the agent wrapper's internal retry loop,
375
- // not passed to the CLI. See _handleTrigger() for retry logic.
376
-
377
- // Add JSON schema if specified in agent config.
378
- // If we are running stream-json for live logs (strictSchema=false), do NOT pass schema to CLI.
379
- if (agent.config.jsonSchema) {
380
- if (runOutputFormat === 'json') {
381
- // strictSchema=true OR no schema conflict: pass schema to CLI for native enforcement
382
- const schema = JSON.stringify(agent.config.jsonSchema);
383
- args.push('--json-schema', schema);
384
- } else if (!agent.quiet) {
385
- agent._log(
386
- `[Agent ${agent.id}] jsonSchema configured; running stream-json for live logs (strictSchema=false). Schema will be validated after completion.`
387
- );
388
- }
389
- }
390
-
391
- // If schema enforcement is desired but we had to run stream-json for live logs,
392
- // add explicit output instructions so the model still knows the required shape.
393
- let finalContext = context;
394
- if (
395
- agent.config.jsonSchema &&
396
- desiredOutputFormat === 'json' &&
397
- runOutputFormat === 'stream-json'
398
- ) {
399
- finalContext += `\n\n## Output Format (REQUIRED)\n\nReturn a JSON object that matches this schema exactly.\n\nSchema:\n\`\`\`json\n${JSON.stringify(
400
- agent.config.jsonSchema,
401
- null,
402
- 2
403
- )}\n\`\`\`\n`;
404
- }
405
-
406
- args.push(finalContext);
407
-
408
- // MOCK SUPPORT: Use injected mock function if provided
409
- if (agent.mockSpawnFn) {
410
- return agent.mockSpawnFn(args, { context });
411
- }
412
-
413
- // SAFETY: Fail hard if testMode=true but no mock (should be caught in constructor)
414
- if (agent.testMode) {
415
- throw new Error(
416
- `AgentWrapper: testMode=true but attempting real Claude API call for agent '${agent.id}'. ` +
417
- `This is a bug - mock should be set in constructor.`
418
- );
419
- }
420
-
421
- // ISOLATION MODE: Run inside Docker container
422
- if (agent.isolation?.enabled) {
423
- return spawnClaudeTaskIsolated(agent, context);
424
- }
425
-
426
- // NON-ISOLATION MODE: For Claude, use user's existing Claude config
427
- // AskUserQuestion blocking handled via:
428
- // 1. Prompt injection (see agent-context-builder)
429
- // 2. PreToolUse hook (defense-in-depth) - activated by ZEROSHOT_BLOCK_ASK_USER env var
430
- if (providerName === 'claude') {
431
- ensureAskUserQuestionHook();
432
-
433
- // WORKTREE MODE: Install git safety hook (blocks dangerous git commands)
434
- if (agent.worktree?.enabled) {
435
- ensureDangerousGitHook();
436
- }
437
- }
438
-
439
- // Build environment for spawn
440
- const spawnEnv = {
441
- ...process.env,
442
- };
443
-
444
- if (providerName === 'claude') {
445
- if (modelSpec?.model) {
446
- spawnEnv.ANTHROPIC_MODEL = modelSpec.model;
447
- }
448
- // Activate AskUserQuestion blocking hook (see hooks/block-ask-user-question.py)
449
- spawnEnv.ZEROSHOT_BLOCK_ASK_USER = '1';
450
-
451
- // WORKTREE MODE: Activate git safety hook via environment variable
452
- if (agent.worktree?.enabled) {
453
- spawnEnv.ZEROSHOT_WORKTREE = '1';
454
- }
455
- }
456
-
457
- const taskId = await new Promise((resolve, reject) => {
458
- const proc = spawn(ctPath, args, {
459
- cwd,
460
- stdio: ['ignore', 'pipe', 'pipe'],
461
- env: spawnEnv,
462
- });
463
- // Track PID for resource monitoring
464
- agent.processPid = proc.pid;
465
- agent._publishLifecycle('PROCESS_SPAWNED', { pid: proc.pid });
466
-
467
- let stdout = '';
468
- let stderr = '';
469
-
470
- proc.stdout.on('data', (data) => {
471
- stdout += data.toString();
472
- });
473
-
474
- proc.stderr.on('data', (data) => {
475
- stderr += data.toString();
476
- });
477
-
478
- proc.on('close', (code, signal) => {
479
- // Handle process killed by signal (e.g., SIGTERM, SIGKILL, SIGSTOP)
480
- if (signal) {
481
- reject(new Error(`Process killed by signal ${signal}${stderr ? `: ${stderr}` : ''}`));
482
- return;
483
- }
484
-
485
- if (code === 0) {
486
- // Parse task ID from output: "✓ Task spawned: xxx-yyy-nn"
487
- // Format: <adjective>-<noun>-<digits> (may or may not have task- prefix)
488
- const match = stdout.match(/Task spawned: ((?:task-)?[a-z]+-[a-z]+-[a-z0-9]+)/);
489
- if (match) {
490
- const spawnedTaskId = match[1];
491
- agent.currentTaskId = spawnedTaskId; // Track for resume capability
492
- agent._publishLifecycle('TASK_ID_ASSIGNED', {
493
- pid: agent.processPid,
494
- taskId: spawnedTaskId,
495
- });
496
-
497
- // Start liveness monitoring
498
- if (agent.enableLivenessCheck) {
499
- agent.lastOutputTime = Date.now(); // Initialize to spawn time
500
- agent._startLivenessCheck();
501
- }
502
-
503
- resolve(spawnedTaskId);
504
- } else {
505
- reject(new Error(`Could not parse task ID from output: ${stdout}`));
506
- }
507
- } else {
508
- reject(new Error(`zeroshot task run failed with code ${code}: ${stderr}`));
509
- }
510
- });
511
-
512
- proc.on('error', (error) => {
513
- reject(error);
514
- });
515
- });
516
-
517
- agent._log(`📋 Agent ${agent.id}: Following zeroshot logs for ${taskId}`);
518
-
519
- // Wait for task to be registered in zeroshot storage (race condition fix)
520
- await waitForTaskReady(agent, taskId);
521
-
522
- // Now follow the logs and stream output
523
- return followClaudeTaskLogs(agent, taskId);
524
- }
525
-
526
- /**
527
- * Wait for task to be registered in ct storage
528
- * @param {Object} agent - Agent instance
529
- * @param {String} taskId - Task ID to wait for
530
- * @param {Number} maxRetries - Max retries (default 10)
531
- * @param {Number} delayMs - Delay between retries (default 200)
532
- * @returns {Promise<void>}
533
- */
534
- async function waitForTaskReady(agent, taskId, maxRetries = 10, delayMs = 200) {
535
- const { exec } = require('child_process');
536
- const ctPath = getClaudeTasksPath();
537
-
538
- for (let i = 0; i < maxRetries; i++) {
539
- const exists = await new Promise((resolve) => {
540
- exec(`${ctPath} status ${taskId}`, (error, stdout) => {
541
- // Task exists if status doesn't return "Task not found"
542
- resolve(!error && !stdout.includes('Task not found'));
543
- });
544
- });
545
-
546
- if (exists) return;
547
-
548
- // Wait before retry
549
- await new Promise((r) => setTimeout(r, delayMs));
550
- }
551
-
552
- // Continue anyway after max retries - the task may still work
553
- console.warn(`⚠️ Task ${taskId} not yet visible after ${maxRetries} retries, continuing anyway`);
554
- }
555
-
556
- /**
557
- * Follow claude-zeroshots logs until completion, streaming to message bus
558
- * Reads log file directly for reliable streaming
559
- * @param {Object} agent - Agent instance
560
- * @param {String} taskId - Task ID to follow
561
- * @returns {Promise<Object>} Result object { success, output, error }
562
- */
563
- function followClaudeTaskLogs(agent, taskId) {
564
- const fsModule = require('fs');
565
- const { execSync, exec } = require('child_process');
566
- const ctPath = getClaudeTasksPath();
567
- const providerName = agent._resolveProvider ? agent._resolveProvider() : 'claude';
568
-
569
- return new Promise((resolve, _reject) => {
570
- let output = '';
571
- let logFilePath = null;
572
- let lastSize = 0;
573
- let pollInterval = null;
574
- let statusCheckInterval = null;
575
- let resolved = false;
576
-
577
- // Get log file path from ct
578
- try {
579
- logFilePath = execSync(`${ctPath} get-log-path ${taskId}`, {
580
- encoding: 'utf-8',
581
- }).trim();
582
- agent._log(`📋 Agent ${agent.id}: Following ct logs for ${taskId}`);
583
- } catch {
584
- // Task might not have log file yet, wait and retry
585
- agent._log(`⏳ Agent ${agent.id}: Waiting for log file...`);
586
- }
587
-
588
- // Buffer for incomplete lines across polls
589
- let lineBuffer = '';
590
-
591
- // Broadcast a complete JSON line as one message
592
- // Lines are now prefixed with timestamps: [1733301234567]{json...}
593
- const broadcastLine = (line) => {
594
- if (!line.trim()) return;
595
-
596
- // Parse timestamp prefix if present: [epochMs]content
597
- // IMPORTANT: Trim \r from CRLF line endings before matching
598
- let timestamp = Date.now();
599
- let content = line.replace(/\r$/, '');
600
-
601
- const timestampMatch = content.match(/^\[(\d{13})\](.*)$/);
602
- if (timestampMatch) {
603
- timestamp = parseInt(timestampMatch[1], 10);
604
- content = timestampMatch[2];
605
- }
606
-
607
- // Skip known noise patterns (footer, separators, metadata)
608
- if (
609
- content.startsWith('===') ||
610
- content.startsWith('Finished:') ||
611
- content.startsWith('Exit code:') ||
612
- (content.includes('"type":"system"') && content.includes('"subtype":"init"'))
613
- ) {
614
- return;
615
- }
616
-
617
- // Determine if this is JSON or plain text output
618
- const isJson = content.trim().startsWith('{');
619
- let isValidJson = false;
620
-
621
- if (isJson) {
622
- try {
623
- JSON.parse(content);
624
- isValidJson = true;
625
- } catch {
626
- // Looks like JSON but isn't valid - treat as text
627
- }
628
- }
629
-
630
- // Accumulate output (JSON for parsing, text for human visibility)
631
- output += content + '\n';
632
-
633
- // Update liveness timestamp
634
- agent.lastOutputTime = Date.now();
635
-
636
- agent._publish({
637
- topic: 'AGENT_OUTPUT',
638
- receiver: 'broadcast',
639
- timestamp, // Use the actual timestamp from when output was produced
640
- content: {
641
- text: content,
642
- data: {
643
- type: isValidJson ? 'json' : 'text',
644
- line: content,
645
- agent: agent.id,
646
- role: agent.role,
647
- iteration: agent.iteration,
648
- provider: providerName,
649
- },
650
- },
651
- });
652
- };
653
-
654
- // Process new content by splitting into complete lines
655
- const processNewContent = (content) => {
656
- // Add to buffer
657
- lineBuffer += content;
658
-
659
- // Split by newlines
660
- const lines = lineBuffer.split('\n');
661
-
662
- // Process all complete lines (all except last, which might be incomplete)
663
- for (let i = 0; i < lines.length - 1; i++) {
664
- broadcastLine(lines[i]);
665
- }
666
-
667
- // Keep last line in buffer (might be incomplete)
668
- lineBuffer = lines[lines.length - 1];
669
- };
670
-
671
- // Poll the log file for new content
672
- const pollLogFile = () => {
673
- // If we don't have log path yet, try to get it
674
- if (!logFilePath) {
675
- try {
676
- logFilePath = execSync(`${ctPath} get-log-path ${taskId}`, {
677
- encoding: 'utf-8',
678
- }).trim();
679
- agent._log(`📋 Agent ${agent.id}: Found log file: ${logFilePath}`);
680
- } catch {
681
- return; // Not ready yet
682
- }
683
- }
684
-
685
- // Check if file exists
686
- if (!fsModule.existsSync(logFilePath)) {
687
- return; // File not created yet
688
- }
689
-
690
- try {
691
- const stats = fsModule.statSync(logFilePath);
692
- const currentSize = stats.size;
693
-
694
- if (currentSize > lastSize) {
695
- // Read new content
696
- const fd = fsModule.openSync(logFilePath, 'r');
697
- const buffer = Buffer.alloc(currentSize - lastSize);
698
- fsModule.readSync(fd, buffer, 0, buffer.length, lastSize);
699
- fsModule.closeSync(fd);
700
-
701
- const newContent = buffer.toString('utf-8');
702
- // Process new content line-by-line
703
- processNewContent(newContent);
704
- lastSize = currentSize;
705
- }
706
- } catch (err) {
707
- // File might have been deleted or locked
708
- console.warn(`⚠️ Agent ${agent.id}: Error reading log: ${err.message}`);
709
- }
710
- };
711
-
712
- // Start polling log file (every 300ms for responsive streaming)
713
- pollInterval = setInterval(pollLogFile, 300);
714
-
715
- // Poll ct status to know when task is complete
716
- // Track consecutive failures for debugging stuck clusters
717
- let consecutiveExecFailures = 0;
718
- const MAX_CONSECUTIVE_FAILURES = 30; // 30 seconds of failures = log warning
719
-
720
- statusCheckInterval = setInterval(() => {
721
- exec(`${ctPath} status ${taskId}`, (error, stdout, stderr) => {
722
- if (resolved) return;
723
-
724
- // Track exec failures - if status command keeps failing, something is wrong
725
- if (error) {
726
- consecutiveExecFailures++;
727
- if (consecutiveExecFailures >= MAX_CONSECUTIVE_FAILURES) {
728
- console.error(
729
- `[Agent ${agent.id}] ⚠️ Status polling failed ${MAX_CONSECUTIVE_FAILURES} times consecutively! STOPPING.`
730
- );
731
- console.error(` Command: ${ctPath} status ${taskId}`);
732
- console.error(` Error: ${error.message}`);
733
- console.error(` Stderr: ${stderr || 'none'}`);
734
- console.error(
735
- ` This may indicate zeroshot is not in PATH or task storage is corrupted.`
736
- );
737
-
738
- // Stop polling and resolve with failure
739
- if (!resolved) {
740
- resolved = true;
741
- clearInterval(pollInterval);
742
- clearInterval(statusCheckInterval);
743
- agent.currentTask = null;
744
-
745
- // Publish error for orchestrator/resume
746
- agent._publish({
747
- topic: 'AGENT_ERROR',
748
- receiver: 'broadcast',
749
- content: {
750
- text: `Task ${taskId} polling failed after ${MAX_CONSECUTIVE_FAILURES} consecutive failures`,
751
- data: {
752
- taskId,
753
- error: 'polling_timeout',
754
- attempts: consecutiveExecFailures,
755
- role: agent.role,
756
- iteration: agent.iteration,
757
- },
758
- },
759
- });
760
-
761
- resolve({
762
- success: false,
763
- output,
764
- error: `Status polling failed ${MAX_CONSECUTIVE_FAILURES} times - task may not exist`,
765
- });
766
- }
767
- return;
768
- }
769
- return; // Keep polling - might be transient
770
- }
771
-
772
- // Reset failure counter on success
773
- consecutiveExecFailures = 0;
774
-
775
- // Check for completion/failure status
776
- // Strip ANSI codes in case chalk outputs them (shouldn't in non-TTY, but be safe)
777
- // Use RegExp constructor to avoid ESLint no-control-regex false positive
778
- const ansiPattern = new RegExp(String.fromCharCode(27) + '\\[[0-9;]*m', 'g');
779
- const cleanStdout = stdout.replace(ansiPattern, '');
780
- // Use flexible whitespace matching in case spacing changes
781
- const isCompleted = /Status:\s+completed/i.test(cleanStdout);
782
- const isFailed = /Status:\s+failed/i.test(cleanStdout);
783
- // BUGFIX: Handle "stale (process died)" status - watcher died before updating status
784
- // Check if task produced a successful result (structured_output in log file)
785
- const isStale = /Status:\s+stale/i.test(cleanStdout);
786
-
787
- if (isCompleted || isFailed || isStale) {
788
- // CRITICAL: Read final log content BEFORE checking output
789
- // Fixes race where status flips to stale before log polling catches up
790
- pollLogFile();
791
-
792
- // For stale tasks, check log file for successful result
793
- let success = isCompleted;
794
- if (isStale && output) {
795
- // Look for structured_output in accumulated output - indicates success
796
- const hasStructuredOutput = /"structured_output"\s*:/.test(output);
797
- const hasSuccessResult = /"subtype"\s*:\s*"success"/.test(output);
798
- let hasParsedOutput = false;
799
- try {
800
- const { extractJsonFromOutput } = require('./output-extraction');
801
- hasParsedOutput = !!extractJsonFromOutput(output, providerName);
802
- } catch {
803
- // Ignore extraction errors - fallback to other signals
804
- }
805
- success = hasStructuredOutput || hasSuccessResult || hasParsedOutput;
806
- if (!agent.quiet) {
807
- agent._log(
808
- `[Agent ${agent.id}] Task ${taskId} is stale - recovered as ${success ? 'SUCCESS' : 'FAILURE'} based on output analysis`
809
- );
810
- }
811
- }
812
-
813
- // Clean up and resolve
814
- setTimeout(() => {
815
- if (resolved) return;
816
- resolved = true;
817
-
818
- clearInterval(pollInterval);
819
- clearInterval(statusCheckInterval);
820
- agent.currentTask = null;
821
-
822
- // Extract error context using shared helper
823
- const errorContext = !success
824
- ? extractErrorContext({ output, statusOutput: stdout, taskId })
825
- : null;
826
-
827
- resolve({
828
- success,
829
- output,
830
- error: errorContext,
831
- tokenUsage: extractTokenUsage(output, providerName),
832
- });
833
- }, 500);
834
- }
835
- });
836
- }, 1000);
837
-
838
- // Store cleanup function for kill
839
- // CRITICAL: Must reject promise to avoid orphaned promise that hangs forever
840
- agent.currentTask = {
841
- kill: (reason = 'Task killed') => {
842
- if (resolved) return;
843
- resolved = true;
844
- clearInterval(pollInterval);
845
- clearInterval(statusCheckInterval);
846
- agent._stopLivenessCheck();
847
- // BUGFIX: Resolve with failure instead of orphaning the promise
848
- // This allows the caller to handle the kill gracefully
849
- resolve({
850
- success: false,
851
- output,
852
- error: reason,
853
- tokenUsage: extractTokenUsage(output, providerName),
854
- });
855
- },
856
- };
857
-
858
- // REMOVED: Task timeout disabled - tasks run until completion or explicit kill
859
- // Tasks should run until:
860
- // - Completion
861
- // - Explicit kill
862
- // - External error (rate limit, API failure)
863
- //
864
- // setTimeout(() => {
865
- // if (resolved) return;
866
- // resolved = true;
867
- //
868
- // clearInterval(pollInterval);
869
- // clearInterval(statusCheckInterval);
870
- // agent._stopLivenessCheck();
871
- // agent.currentTask = null;
872
- // const timeoutMinutes = Math.round(agent.timeout / 60000);
873
- // reject(new Error(`Task timed out after ${timeoutMinutes} minutes`));
874
- // }, agent.timeout);
875
- });
876
- }
877
-
878
- /**
879
- * Get path to claude-zeroshots executable
880
- * @returns {String} Path to zeroshot command
881
- */
882
- function getClaudeTasksPath() {
883
- // Use zeroshot command (unified CLI)
884
- return 'zeroshot'; // Assumes zeroshot is installed globally
885
- }
886
-
887
- /**
888
- * Spawn claude-zeroshots inside Docker container (isolation mode)
889
- * Runs Claude CLI inside the container for full isolation
890
- * @param {Object} agent - Agent instance
891
- * @param {String} context - Context to pass to Claude
892
- * @returns {Promise<Object>} Result object { success, output, error }
893
- */
894
- async function spawnClaudeTaskIsolated(agent, context) {
895
- const { manager, clusterId } = agent.isolation;
896
- const providerName = agent._resolveProvider ? agent._resolveProvider() : 'claude';
897
- const modelSpec = agent._resolveModelSpec
898
- ? agent._resolveModelSpec()
899
- : { model: agent._selectModel() };
900
-
901
- agent._log(`📦 Agent ${agent.id}: Running task in isolated container using zeroshot task run...`);
902
-
903
- // Build zeroshot task run command (same infrastructure as non-isolation mode)
904
- // CRITICAL: Default to strict schema validation to prevent cluster crashes from parse failures
905
- const desiredOutputFormat = agent.config.outputFormat || 'json';
906
- const strictSchema = agent.config.strictSchema !== false; // DEFAULT TO TRUE
907
- const runOutputFormat =
908
- agent.config.jsonSchema && desiredOutputFormat === 'json' && !strictSchema
909
- ? 'stream-json'
910
- : desiredOutputFormat;
911
-
912
- const command = [
913
- 'zeroshot',
914
- 'task',
915
- 'run',
916
- '--output-format',
917
- runOutputFormat,
918
- '--provider',
919
- providerName,
920
- ];
921
-
922
- if (modelSpec?.model) {
923
- command.push('--model', modelSpec.model);
924
- }
925
-
926
- if (modelSpec?.reasoningEffort) {
927
- command.push('--reasoning-effort', modelSpec.reasoningEffort);
928
- }
929
-
930
- // Add verification mode flag if configured
931
- if (agent.config.verificationMode) {
932
- command.push('-v');
933
- }
934
-
935
- // Add JSON schema if specified in agent config
936
- // If we are running stream-json for live logs (strictSchema=false), do NOT pass schema to CLI
937
- if (agent.config.jsonSchema) {
938
- if (runOutputFormat === 'json') {
939
- // strictSchema=true OR no schema conflict: pass schema to CLI for native enforcement
940
- const schema = JSON.stringify(agent.config.jsonSchema);
941
- command.push('--json-schema', schema);
942
- } else if (!agent.quiet) {
943
- agent._log(
944
- `[Agent ${agent.id}] jsonSchema configured; running stream-json for live logs (strictSchema=false). Schema will be validated after completion.`
945
- );
946
- }
947
- }
948
-
949
- // Add explicit output instructions when we run stream-json for a jsonSchema agent
950
- let finalContext = context;
951
- if (
952
- agent.config.jsonSchema &&
953
- desiredOutputFormat === 'json' &&
954
- runOutputFormat === 'stream-json'
955
- ) {
956
- finalContext += `\n\n## Output Format (REQUIRED)\n\nReturn a JSON object that matches this schema exactly.\n\nSchema:\n\`\`\`json\n${JSON.stringify(
957
- agent.config.jsonSchema,
958
- null,
959
- 2
960
- )}\n\`\`\`\n`;
961
- }
962
-
963
- command.push(finalContext);
964
-
965
- // STEP 1: Spawn task and extract task ID (same as non-isolated mode)
966
- const taskId = await new Promise((resolve, reject) => {
967
- const proc = manager.spawnInContainer(clusterId, command, {
968
- env:
969
- providerName === 'claude'
970
- ? {
971
- ANTHROPIC_MODEL: modelSpec?.model,
972
- // Activate AskUserQuestion blocking hook (see hooks/block-ask-user-question.py)
973
- ZEROSHOT_BLOCK_ASK_USER: '1',
974
- }
975
- : {},
976
- });
977
-
978
- // Track PID for resource monitoring
979
- agent.processPid = proc.pid;
980
- agent._publishLifecycle('PROCESS_SPAWNED', { pid: proc.pid });
981
-
982
- let stdout = '';
983
- let stderr = '';
984
-
985
- proc.stdout.on('data', (data) => {
986
- stdout += data.toString();
987
- });
988
-
989
- proc.stderr.on('data', (data) => {
990
- stderr += data.toString();
991
- });
992
-
993
- proc.on('close', (code, signal) => {
994
- // Handle process killed by signal
995
- if (signal) {
996
- reject(new Error(`Process killed by signal ${signal}${stderr ? `: ${stderr}` : ''}`));
997
- return;
998
- }
999
-
1000
- if (code === 0) {
1001
- // Parse task ID from output: "✓ Task spawned: xxx-yyy-nn"
1002
- const match = stdout.match(/Task spawned: ((?:task-)?[a-z]+-[a-z]+-[a-z0-9]+)/);
1003
- if (match) {
1004
- const spawnedTaskId = match[1];
1005
- agent.currentTaskId = spawnedTaskId; // Track for resume capability
1006
- agent._publishLifecycle('TASK_ID_ASSIGNED', {
1007
- pid: agent.processPid,
1008
- taskId: spawnedTaskId,
1009
- });
1010
-
1011
- // Start liveness monitoring
1012
- if (agent.enableLivenessCheck) {
1013
- agent.lastOutputTime = Date.now(); // Initialize to spawn time
1014
- agent._startLivenessCheck();
1015
- }
1016
-
1017
- resolve(spawnedTaskId);
1018
- } else {
1019
- reject(new Error(`Could not parse task ID from output: ${stdout}`));
1020
- }
1021
- } else {
1022
- reject(new Error(`zeroshot task run failed with code ${code}: ${stderr}`));
1023
- }
1024
- });
1025
-
1026
- proc.on('error', (error) => {
1027
- reject(error);
1028
- });
1029
- });
1030
-
1031
- agent._log(`📋 Agent ${agent.id}: Following zeroshot logs for ${taskId} in container...`);
1032
-
1033
- // STEP 2: Follow the task's log file inside container (NOT the spawn stdout!)
1034
- return followClaudeTaskLogsIsolated(agent, taskId);
1035
- }
1036
-
1037
- /**
1038
- * Follow task logs inside Docker container (isolated mode)
1039
- * Reads task log file inside container and streams JSON lines to message bus
1040
- * @param {Object} agent - Agent instance with isolation context
1041
- * @param {String} taskId - Task ID to follow
1042
- * @returns {Promise<Object>} Result object
1043
- * @private
1044
- */
1045
- /**
1046
- * Follow Claude task logs in isolated container using persistent tail -f stream
1047
- * Issue #23: Persistent log streaming instead of polling (10-20% latency reduction)
1048
- *
1049
- * OLD APPROACH (removed):
1050
- * - Polled every 500ms with 2-3 docker exec calls per poll
1051
- * - Each docker exec = ~100-200ms overhead
1052
- * - Total: 300-400ms latency per poll cycle
1053
- *
1054
- * NEW APPROACH:
1055
- * - Single persistent `tail -f` stream via spawnInContainer()
1056
- * - Lines arrive in real-time as they're written
1057
- * - Status checks reduced to every 2 seconds (not every poll)
1058
- * - Result: 10-20% overall latency reduction
1059
- */
1060
- function followClaudeTaskLogsIsolated(agent, taskId) {
1061
- const { isolation } = agent;
1062
- if (!isolation?.manager) {
1063
- throw new Error('followClaudeTaskLogsIsolated: isolation manager not found');
1064
- }
1065
-
1066
- const manager = isolation.manager;
1067
- const clusterId = isolation.clusterId;
1068
- const providerName = agent._resolveProvider ? agent._resolveProvider() : 'claude';
1069
-
1070
- return new Promise((resolve, reject) => {
1071
- let taskExited = false;
1072
- let fullOutput = '';
1073
- let tailProcess = null;
1074
- let statusCheckInterval = null;
1075
- let lineBuffer = '';
1076
-
1077
- // Cleanup function - kill tail process and clear intervals
1078
- const cleanup = () => {
1079
- if (tailProcess) {
1080
- try {
1081
- tailProcess.kill('SIGTERM');
1082
- } catch {
1083
- // Ignore - process may already be dead
1084
- }
1085
- tailProcess = null;
1086
- }
1087
- if (statusCheckInterval) {
1088
- clearInterval(statusCheckInterval);
1089
- statusCheckInterval = null;
1090
- }
1091
- };
1092
-
1093
- // Broadcast line helper (same as non-isolated mode)
1094
- const broadcastLine = (line) => {
1095
- const timestampMatch = line.match(/^\[(\d{4}-\d{2}-\d{2}T[^\]]+)\]\s*(.*)$/);
1096
- const timestamp = timestampMatch ? new Date(timestampMatch[1]).getTime() : Date.now();
1097
- const content = timestampMatch ? timestampMatch[2] : line;
1098
-
1099
- agent.messageBus.publish({
1100
- cluster_id: agent.cluster.id,
1101
- topic: 'AGENT_OUTPUT',
1102
- sender: agent.id,
1103
- content: {
1104
- data: {
1105
- line: content,
1106
- taskId,
1107
- iteration: agent.iteration,
1108
- provider: providerName,
1109
- },
1110
- },
1111
- timestamp,
1112
- });
1113
-
1114
- // Update last output time for liveness tracking
1115
- agent.lastOutputTime = Date.now();
1116
- };
1117
-
1118
- // Process new content by splitting into complete lines
1119
- const processNewContent = (content) => {
1120
- lineBuffer += content;
1121
- const lines = lineBuffer.split('\n');
1122
-
1123
- // Process all complete lines (all except last, which might be incomplete)
1124
- for (let i = 0; i < lines.length - 1; i++) {
1125
- if (lines[i].trim()) {
1126
- broadcastLine(lines[i]);
1127
- }
1128
- }
1129
-
1130
- // Keep last line in buffer (might be incomplete)
1131
- lineBuffer = lines[lines.length - 1];
1132
- };
1133
-
1134
- // Get log file path from zeroshot CLI inside container
1135
- manager
1136
- .execInContainer(clusterId, ['sh', '-c', `zeroshot get-log-path ${taskId}`])
1137
- .then(({ stdout, stderr, code }) => {
1138
- if (code !== 0) {
1139
- cleanup();
1140
- return reject(
1141
- new Error(`Failed to get log path for ${taskId} inside container: ${stderr || stdout}`)
1142
- );
1143
- }
1144
-
1145
- const logFilePath = stdout.trim();
1146
- if (!logFilePath) {
1147
- cleanup();
1148
- return reject(new Error(`Empty log path returned for ${taskId}`));
1149
- }
1150
-
1151
- agent._log(`[${agent.id}] Following isolated task logs (streaming): ${logFilePath}`);
1152
-
1153
- // Start persistent tail -f stream
1154
- // Uses spawnInContainer() which creates a single docker exec process
1155
- // that streams output in real-time (no polling overhead)
1156
- tailProcess = manager.spawnInContainer(clusterId, [
1157
- 'sh',
1158
- '-c',
1159
- // Wait for file to exist, then tail -f from beginning
1160
- // The -F flag handles file recreation (rotation)
1161
- `while [ ! -f "${logFilePath}" ]; do sleep 0.1; done; tail -F -n +1 "${logFilePath}"`,
1162
- ]);
1163
-
1164
- // Stream stdout directly - lines arrive as they're written
1165
- tailProcess.stdout.on('data', (data) => {
1166
- const chunk = data.toString();
1167
- fullOutput += chunk;
1168
- processNewContent(chunk);
1169
- });
1170
-
1171
- // Log stderr but don't fail (tail might emit warnings)
1172
- tailProcess.stderr.on('data', (data) => {
1173
- const msg = data.toString().trim();
1174
- if (msg && !msg.includes('file truncated')) {
1175
- agent._log(`[${agent.id}] tail stderr: ${msg}`);
1176
- }
1177
- });
1178
-
1179
- // Handle tail process exit (shouldn't happen unless killed)
1180
- tailProcess.on('close', (exitCode) => {
1181
- if (!taskExited) {
1182
- agent._log(`[${agent.id}] tail process exited with code ${exitCode}`);
1183
- }
1184
- });
1185
-
1186
- tailProcess.on('error', (err) => {
1187
- agent._log(`[${agent.id}] tail process error: ${err.message}`);
1188
- });
1189
-
1190
- // Check task status periodically (every 2 seconds - much less frequent than polling)
1191
- // This is the only remaining docker exec - but now at 2s intervals instead of 500ms
1192
- statusCheckInterval = setInterval(async () => {
1193
- if (taskExited) return;
1194
-
1195
- try {
1196
- const statusResult = await manager.execInContainer(clusterId, [
1197
- 'sh',
1198
- '-c',
1199
- `zeroshot status ${taskId} 2>/dev/null || echo "not_found"`,
1200
- ]);
1201
-
1202
- const statusOutput = statusResult.stdout;
1203
- const isSuccess = /Status:\s+completed/i.test(statusOutput);
1204
- const isError = /Status:\s+failed/i.test(statusOutput);
1205
- const isNotFound = statusOutput.includes('not_found');
1206
-
1207
- if (isSuccess || isError || isNotFound) {
1208
- taskExited = true;
1209
-
1210
- // Give tail a moment to flush remaining output
1211
- await new Promise((r) => setTimeout(r, 200));
1212
-
1213
- // Read final output to ensure we have everything
1214
- const finalReadResult = await manager.execInContainer(clusterId, [
1215
- 'sh',
1216
- '-c',
1217
- `cat "${logFilePath}" 2>/dev/null || echo ""`,
1218
- ]);
1219
-
1220
- if (finalReadResult.code === 0 && finalReadResult.stdout) {
1221
- fullOutput = finalReadResult.stdout;
1222
-
1223
- // Process any remaining content
1224
- const remainingLines = fullOutput.split('\n');
1225
- for (const line of remainingLines) {
1226
- if (line.trim()) {
1227
- broadcastLine(line);
1228
- }
1229
- }
1230
- }
1231
-
1232
- cleanup();
1233
-
1234
- // Determine success status
1235
- const success = isSuccess && !isError;
1236
-
1237
- // Extract error context using shared helper
1238
- const errorContext = !success
1239
- ? extractErrorContext({ output: fullOutput, taskId, isNotFound })
1240
- : null;
1241
-
1242
- // Parse result from output (async - may trigger reformatting)
1243
- const parsedResult = await agent._parseResultOutput(fullOutput);
1244
-
1245
- resolve({
1246
- success,
1247
- output: fullOutput,
1248
- taskId,
1249
- result: parsedResult,
1250
- error: errorContext,
1251
- tokenUsage: extractTokenUsage(fullOutput, providerName),
1252
- });
1253
- }
1254
- } catch (statusErr) {
1255
- // Log error but continue checking (transient failures are common)
1256
- agent._log(`[${agent.id}] Status check error (will retry): ${statusErr.message}`);
1257
- }
1258
- }, 2000); // Check every 2 seconds (was 500ms in polling mode)
1259
-
1260
- // Safety timeout (0 = no timeout, task runs until completion)
1261
- if (agent.timeout > 0) {
1262
- setTimeout(() => {
1263
- if (!taskExited) {
1264
- cleanup();
1265
- reject(new Error(`Task ${taskId} timeout after ${agent.timeout}ms (isolated mode)`));
1266
- }
1267
- }, agent.timeout);
1268
- }
1269
- })
1270
- .catch((err) => {
1271
- cleanup();
1272
- reject(err);
1273
- });
1274
- });
1275
- }
1276
-
1277
- /**
1278
- * Parse agent output to extract structured result data
1279
- * GENERIC - returns whatever structured output the agent provides
1280
- * Works with any agent schema (planner, validator, worker, etc.)
1281
- *
1282
- * Uses clean extraction pipeline from output-extraction.js
1283
- * Falls back to reformatting if extraction fails and schema is available
1284
- *
1285
- * @param {Object} agent - Agent instance
1286
- * @param {String} output - Raw output from agent
1287
- * @returns {Promise<Object>} Parsed result data
1288
- */
1289
- async function parseResultOutput(agent, output) {
1290
- // Empty or error outputs = FAIL
1291
- if (!output || output.includes('Task not found') || output.includes('Process terminated')) {
1292
- throw new Error('Task execution failed - no output');
1293
- }
1294
-
1295
- const providerName = agent._resolveProvider ? agent._resolveProvider() : 'claude';
1296
- const { extractJsonFromOutput } = require('./output-extraction');
1297
-
1298
- // Use clean extraction pipeline
1299
- let parsed = extractJsonFromOutput(output, providerName);
1300
-
1301
- // If extraction failed but we have a schema, attempt reformatting
1302
- if (!parsed && agent.config.jsonSchema) {
1303
- const { reformatOutput } = require('./output-reformatter');
1304
-
1305
- try {
1306
- parsed = await reformatOutput({
1307
- rawOutput: output,
1308
- schema: agent.config.jsonSchema,
1309
- providerName,
1310
- onAttempt: (attempt, lastError) => {
1311
- if (lastError) {
1312
- console.warn(`[Agent ${agent.id}] Reformat attempt ${attempt}: ${lastError}`);
1313
- } else {
1314
- console.warn(
1315
- `[Agent ${agent.id}] JSON extraction failed, reformatting (attempt ${attempt})...`
1316
- );
1317
- }
1318
- },
1319
- });
1320
- } catch (reformatError) {
1321
- // Reformatting failed - fall through to error below
1322
- console.error(`[Agent ${agent.id}] Reformatting failed: ${reformatError.message}`);
1323
- }
1324
- }
1325
-
1326
- if (!parsed) {
1327
- const trimmedOutput = output.trim();
1328
- console.error(`\n${'='.repeat(80)}`);
1329
- console.error(`🔴 AGENT OUTPUT MISSING REQUIRED JSON BLOCK`);
1330
- console.error(`${'='.repeat(80)}`);
1331
- console.error(`Agent: ${agent.id}, Role: ${agent.role}, Provider: ${providerName}`);
1332
- console.error(`Output (last 500 chars): ${trimmedOutput.slice(-500)}`);
1333
- console.error(`${'='.repeat(80)}\n`);
1334
- throw new Error(`Agent ${agent.id} output missing required JSON block`);
1335
- }
1336
-
1337
- // If a JSON schema is configured, validate parsed output locally.
1338
- // This preserves schema enforcement even when we run stream-json for live logs.
1339
- // IMPORTANT: For non-validator agents we warn but do not fail the cluster.
1340
- if (agent.config.jsonSchema) {
1341
- // Normalize enum values BEFORE validation (handles case mismatches, common variations)
1342
- // This is provider-agnostic - works for Claude CLI, Gemini, Codex, etc.
1343
- normalizeEnumValues(parsed, agent.config.jsonSchema);
1344
-
1345
- const Ajv = require('ajv');
1346
- const ajv = new Ajv({
1347
- allErrors: true,
1348
- strict: false,
1349
- coerceTypes: false, // STRICT: Reject type mismatches (e.g., null instead of array)
1350
- useDefaults: true,
1351
- removeAdditional: true,
1352
- });
1353
- const validate = ajv.compile(agent.config.jsonSchema);
1354
- const valid = validate(parsed);
1355
- if (!valid) {
1356
- const errorList = (validate.errors || [])
1357
- .slice(0, 5)
1358
- .map((e) => `${e.instancePath || e.schemaPath} ${e.message}`)
1359
- .join('; ');
1360
- const msg =
1361
- `Agent ${agent.id} output failed JSON schema validation: ` +
1362
- (errorList || 'unknown schema error');
1363
-
1364
- // Validators stay strict (they already have auto-approval fallback on crash).
1365
- if (agent.role === 'validator') {
1366
- throw new Error(msg);
1367
- }
1368
-
1369
- // Non-validators: emit warning and continue with best-effort parsed data.
1370
- console.warn(`⚠️ ${msg}`);
1371
- agent._publish({
1372
- topic: 'AGENT_SCHEMA_WARNING',
1373
- receiver: 'broadcast',
1374
- content: {
1375
- text: msg,
1376
- data: {
1377
- agent: agent.id,
1378
- role: agent.role,
1379
- iteration: agent.iteration,
1380
- errors: validate.errors || [],
1381
- },
1382
- },
1383
- });
1384
- }
1385
- }
1386
-
1387
- // Return whatever the agent produced - no hardcoded field requirements
1388
- // Template substitution will validate that required fields exist
1389
- return parsed;
1390
- }
1391
-
1392
- /**
1393
- * Kill current task
1394
- * @param {Object} agent - Agent instance
1395
- */
1396
- function killTask(agent) {
1397
- if (agent.currentTask) {
1398
- // currentTask may be either a ChildProcess or our custom { kill } object
1399
- if (typeof agent.currentTask.kill === 'function') {
1400
- agent.currentTask.kill('SIGTERM');
1401
- }
1402
- agent.currentTask = null;
1403
- }
1404
-
1405
- // Also kill the underlying zeroshot task if we have a task ID
1406
- // This ensures the task process is stopped, not just our polling intervals
1407
- if (agent.currentTaskId) {
1408
- const { exec } = require('child_process');
1409
- const ctPath = getClaudeTasksPath();
1410
- exec(`${ctPath} task kill ${agent.currentTaskId}`, (error) => {
1411
- if (error) {
1412
- // Task may have already completed or been killed, ignore errors
1413
- agent._log(`Note: Could not kill task ${agent.currentTaskId}: ${error.message}`);
1414
- } else {
1415
- agent._log(`Killed task ${agent.currentTaskId}`);
1416
- }
1417
- });
1418
- agent.currentTaskId = null;
1419
- }
1420
- }
1421
-
1422
- module.exports = {
1423
- ensureAskUserQuestionHook,
1424
- spawnClaudeTask,
1425
- followClaudeTaskLogs,
1426
- waitForTaskReady,
1427
- spawnClaudeTaskIsolated,
1428
- getClaudeTasksPath,
1429
- parseResultOutput,
1430
- killTask,
1431
- };