@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.
- package/LICENSE +1 -1
- package/README.md +8 -385
- package/cli/index.js +108 -4615
- package/package.json +16 -125
- package/CHANGELOG.md +0 -537
- package/cli/commands/providers.js +0 -150
- package/cli/lib/first-run.js +0 -211
- package/cli/lib/update-checker.js +0 -281
- package/cli/message-formatter-utils.js +0 -75
- package/cli/message-formatters-normal.js +0 -253
- package/cli/message-formatters-watch.js +0 -181
- package/cluster-templates/base-templates/debug-workflow.json +0 -375
- package/cluster-templates/base-templates/full-workflow.json +0 -697
- package/cluster-templates/base-templates/single-worker.json +0 -63
- package/cluster-templates/base-templates/worker-validator.json +0 -152
- package/cluster-templates/conductor-bootstrap.json +0 -124
- package/docker/zeroshot-cluster/Dockerfile +0 -152
- package/docker/zeroshot-cluster/pre-baked-deps.json +0 -28
- package/lib/completion.js +0 -174
- package/lib/docker-config.js +0 -212
- package/lib/id-detector.js +0 -53
- package/lib/provider-detection.js +0 -59
- package/lib/provider-names.js +0 -56
- package/lib/settings.js +0 -405
- package/lib/stream-json-parser.js +0 -10
- package/scripts/fix-node-pty-permissions.js +0 -75
- package/scripts/record-demo.sh +0 -279
- package/scripts/test-install.sh +0 -40
- package/scripts/validate-templates.js +0 -100
- package/src/agent/agent-config.js +0 -193
- package/src/agent/agent-context-builder.js +0 -335
- package/src/agent/agent-hook-executor.js +0 -402
- package/src/agent/agent-lifecycle.js +0 -683
- package/src/agent/agent-stuck-detector.js +0 -256
- package/src/agent/agent-task-executor.js +0 -1431
- package/src/agent/agent-trigger-evaluator.js +0 -67
- package/src/agent/output-extraction.js +0 -200
- package/src/agent/output-reformatter.js +0 -175
- package/src/agent/schema-utils.js +0 -111
- package/src/agent-wrapper.js +0 -570
- package/src/agents/git-pusher-agent.json +0 -20
- package/src/attach/attach-client.js +0 -438
- package/src/attach/attach-server.js +0 -543
- package/src/attach/index.js +0 -35
- package/src/attach/protocol.js +0 -220
- package/src/attach/ring-buffer.js +0 -121
- package/src/attach/socket-discovery.js +0 -267
- package/src/claude-task-runner.js +0 -523
- package/src/config-router.js +0 -80
- package/src/config-validator.js +0 -1660
- package/src/copy-worker.js +0 -43
- package/src/github.js +0 -139
- package/src/isolation-manager.js +0 -1381
- package/src/ledger.js +0 -614
- package/src/logic-engine.js +0 -223
- package/src/message-bus-bridge.js +0 -139
- package/src/message-bus.js +0 -249
- package/src/name-generator.js +0 -232
- package/src/orchestrator.js +0 -2519
- package/src/preflight.js +0 -485
- package/src/process-metrics.js +0 -566
- package/src/providers/anthropic/cli-builder.js +0 -45
- package/src/providers/anthropic/index.js +0 -134
- package/src/providers/anthropic/models.js +0 -23
- package/src/providers/anthropic/output-parser.js +0 -159
- package/src/providers/base-provider.js +0 -181
- package/src/providers/capabilities.js +0 -51
- package/src/providers/google/cli-builder.js +0 -55
- package/src/providers/google/index.js +0 -116
- package/src/providers/google/models.js +0 -24
- package/src/providers/google/output-parser.js +0 -92
- package/src/providers/index.js +0 -75
- package/src/providers/openai/cli-builder.js +0 -122
- package/src/providers/openai/index.js +0 -135
- package/src/providers/openai/models.js +0 -21
- package/src/providers/openai/output-parser.js +0 -129
- package/src/schemas/sub-cluster.js +0 -156
- package/src/status-footer.js +0 -980
- package/src/sub-cluster-wrapper.js +0 -560
- package/src/task-runner.js +0 -30
- package/src/template-resolver.js +0 -369
- package/src/tui/CHANGES.txt +0 -133
- package/src/tui/LAYOUT.md +0 -261
- package/src/tui/README.txt +0 -192
- package/src/tui/TWO-LEVEL-NAVIGATION.md +0 -186
- package/src/tui/data-poller.js +0 -325
- package/src/tui/demo.js +0 -208
- package/src/tui/formatters.js +0 -128
- package/src/tui/index.js +0 -193
- package/src/tui/keybindings.js +0 -383
- package/src/tui/layout.js +0 -334
- package/src/tui/renderer.js +0 -194
- package/task-lib/attachable-watcher.js +0 -272
- package/task-lib/claude-recovery.js +0 -119
- package/task-lib/commands/clean.js +0 -50
- package/task-lib/commands/get-log-path.js +0 -23
- package/task-lib/commands/kill.js +0 -32
- package/task-lib/commands/list.js +0 -105
- package/task-lib/commands/logs.js +0 -411
- package/task-lib/commands/resume.js +0 -42
- package/task-lib/commands/run.js +0 -57
- package/task-lib/commands/schedule.js +0 -105
- package/task-lib/commands/scheduler-cmd.js +0 -96
- package/task-lib/commands/schedules.js +0 -98
- package/task-lib/commands/status.js +0 -44
- package/task-lib/commands/unschedule.js +0 -16
- package/task-lib/completion.js +0 -9
- package/task-lib/config.js +0 -10
- package/task-lib/name-generator.js +0 -230
- package/task-lib/package.json +0 -3
- package/task-lib/runner.js +0 -144
- package/task-lib/scheduler.js +0 -252
- package/task-lib/store.js +0 -230
- package/task-lib/tui/formatters.js +0 -166
- package/task-lib/tui/index.js +0 -197
- package/task-lib/tui/layout.js +0 -111
- package/task-lib/tui/renderer.js +0 -119
- package/task-lib/tui.js +0 -384
- 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
|
-
};
|