teleportation-cli 1.1.4 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/hooks/config-loader.mjs +88 -34
- package/.claude/hooks/permission_request.mjs +392 -82
- package/.claude/hooks/post_tool_use.mjs +90 -0
- package/.claude/hooks/pre_tool_use.mjs +247 -305
- package/.claude/hooks/session-register.mjs +94 -105
- package/.claude/hooks/session_end.mjs +41 -42
- package/.claude/hooks/session_start.mjs +45 -60
- package/.claude/hooks/stop.mjs +752 -99
- package/.claude/hooks/user_prompt_submit.mjs +26 -3
- package/README.md +7 -0
- package/lib/auth/api-key.js +12 -0
- package/lib/auth/token-refresh.js +286 -0
- package/lib/cli/daemon-commands.js +1 -1
- package/lib/cli/teleport-commands.js +469 -0
- package/lib/daemon/daemon-v2.js +104 -0
- package/lib/daemon/lifecycle.js +56 -171
- package/lib/daemon/response-classifier.js +15 -1
- package/lib/daemon/services/index.js +3 -0
- package/lib/daemon/services/polling-service.js +173 -0
- package/lib/daemon/services/queue-service.js +318 -0
- package/lib/daemon/services/session-service.js +115 -0
- package/lib/daemon/state.js +35 -0
- package/lib/daemon/task-executor-v2.js +413 -0
- package/lib/daemon/task-executor.js +1235 -0
- package/lib/daemon/teleportation-daemon.js +770 -25
- package/lib/daemon/timeline-analyzer.js +215 -0
- package/lib/daemon/transcript-ingestion.js +696 -0
- package/lib/daemon/utils.js +91 -0
- package/lib/install/installer.js +184 -20
- package/lib/install/uhr-installer.js +136 -0
- package/lib/remote/providers/base-provider.js +46 -0
- package/lib/remote/providers/daytona-provider.js +58 -0
- package/lib/remote/providers/provider-factory.js +90 -19
- package/lib/remote/providers/sprites-provider.js +711 -0
- package/lib/teleport/exporters/claude-exporter.js +302 -0
- package/lib/teleport/exporters/gemini-exporter.js +307 -0
- package/lib/teleport/exporters/index.js +93 -0
- package/lib/teleport/exporters/interface.js +153 -0
- package/lib/teleport/fork-tracker.js +415 -0
- package/lib/teleport/git-committer.js +337 -0
- package/lib/teleport/index.js +48 -0
- package/lib/teleport/manager.js +620 -0
- package/lib/teleport/session-capture.js +282 -0
- package/package.json +11 -5
- package/teleportation-cli.cjs +632 -451
- package/.claude/hooks/heartbeat.mjs +0 -396
- package/lib/daemon/agentic-executor.js +0 -803
- package/lib/daemon/pid-manager.js +0 -160
|
@@ -0,0 +1,1235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task Executor
|
|
3
|
+
*
|
|
4
|
+
* Executes task tasks using Claude Code's headless mode.
|
|
5
|
+
* Provides an autonomous execution loop that continues until:
|
|
6
|
+
* - Task is complete
|
|
7
|
+
* - Claude asks a question requiring user input
|
|
8
|
+
* - User stops the task
|
|
9
|
+
* - Budget is exhausted
|
|
10
|
+
*
|
|
11
|
+
* Key features:
|
|
12
|
+
* - Uses `claude -p` with `--output-format json` for structured results
|
|
13
|
+
* - Captures `session_id` from JSON result for conversation continuity
|
|
14
|
+
* - Uses `--resume <session-id>` for subsequent turns
|
|
15
|
+
* - Budget control via `--max-budget-usd`
|
|
16
|
+
* - Hooks fire normally (approvals go to mobile app)
|
|
17
|
+
*
|
|
18
|
+
* @module lib/daemon/task-executor
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { spawn } from 'child_process';
|
|
22
|
+
import { randomUUID } from 'crypto';
|
|
23
|
+
import fs from 'fs';
|
|
24
|
+
import path from 'path';
|
|
25
|
+
import os from 'os';
|
|
26
|
+
import { classifyResponse, getConfidenceThreshold } from './response-classifier.js';
|
|
27
|
+
|
|
28
|
+
// Paths
|
|
29
|
+
const TELEPORTATION_DIR = path.join(os.homedir(), '.teleportation');
|
|
30
|
+
const TASKS_LOCK_DIR = path.join(TELEPORTATION_DIR, 'task_locks');
|
|
31
|
+
|
|
32
|
+
// Ensure lock directory exists with strict permissions (owner only)
|
|
33
|
+
if (!fs.existsSync(TASKS_LOCK_DIR)) {
|
|
34
|
+
fs.mkdirSync(TASKS_LOCK_DIR, { recursive: true, mode: 0o700 });
|
|
35
|
+
} else {
|
|
36
|
+
// Ensure permissions are correct if it already exists
|
|
37
|
+
try { fs.chmodSync(TASKS_LOCK_DIR, 0o700); } catch (e) {}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Default configuration
|
|
41
|
+
const DEFAULT_BUDGET_USD = 10.0;
|
|
42
|
+
const DEFAULT_TIMEOUT_MS = 600000; // 10 minutes per turn
|
|
43
|
+
const CLAUDE_CLI = process.env.CLAUDE_CLI_PATH || 'claude';
|
|
44
|
+
const SIGTERM_TIMEOUT_MS = 2000; // 2 seconds before SIGKILL
|
|
45
|
+
|
|
46
|
+
// Safety limits to prevent runaway execution
|
|
47
|
+
const MAX_TURNS = 100; // Maximum execution turns before forced stop
|
|
48
|
+
const WAIT_FOR_RESUME_TIMEOUT_MS = 1800000; // 30 minutes max wait for resume
|
|
49
|
+
const POLLING_INTERVAL_MS = 1000; // Polling interval for waitForResume
|
|
50
|
+
const SESSION_CLEANUP_INTERVAL_MS = 300000; // 5 minutes cleanup interval
|
|
51
|
+
const SESSION_MAX_AGE_MS = 3600000; // 1 hour max session age
|
|
52
|
+
const MAX_CONSECUTIVE_FAILURES = 3; // Stop after this many consecutive failures
|
|
53
|
+
const MAX_HISTORY_SIZE = 20; // Maximum history entries to keep per session
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Helper: Remove task lock file safely
|
|
57
|
+
* @param {string} taskId
|
|
58
|
+
*/
|
|
59
|
+
export function removeTaskLock(taskId) {
|
|
60
|
+
const lockPath = path.join(TASKS_LOCK_DIR, `${taskId}.pid`);
|
|
61
|
+
try {
|
|
62
|
+
fs.unlinkSync(lockPath);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if (err.code !== 'ENOENT') {
|
|
65
|
+
console.debug(`[task] Non-fatal: Failed to remove lock for ${taskId}:`, err.message);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Task session state
|
|
72
|
+
* @typedef {Object} TaskSession
|
|
73
|
+
* @property {string} id - Unique session ID
|
|
74
|
+
* @property {string} task - Original task description
|
|
75
|
+
* @property {'running' | 'paused' | 'waiting_input' | 'completed' | 'stopped' | 'budget_paused'} status
|
|
76
|
+
* @property {string|null} claude_session_id - Claude Code's session ID for resume
|
|
77
|
+
* @property {boolean} auto_continue - Whether to automatically continue after each turn
|
|
78
|
+
* @property {number} budget_usd - Total budget allocated
|
|
79
|
+
* @property {number} cost_usd - Total cost incurred so far
|
|
80
|
+
* @property {number} started_at - Timestamp when task started
|
|
81
|
+
* @property {number} updated_at - Timestamp of last update
|
|
82
|
+
* @property {number|null} completed_at - Timestamp when task completed
|
|
83
|
+
* @property {string|null} pending_question - Question waiting for user answer
|
|
84
|
+
* @property {number} turn_count - Number of execution turns
|
|
85
|
+
* @property {Array} history - Execution history
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
// In-memory session store (will be replaced with Redis in relay endpoints)
|
|
89
|
+
const taskSessions = new Map();
|
|
90
|
+
|
|
91
|
+
// Track running processes for stop functionality
|
|
92
|
+
const runningProcesses = new Map();
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Cleanup orphaned process entries on startup
|
|
96
|
+
* Removes Map entries for processes that are no longer running
|
|
97
|
+
*/
|
|
98
|
+
export function cleanupOrphanedProcesses() {
|
|
99
|
+
let cleanedCount = 0;
|
|
100
|
+
for (const [id, proc] of runningProcesses) {
|
|
101
|
+
if (proc.pid && !isProcessRunning(proc.pid)) {
|
|
102
|
+
runningProcesses.delete(id);
|
|
103
|
+
cleanedCount++;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (cleanedCount > 0) {
|
|
107
|
+
console.log(`[task] Cleaned up ${cleanedCount} orphaned process entries`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Check if a process is running
|
|
113
|
+
* @param {number} pid
|
|
114
|
+
* @returns {boolean}
|
|
115
|
+
*/
|
|
116
|
+
function isProcessRunning(pid) {
|
|
117
|
+
try {
|
|
118
|
+
process.kill(pid, 0);
|
|
119
|
+
return true;
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Cleanup old/stale task locks on startup
|
|
127
|
+
* Removes lock files for processes that are no longer running
|
|
128
|
+
*/
|
|
129
|
+
export function cleanupStaleLocks() {
|
|
130
|
+
if (!fs.existsSync(TASKS_LOCK_DIR)) return;
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const files = fs.readdirSync(TASKS_LOCK_DIR);
|
|
134
|
+
let cleanedCount = 0;
|
|
135
|
+
|
|
136
|
+
for (const file of files) {
|
|
137
|
+
if (!file.endsWith('.pid')) continue;
|
|
138
|
+
|
|
139
|
+
const lockPath = path.join(TASKS_LOCK_DIR, file);
|
|
140
|
+
try {
|
|
141
|
+
const lockContent = fs.readFileSync(lockPath, 'utf8');
|
|
142
|
+
const { pid } = JSON.parse(lockContent);
|
|
143
|
+
|
|
144
|
+
// Check if process exists
|
|
145
|
+
process.kill(pid, 0);
|
|
146
|
+
} catch (e) {
|
|
147
|
+
// Process is dead, stale lock, or invalid JSON
|
|
148
|
+
// Only clean up if it's actually dead (ESRCH) or corrupted (SyntaxError)
|
|
149
|
+
if (e.code === 'ESRCH' || e instanceof SyntaxError || e.name === 'SyntaxError') {
|
|
150
|
+
try {
|
|
151
|
+
fs.unlinkSync(lockPath);
|
|
152
|
+
cleanedCount++;
|
|
153
|
+
} catch (unlinkErr) {}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (cleanedCount > 0) {
|
|
159
|
+
console.log(`[task] Cleaned up ${cleanedCount} stale task lock(s)`);
|
|
160
|
+
}
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.error('[task] Failed to cleanup stale locks:', err.message);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Cleanup old/stale sessions to prevent memory leaks
|
|
168
|
+
* Removes sessions that:
|
|
169
|
+
* - Are completed/stopped and older than SESSION_MAX_AGE_MS
|
|
170
|
+
* - Have no activity for SESSION_MAX_AGE_MS
|
|
171
|
+
*/
|
|
172
|
+
function cleanupStaleSessions() {
|
|
173
|
+
const now = Date.now();
|
|
174
|
+
let cleanedCount = 0;
|
|
175
|
+
|
|
176
|
+
for (const [id, session] of taskSessions) {
|
|
177
|
+
const age = now - session.started_at;
|
|
178
|
+
const lastActivity = now - session.updated_at;
|
|
179
|
+
|
|
180
|
+
// Remove finished sessions older than max age
|
|
181
|
+
const isFinished = session.status === 'completed' || session.status === 'stopped';
|
|
182
|
+
if (isFinished && age > SESSION_MAX_AGE_MS) {
|
|
183
|
+
taskSessions.delete(id);
|
|
184
|
+
runningProcesses.delete(id);
|
|
185
|
+
cleanedCount++;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Remove sessions with no activity for max age
|
|
190
|
+
if (lastActivity > SESSION_MAX_AGE_MS) {
|
|
191
|
+
// Kill any running process
|
|
192
|
+
const proc = runningProcesses.get(id);
|
|
193
|
+
if (proc) {
|
|
194
|
+
proc.kill('SIGTERM');
|
|
195
|
+
runningProcesses.delete(id);
|
|
196
|
+
}
|
|
197
|
+
taskSessions.delete(id);
|
|
198
|
+
cleanedCount++;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (cleanedCount > 0) {
|
|
203
|
+
console.log(`[task] Cleaned up ${cleanedCount} stale sessions`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Start periodic cleanup (only in non-test environments)
|
|
208
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
209
|
+
setInterval(cleanupStaleSessions, SESSION_CLEANUP_INTERVAL_MS);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Generate a unique task session ID using cryptographically secure random UUID
|
|
214
|
+
* @returns {string}
|
|
215
|
+
*/
|
|
216
|
+
function generateTaskId() {
|
|
217
|
+
return `task-${Date.now()}-${randomUUID()}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Execute Claude Code in headless mode
|
|
222
|
+
*
|
|
223
|
+
* @param {Object} options
|
|
224
|
+
* @param {string} options.prompt - The prompt to send to Claude
|
|
225
|
+
* @param {string} options.cwd - Working directory
|
|
226
|
+
* @param {string|null} options.resumeSessionId - Claude session ID to resume (null for new)
|
|
227
|
+
* @param {number} options.budgetUsd - Max budget in USD
|
|
228
|
+
* @param {number} options.timeoutMs - Timeout in milliseconds
|
|
229
|
+
* @param {string} options.taskId - Task session ID for tracking
|
|
230
|
+
* @param {Function} [options.onUpdate] - Optional callback for streaming updates
|
|
231
|
+
* @returns {Promise<Object>} Execution result with session_id, output, cost, etc.
|
|
232
|
+
*/
|
|
233
|
+
async function executeClaudeHeadless(options) {
|
|
234
|
+
const {
|
|
235
|
+
prompt,
|
|
236
|
+
cwd,
|
|
237
|
+
resumeSessionId,
|
|
238
|
+
budgetUsd,
|
|
239
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
240
|
+
taskId,
|
|
241
|
+
parentSessionId,
|
|
242
|
+
onUpdate,
|
|
243
|
+
} = options;
|
|
244
|
+
|
|
245
|
+
return new Promise((resolve, reject) => {
|
|
246
|
+
const args = [];
|
|
247
|
+
|
|
248
|
+
// Resume existing session or start new
|
|
249
|
+
if (resumeSessionId) {
|
|
250
|
+
args.push('--resume', resumeSessionId);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Add prompt
|
|
254
|
+
args.push('-p', prompt);
|
|
255
|
+
|
|
256
|
+
// Output format for structured parsing - using stream-json for real-time metadata
|
|
257
|
+
args.push('--output-format', 'stream-json');
|
|
258
|
+
args.push('--verbose');
|
|
259
|
+
|
|
260
|
+
// Budget control (Claude Code native feature)
|
|
261
|
+
if (budgetUsd > 0) {
|
|
262
|
+
args.push('--max-budget-usd', budgetUsd.toFixed(2));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Hooks enabled: approvals route to mobile app
|
|
266
|
+
// Session must be in "away" mode for hooks to wait for remote approval
|
|
267
|
+
// (Daemon sets away mode before calling startTask)
|
|
268
|
+
|
|
269
|
+
console.log(`[task] Executing: ${CLAUDE_CLI} ${args.join(' ')}`);
|
|
270
|
+
|
|
271
|
+
const proc = spawn(CLAUDE_CLI, args, {
|
|
272
|
+
cwd,
|
|
273
|
+
stdio: 'pipe',
|
|
274
|
+
env: {
|
|
275
|
+
...process.env,
|
|
276
|
+
CI: 'true', // Non-interactive mode
|
|
277
|
+
TELEPORTATION_TASK_MODE: 'true',
|
|
278
|
+
// Pass parent session ID so hooks can log approvals to correct timeline
|
|
279
|
+
...(parentSessionId && { TELEPORTATION_PARENT_SESSION_ID: parentSessionId }),
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Track process for stop functionality
|
|
284
|
+
runningProcesses.set(taskId, proc);
|
|
285
|
+
|
|
286
|
+
// Close stdin to prevent hanging
|
|
287
|
+
proc.stdin.end();
|
|
288
|
+
|
|
289
|
+
let stdout = '';
|
|
290
|
+
let stderr = '';
|
|
291
|
+
let timedOut = false;
|
|
292
|
+
let buffer = '';
|
|
293
|
+
|
|
294
|
+
// Final result structure
|
|
295
|
+
let result = {
|
|
296
|
+
success: false,
|
|
297
|
+
output: '',
|
|
298
|
+
error: null,
|
|
299
|
+
exit_code: 0,
|
|
300
|
+
session_id: null,
|
|
301
|
+
cost_usd: 0,
|
|
302
|
+
duration_ms: 0,
|
|
303
|
+
model: null,
|
|
304
|
+
usage: {
|
|
305
|
+
tools_used: 0,
|
|
306
|
+
input_tokens: 0,
|
|
307
|
+
output_tokens: 0,
|
|
308
|
+
cache_creation_input_tokens: 0,
|
|
309
|
+
cache_read_input_tokens: 0
|
|
310
|
+
},
|
|
311
|
+
tool_calls: [],
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// Timeout handler
|
|
315
|
+
const timeout = setTimeout(() => {
|
|
316
|
+
timedOut = true;
|
|
317
|
+
proc.kill('SIGTERM');
|
|
318
|
+
}, timeoutMs);
|
|
319
|
+
|
|
320
|
+
proc.stdout.on('data', (data) => {
|
|
321
|
+
const chunk = data.toString();
|
|
322
|
+
stdout += chunk;
|
|
323
|
+
buffer += chunk;
|
|
324
|
+
|
|
325
|
+
// Process stream-json lines
|
|
326
|
+
const lines = buffer.split('\n');
|
|
327
|
+
buffer = lines.pop(); // Keep last incomplete line
|
|
328
|
+
|
|
329
|
+
for (const line of lines) {
|
|
330
|
+
if (!line.trim()) continue;
|
|
331
|
+
try {
|
|
332
|
+
// Debug: console.debug(`[stream] Line: ${line.slice(0, 100)}`);
|
|
333
|
+
const msg = JSON.parse(line);
|
|
334
|
+
|
|
335
|
+
// Accumulate metadata from stream
|
|
336
|
+
if (msg.type === 'system' && msg.subtype === 'init' && msg.session_id) {
|
|
337
|
+
result.session_id = msg.session_id;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (msg.type === 'assistant' && msg.message?.content) {
|
|
341
|
+
const content = msg.message.content;
|
|
342
|
+
for (const block of content) {
|
|
343
|
+
if (block.type === 'text') {
|
|
344
|
+
result.output += block.text;
|
|
345
|
+
if (onUpdate) onUpdate({ type: 'text', text: block.text });
|
|
346
|
+
} else if (block.type === 'tool_use') {
|
|
347
|
+
result.usage.tools_used++;
|
|
348
|
+
result.tool_calls.push(block);
|
|
349
|
+
if (onUpdate) onUpdate({ type: 'tool_use', tool: block.name });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (msg.type === 'usage' || (msg.type === 'assistant' && msg.message?.usage)) {
|
|
355
|
+
const usage = msg.usage || msg.message.usage;
|
|
356
|
+
result.usage.input_tokens += usage.input_tokens || 0;
|
|
357
|
+
result.usage.output_tokens += usage.output_tokens || 0;
|
|
358
|
+
result.usage.cache_creation_input_tokens += usage.cache_creation_input_tokens || 0;
|
|
359
|
+
result.usage.cache_read_input_tokens += usage.cache_read_input_tokens || 0;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (msg.type === 'result') {
|
|
363
|
+
result.success = !msg.is_error;
|
|
364
|
+
if (msg.is_error) result.error = msg.error;
|
|
365
|
+
if (msg.total_cost_usd) result.cost_usd = msg.total_cost_usd;
|
|
366
|
+
}
|
|
367
|
+
} catch (e) {
|
|
368
|
+
// Non-JSON line, ignore or log
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
proc.stderr.on('data', (data) => {
|
|
374
|
+
stderr += data.toString();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
proc.on('close', (code) => {
|
|
378
|
+
clearTimeout(timeout);
|
|
379
|
+
runningProcesses.delete(taskId);
|
|
380
|
+
|
|
381
|
+
result.exit_code = code;
|
|
382
|
+
result.duration_ms = Date.now() - (result.start_time || Date.now());
|
|
383
|
+
|
|
384
|
+
if (timedOut) {
|
|
385
|
+
result.success = false;
|
|
386
|
+
result.error = 'Execution timed out';
|
|
387
|
+
result.exit_code = -1;
|
|
388
|
+
} else if (code !== 0 && !result.error) {
|
|
389
|
+
result.success = false;
|
|
390
|
+
result.error = stderr || `Exit code: ${code}`;
|
|
391
|
+
} else if (code === 0) {
|
|
392
|
+
result.success = true;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// If output is still empty but we have stdout that wasn't stream-json,
|
|
396
|
+
// fall back to raw stdout
|
|
397
|
+
if (!result.output && stdout) {
|
|
398
|
+
try {
|
|
399
|
+
const parsed = JSON.parse(stdout);
|
|
400
|
+
result.output = parsed.result || parsed.response || parsed.output || stdout;
|
|
401
|
+
result.session_id = result.session_id || parsed.session_id;
|
|
402
|
+
result.cost_usd = result.cost_usd || parsed.cost || 0;
|
|
403
|
+
} catch {
|
|
404
|
+
result.output = stdout;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
resolve(result);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
proc.on('error', (err) => {
|
|
412
|
+
clearTimeout(timeout);
|
|
413
|
+
runningProcesses.delete(taskId);
|
|
414
|
+
reject(err);
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Start a new task task
|
|
421
|
+
*
|
|
422
|
+
* @param {Object} options
|
|
423
|
+
* @param {string} options.task - Task description
|
|
424
|
+
* @param {string} options.session_id - Teleportation session ID
|
|
425
|
+
* @param {string} options.cwd - Working directory
|
|
426
|
+
* @param {number} [options.budget_usd] - Max budget in USD (default: 10)
|
|
427
|
+
* @param {boolean} [options.auto_continue=false] - Auto-continue after each turn (default: false). When disabled, task pauses after each turn and waits for user message.
|
|
428
|
+
* @param {Function} [options.onEvent] - Callback for task events
|
|
429
|
+
* @returns {Promise<TaskSession>}
|
|
430
|
+
*/
|
|
431
|
+
export async function startTask(options) {
|
|
432
|
+
const {
|
|
433
|
+
task,
|
|
434
|
+
session_id,
|
|
435
|
+
cwd,
|
|
436
|
+
budget_usd = DEFAULT_BUDGET_USD,
|
|
437
|
+
parent_claude_session_id = null,
|
|
438
|
+
auto_continue: rawAutoContinue = false,
|
|
439
|
+
onEvent,
|
|
440
|
+
} = options;
|
|
441
|
+
|
|
442
|
+
// Validate auto_continue parameter - ensure it's a boolean
|
|
443
|
+
const auto_continue = Boolean(rawAutoContinue ?? false);
|
|
444
|
+
|
|
445
|
+
const taskId = options.task_id || generateTaskId();
|
|
446
|
+
const now = Date.now();
|
|
447
|
+
const lockPath = path.join(TASKS_LOCK_DIR, `${taskId}.pid`);
|
|
448
|
+
|
|
449
|
+
// Atomic lock acquisition using 'wx' (exclusive write)
|
|
450
|
+
// This prevents multiple daemon instances from claiming the same task simultaneously
|
|
451
|
+
let acquired = false;
|
|
452
|
+
let attempts = 0;
|
|
453
|
+
|
|
454
|
+
while (!acquired && attempts < 2) {
|
|
455
|
+
attempts++;
|
|
456
|
+
try {
|
|
457
|
+
const lockData = JSON.stringify({
|
|
458
|
+
pid: process.pid,
|
|
459
|
+
taskId,
|
|
460
|
+
startTime: now,
|
|
461
|
+
hostname: os.hostname()
|
|
462
|
+
});
|
|
463
|
+
fs.writeFileSync(lockPath, lockData, { flag: 'wx', mode: 0o600 });
|
|
464
|
+
acquired = true;
|
|
465
|
+
} catch (err) {
|
|
466
|
+
if (err.code === 'EEXIST') {
|
|
467
|
+
// Lock already exists - verify if it's stale or our own
|
|
468
|
+
try {
|
|
469
|
+
const lockContent = fs.readFileSync(lockPath, 'utf8');
|
|
470
|
+
const existing = JSON.parse(lockContent);
|
|
471
|
+
|
|
472
|
+
// 1. Self-recovery check
|
|
473
|
+
if (existing.pid === process.pid && existing.hostname === os.hostname()) {
|
|
474
|
+
console.log(`[task] Task ${taskId} already owned by this process, continuing.`);
|
|
475
|
+
acquired = true;
|
|
476
|
+
break;
|
|
477
|
+
} else if (existing.hostname === os.hostname()) {
|
|
478
|
+
// 2. Same-machine staleness check
|
|
479
|
+
process.kill(existing.pid, 0); // Throws if process is dead
|
|
480
|
+
console.log(`[task] Task ${taskId} is already being handled by process ${existing.pid} (started ${new Date(existing.startTime).toISOString()}), skipping start.`);
|
|
481
|
+
return { id: taskId, status: 'already_running' };
|
|
482
|
+
} else {
|
|
483
|
+
// 3. Different machine - assume task is alive if lock is relatively fresh (< 1 hour)
|
|
484
|
+
const lockAge = now - existing.startTime;
|
|
485
|
+
if (lockAge < 3600000) {
|
|
486
|
+
console.log(`[task] Task ${taskId} is owned by another host (${existing.hostname}), skipping start.`);
|
|
487
|
+
return { id: taskId, status: 'already_running' };
|
|
488
|
+
}
|
|
489
|
+
// Lock is old - potentially stale cross-host, remove and retry
|
|
490
|
+
console.log(`[task] Stale cross-host lock found for task ${taskId}, cleaning up and retrying acquisition.`);
|
|
491
|
+
removeTaskLock(taskId);
|
|
492
|
+
// Allow while loop to retry acquisition immediately
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
} catch (e) {
|
|
496
|
+
// Process dead or invalid JSON - lock is stale
|
|
497
|
+
console.log(`[task] Stale lock found for task ${taskId}, cleaning up and retrying acquisition.`);
|
|
498
|
+
removeTaskLock(taskId);
|
|
499
|
+
// Allow while loop to retry acquisition immediately
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
} else {
|
|
503
|
+
throw err;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (!acquired) {
|
|
509
|
+
console.log(`[task] Failed to acquire lock for ${taskId} after retrying stale cleanup.`);
|
|
510
|
+
return { id: taskId, status: 'already_running' };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Create task session
|
|
514
|
+
const session = {
|
|
515
|
+
id: taskId,
|
|
516
|
+
teleportation_session_id: session_id,
|
|
517
|
+
task,
|
|
518
|
+
status: 'running',
|
|
519
|
+
claude_session_id: null, // Will be set to child session on first execution
|
|
520
|
+
parent_claude_session_id, // Parent session to resume for context
|
|
521
|
+
auto_continue, // Whether to automatically continue or wait for user messages
|
|
522
|
+
budget_usd,
|
|
523
|
+
cost_usd: 0,
|
|
524
|
+
started_at: now,
|
|
525
|
+
updated_at: now,
|
|
526
|
+
completed_at: null,
|
|
527
|
+
pending_question: null,
|
|
528
|
+
turn_count: 0,
|
|
529
|
+
history: [],
|
|
530
|
+
cwd,
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
taskSessions.set(taskId, session);
|
|
534
|
+
|
|
535
|
+
// Emit started event
|
|
536
|
+
if (onEvent) {
|
|
537
|
+
onEvent({
|
|
538
|
+
type: 'task_started',
|
|
539
|
+
source: 'autonomous_task',
|
|
540
|
+
task_id: taskId,
|
|
541
|
+
session_id,
|
|
542
|
+
task,
|
|
543
|
+
budget_usd,
|
|
544
|
+
timestamp: now,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Start execution loop - ensure we don't start the same task twice
|
|
549
|
+
// (Race condition protection at the executor level)
|
|
550
|
+
if (runningProcesses.has(taskId)) {
|
|
551
|
+
console.log(`[task] Task ${taskId} is already running in this process, skipping loop start`);
|
|
552
|
+
return session;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
executeTaskLoop(taskId, onEvent).catch(err => {
|
|
556
|
+
console.error(`[task] Loop error for ${taskId}:`, err.message);
|
|
557
|
+
const s = taskSessions.get(taskId);
|
|
558
|
+
if (s) {
|
|
559
|
+
s.status = 'stopped';
|
|
560
|
+
s.updated_at = Date.now();
|
|
561
|
+
// Remove lock on error
|
|
562
|
+
removeTaskLock(taskId);
|
|
563
|
+
|
|
564
|
+
if (onEvent) {
|
|
565
|
+
onEvent({
|
|
566
|
+
type: 'task_stopped',
|
|
567
|
+
source: 'autonomous_task',
|
|
568
|
+
task_id: taskId,
|
|
569
|
+
reason: `Error: ${err.message}`,
|
|
570
|
+
timestamp: Date.now(),
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
return session;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Main task execution loop
|
|
581
|
+
* Continues until complete, question, stopped, or budget exhausted
|
|
582
|
+
*
|
|
583
|
+
* @param {string} taskId - Task session ID
|
|
584
|
+
* @param {Function} onEvent - Event callback
|
|
585
|
+
*/
|
|
586
|
+
async function executeTaskLoop(taskId, onEvent) {
|
|
587
|
+
const session = taskSessions.get(taskId);
|
|
588
|
+
if (!session) {
|
|
589
|
+
throw new Error(`Task session not found: ${taskId}`);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
let continueExecution = true;
|
|
593
|
+
let consecutiveFailures = 0;
|
|
594
|
+
|
|
595
|
+
while (continueExecution) {
|
|
596
|
+
// Check for stop signal
|
|
597
|
+
if (session.status === 'stopped') {
|
|
598
|
+
console.log(`[task] Session ${taskId} stopped by user`);
|
|
599
|
+
break;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Safety: Check max turns limit to prevent infinite loops
|
|
603
|
+
if (session.turn_count >= MAX_TURNS) {
|
|
604
|
+
session.status = 'stopped';
|
|
605
|
+
session.updated_at = Date.now();
|
|
606
|
+
console.log(`[task] Session ${taskId} reached max turns limit (${MAX_TURNS})`);
|
|
607
|
+
if (onEvent) {
|
|
608
|
+
onEvent({
|
|
609
|
+
type: 'task_stopped',
|
|
610
|
+
source: 'autonomous_task',
|
|
611
|
+
task_id: taskId,
|
|
612
|
+
reason: `Max turns limit reached (${MAX_TURNS})`,
|
|
613
|
+
cost_usd: session.cost_usd,
|
|
614
|
+
turn_count: session.turn_count,
|
|
615
|
+
timestamp: Date.now(),
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Check for pause
|
|
622
|
+
if (session.status === 'paused') {
|
|
623
|
+
console.log(`[task] Session ${taskId} paused, waiting for resume`);
|
|
624
|
+
try {
|
|
625
|
+
await waitForResume(taskId);
|
|
626
|
+
} catch (timeoutError) {
|
|
627
|
+
// Timeout occurred - session already marked as stopped in waitForResume
|
|
628
|
+
console.log(`[task] Session ${taskId} pause timeout: ${timeoutError.message}`);
|
|
629
|
+
if (onEvent) {
|
|
630
|
+
onEvent({
|
|
631
|
+
type: 'task_stopped',
|
|
632
|
+
source: 'autonomous_task',
|
|
633
|
+
task_id: taskId,
|
|
634
|
+
reason: 'Pause timeout - no resume received',
|
|
635
|
+
timestamp: Date.now(),
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
break;
|
|
639
|
+
}
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Check budget
|
|
644
|
+
const remainingBudget = session.budget_usd - session.cost_usd;
|
|
645
|
+
if (remainingBudget <= 0) {
|
|
646
|
+
session.status = 'budget_paused';
|
|
647
|
+
session.updated_at = Date.now();
|
|
648
|
+
console.log(`[task] Session ${taskId} budget exhausted`);
|
|
649
|
+
if (onEvent) {
|
|
650
|
+
onEvent({
|
|
651
|
+
type: 'task_budget_hit',
|
|
652
|
+
source: 'autonomous_task',
|
|
653
|
+
task_id: taskId,
|
|
654
|
+
cost_usd: session.cost_usd,
|
|
655
|
+
budget_usd: session.budget_usd,
|
|
656
|
+
timestamp: Date.now(),
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
break;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Build prompt for this turn
|
|
663
|
+
let prompt;
|
|
664
|
+
if (session.turn_count === 0) {
|
|
665
|
+
// First turn: use the original task description
|
|
666
|
+
prompt = session.task;
|
|
667
|
+
} else if (session.auto_continue) {
|
|
668
|
+
// Auto-continuation enabled: use generic continuation
|
|
669
|
+
prompt = 'Continue working on the task.';
|
|
670
|
+
} else {
|
|
671
|
+
// Auto-continuation disabled: pause and wait for user message
|
|
672
|
+
session.status = 'paused';
|
|
673
|
+
session.updated_at = Date.now();
|
|
674
|
+
console.log(`[task] Turn ${session.turn_count + 1}: Auto-continue disabled, pausing for user input`);
|
|
675
|
+
if (onEvent) {
|
|
676
|
+
onEvent({
|
|
677
|
+
type: 'task_paused',
|
|
678
|
+
source: 'autonomous_task',
|
|
679
|
+
task_id: taskId,
|
|
680
|
+
reason: 'Waiting for user message (auto_continue disabled)',
|
|
681
|
+
turn_count: session.turn_count,
|
|
682
|
+
timestamp: Date.now(),
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
// Wait for resume with user message
|
|
686
|
+
try {
|
|
687
|
+
await waitForResume(taskId);
|
|
688
|
+
// Atomically get and clear pending_question to prevent race conditions
|
|
689
|
+
const userMessage = atomicGetAndClearMessage(taskId);
|
|
690
|
+
if (!userMessage) {
|
|
691
|
+
console.log(`[task] Resumed without user message, stopping task ${taskId}`);
|
|
692
|
+
session.status = 'stopped';
|
|
693
|
+
session.updated_at = Date.now();
|
|
694
|
+
break;
|
|
695
|
+
}
|
|
696
|
+
// Use the user's message as the prompt
|
|
697
|
+
prompt = userMessage;
|
|
698
|
+
} catch (timeoutError) {
|
|
699
|
+
console.log(`[task] Session ${taskId} pause timeout: ${timeoutError.message}`);
|
|
700
|
+
if (onEvent) {
|
|
701
|
+
onEvent({
|
|
702
|
+
type: 'task_stopped',
|
|
703
|
+
source: 'autonomous_task',
|
|
704
|
+
task_id: taskId,
|
|
705
|
+
reason: 'Pause timeout - no user message received',
|
|
706
|
+
timestamp: Date.now(),
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
break;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Execute Claude
|
|
714
|
+
session.turn_count++;
|
|
715
|
+
console.log(`[task] Turn ${session.turn_count} for ${taskId}`);
|
|
716
|
+
|
|
717
|
+
// Re-check status immediately before expensive operation (race condition fix)
|
|
718
|
+
const currentSession = taskSessions.get(taskId);
|
|
719
|
+
if (!currentSession || currentSession.status === 'stopped') {
|
|
720
|
+
console.log(`[task] Session ${taskId} stopped before execution`);
|
|
721
|
+
break;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
try {
|
|
725
|
+
// Determine which session to resume:
|
|
726
|
+
// - Turn 1: Resume parent session for context (if available)
|
|
727
|
+
// - Turn 2+: Resume child session from previous turn
|
|
728
|
+
const resumeSessionId = session.claude_session_id
|
|
729
|
+
? session.claude_session_id // Use child session from previous turns
|
|
730
|
+
: session.parent_claude_session_id; // Use parent session on first turn
|
|
731
|
+
|
|
732
|
+
if (resumeSessionId && session.turn_count === 1) {
|
|
733
|
+
console.log(`[task] Turn 1: Resuming parent session ${resumeSessionId} for context`);
|
|
734
|
+
} else if (resumeSessionId) {
|
|
735
|
+
console.log(`[task] Turn ${session.turn_count}: Resuming child session ${resumeSessionId}`);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const result = await executeClaudeHeadless({
|
|
739
|
+
prompt,
|
|
740
|
+
cwd: session.cwd,
|
|
741
|
+
resumeSessionId,
|
|
742
|
+
budgetUsd: remainingBudget,
|
|
743
|
+
taskId,
|
|
744
|
+
parentSessionId: session.teleportation_session_id,
|
|
745
|
+
onUpdate: (update) => {
|
|
746
|
+
if (onEvent) {
|
|
747
|
+
onEvent({
|
|
748
|
+
type: 'task_progress',
|
|
749
|
+
source: 'autonomous_task',
|
|
750
|
+
task_id: taskId,
|
|
751
|
+
update,
|
|
752
|
+
timestamp: Date.now(),
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
// Update session with result
|
|
759
|
+
session.claude_session_id = result.session_id || session.claude_session_id;
|
|
760
|
+
session.cost_usd += result.cost_usd || 0;
|
|
761
|
+
session.updated_at = Date.now();
|
|
762
|
+
|
|
763
|
+
// Add to history
|
|
764
|
+
session.history.push({
|
|
765
|
+
turn: session.turn_count,
|
|
766
|
+
prompt,
|
|
767
|
+
output: result.output?.slice(0, 2000), // Truncate for storage
|
|
768
|
+
cost_usd: result.cost_usd,
|
|
769
|
+
success: result.success,
|
|
770
|
+
usage: result.usage,
|
|
771
|
+
timestamp: Date.now(),
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
// Trim history to prevent unbounded growth
|
|
775
|
+
if (session.history.length > MAX_HISTORY_SIZE) {
|
|
776
|
+
session.history = session.history.slice(-MAX_HISTORY_SIZE);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Track consecutive failures for error recovery
|
|
780
|
+
if (!result.success) {
|
|
781
|
+
consecutiveFailures++;
|
|
782
|
+
console.error(`[task] Turn ${session.turn_count} failed (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}):`, result.error);
|
|
783
|
+
|
|
784
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
785
|
+
session.status = 'stopped';
|
|
786
|
+
session.updated_at = Date.now();
|
|
787
|
+
console.log(`[task] Session ${taskId} stopped due to ${MAX_CONSECUTIVE_FAILURES} consecutive failures`);
|
|
788
|
+
if (onEvent) {
|
|
789
|
+
onEvent({
|
|
790
|
+
type: 'task_stopped',
|
|
791
|
+
source: 'autonomous_task',
|
|
792
|
+
task_id: taskId,
|
|
793
|
+
reason: `${MAX_CONSECUTIVE_FAILURES} consecutive failures`,
|
|
794
|
+
cost_usd: session.cost_usd,
|
|
795
|
+
turn_count: session.turn_count,
|
|
796
|
+
timestamp: Date.now(),
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
break;
|
|
800
|
+
}
|
|
801
|
+
} else {
|
|
802
|
+
// Reset failure counter on success
|
|
803
|
+
consecutiveFailures = 0;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Mic-Handover Logic (Parity with Local CLI)
|
|
807
|
+
// 1. If tools were called, we MUST continue automatically (Task loop)
|
|
808
|
+
// 2. If no tools were called, Claude is "handing the mic" to the user.
|
|
809
|
+
|
|
810
|
+
const hasToolsMetadata = result.usage?.tools_used !== undefined || Array.isArray(result.tool_calls);
|
|
811
|
+
const toolsCalled = result.usage?.tools_used > 0 || (Array.isArray(result.tool_calls) && result.tool_calls.length > 0);
|
|
812
|
+
|
|
813
|
+
// If we have tool metadata and tools were used, auto-continue.
|
|
814
|
+
// If we DON'T have metadata (e.g. basic coder), fall back to text classification.
|
|
815
|
+
if (hasToolsMetadata && toolsCalled && result.success) {
|
|
816
|
+
console.log(`[task] Tools executed in turn ${session.turn_count}. Auto-continuing...`);
|
|
817
|
+
// Continue working - loop will iterate automatically
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// No tools called (or no metadata available) - Claude is done or has a question
|
|
822
|
+
console.log(`[task] ${hasToolsMetadata ? 'No tools executed' : 'No tool metadata available'}. Classifying response...`);
|
|
823
|
+
const classification = classifyResponse(result);
|
|
824
|
+
console.log(`[task] Classification: ${classification.action} (confidence: ${classification.confidence})`);
|
|
825
|
+
|
|
826
|
+
// Determine next action based on classification
|
|
827
|
+
switch (classification.action) {
|
|
828
|
+
case 'complete':
|
|
829
|
+
session.status = 'completed';
|
|
830
|
+
session.completed_at = Date.now();
|
|
831
|
+
session.updated_at = Date.now();
|
|
832
|
+
continueExecution = false;
|
|
833
|
+
console.log(`[task] Task completed for ${taskId}`);
|
|
834
|
+
|
|
835
|
+
// Remove lock file on completion
|
|
836
|
+
removeTaskLock(taskId);
|
|
837
|
+
|
|
838
|
+
if (onEvent) {
|
|
839
|
+
onEvent({
|
|
840
|
+
type: 'task_completed',
|
|
841
|
+
source: 'autonomous_task',
|
|
842
|
+
task_id: taskId,
|
|
843
|
+
output: result.output,
|
|
844
|
+
cost_usd: session.cost_usd,
|
|
845
|
+
turn_count: session.turn_count,
|
|
846
|
+
timestamp: Date.now(),
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
break;
|
|
850
|
+
|
|
851
|
+
case 'question':
|
|
852
|
+
session.status = 'waiting_input';
|
|
853
|
+
session.pending_question = classification.questionText;
|
|
854
|
+
session.updated_at = Date.now();
|
|
855
|
+
continueExecution = false;
|
|
856
|
+
console.log(`[task] Question detected for ${taskId}: ${classification.questionText?.slice(0, 100)}`);
|
|
857
|
+
if (onEvent) {
|
|
858
|
+
onEvent({
|
|
859
|
+
type: 'task_question',
|
|
860
|
+
source: 'autonomous_task',
|
|
861
|
+
task_id: taskId,
|
|
862
|
+
question: classification.questionText,
|
|
863
|
+
confidence: classification.confidence,
|
|
864
|
+
timestamp: Date.now(),
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
break;
|
|
868
|
+
|
|
869
|
+
case 'continue':
|
|
870
|
+
default:
|
|
871
|
+
// Continue working - loop will iterate
|
|
872
|
+
console.log(`[task] Continuing execution for ${taskId}`);
|
|
873
|
+
break;
|
|
874
|
+
}
|
|
875
|
+
} catch (error) {
|
|
876
|
+
console.error(`[task] Execution error for ${taskId}:`, error.message);
|
|
877
|
+
session.status = 'stopped';
|
|
878
|
+
session.updated_at = Date.now();
|
|
879
|
+
continueExecution = false;
|
|
880
|
+
|
|
881
|
+
// Remove lock file on error
|
|
882
|
+
removeTaskLock(taskId);
|
|
883
|
+
|
|
884
|
+
if (onEvent) {
|
|
885
|
+
onEvent({
|
|
886
|
+
type: 'task_stopped',
|
|
887
|
+
source: 'autonomous_task',
|
|
888
|
+
task_id: taskId,
|
|
889
|
+
reason: `Error: ${error.message}`,
|
|
890
|
+
timestamp: Date.now(),
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Wait for an task session to be resumed
|
|
899
|
+
* @param {string} taskId
|
|
900
|
+
* @returns {Promise<void>}
|
|
901
|
+
*/
|
|
902
|
+
/**
|
|
903
|
+
* Wait for an task session to be resumed with timeout protection
|
|
904
|
+
* @param {string} taskId
|
|
905
|
+
* @param {number} timeoutMs - Maximum wait time (default: 30 minutes)
|
|
906
|
+
* @returns {Promise<void>}
|
|
907
|
+
* @throws {Error} If timeout is reached
|
|
908
|
+
*/
|
|
909
|
+
async function waitForResume(taskId, timeoutMs = WAIT_FOR_RESUME_TIMEOUT_MS) {
|
|
910
|
+
return new Promise((resolve, reject) => {
|
|
911
|
+
const startTime = Date.now();
|
|
912
|
+
|
|
913
|
+
const checkInterval = setInterval(() => {
|
|
914
|
+
const session = taskSessions.get(taskId);
|
|
915
|
+
|
|
916
|
+
// Check for timeout
|
|
917
|
+
if (Date.now() - startTime > timeoutMs) {
|
|
918
|
+
clearInterval(checkInterval);
|
|
919
|
+
console.log(`[task] Session ${taskId} resume timeout after ${timeoutMs}ms`);
|
|
920
|
+
|
|
921
|
+
// Mark session as stopped due to timeout
|
|
922
|
+
if (session) {
|
|
923
|
+
session.status = 'stopped';
|
|
924
|
+
session.updated_at = Date.now();
|
|
925
|
+
|
|
926
|
+
// Remove lock file on timeout
|
|
927
|
+
removeTaskLock(taskId);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
reject(new Error(`Resume timeout after ${timeoutMs}ms`));
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Check if session no longer paused
|
|
935
|
+
if (!session || session.status !== 'paused') {
|
|
936
|
+
clearInterval(checkInterval);
|
|
937
|
+
resolve();
|
|
938
|
+
}
|
|
939
|
+
}, POLLING_INTERVAL_MS);
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Atomically get and clear pending_question from a session
|
|
945
|
+
* Prevents race conditions where the message could be modified between read and clear
|
|
946
|
+
* @param {string} taskId
|
|
947
|
+
* @returns {string|null} The pending question, or null if none exists
|
|
948
|
+
*/
|
|
949
|
+
function atomicGetAndClearMessage(taskId) {
|
|
950
|
+
const session = taskSessions.get(taskId);
|
|
951
|
+
if (!session) {
|
|
952
|
+
return null;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const message = session.pending_question;
|
|
956
|
+
session.pending_question = null;
|
|
957
|
+
return message;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* Stop an task task
|
|
962
|
+
*
|
|
963
|
+
* @param {string} taskId - Task session ID
|
|
964
|
+
* @returns {{ success: boolean, reason?: string }}
|
|
965
|
+
*/
|
|
966
|
+
export function stopTask(taskId) {
|
|
967
|
+
const session = taskSessions.get(taskId);
|
|
968
|
+
if (!session) {
|
|
969
|
+
return { success: false, reason: 'Session not found' };
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Kill running process if any
|
|
973
|
+
const proc = runningProcesses.get(taskId);
|
|
974
|
+
if (proc) {
|
|
975
|
+
proc.kill('SIGTERM');
|
|
976
|
+
runningProcesses.delete(taskId);
|
|
977
|
+
console.log(`[task] Killed process for ${taskId}`);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
session.status = 'stopped';
|
|
981
|
+
session.updated_at = Date.now();
|
|
982
|
+
|
|
983
|
+
// Remove lock file
|
|
984
|
+
removeTaskLock(taskId);
|
|
985
|
+
|
|
986
|
+
return { success: true };
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Stop all active task tasks and kill their processes
|
|
991
|
+
*/
|
|
992
|
+
export function stopAllTasks() {
|
|
993
|
+
console.log(`[task] Stopping all tasks (${runningProcesses.size} processes active)`);
|
|
994
|
+
|
|
995
|
+
for (const [id, proc] of runningProcesses) {
|
|
996
|
+
try {
|
|
997
|
+
let killTimer = null;
|
|
998
|
+
|
|
999
|
+
// 1. Setup exit handler FIRST to ensure we catch early exits
|
|
1000
|
+
proc.once('exit', () => {
|
|
1001
|
+
if (killTimer) {
|
|
1002
|
+
clearTimeout(killTimer);
|
|
1003
|
+
killTimer = null;
|
|
1004
|
+
}
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
// 2. Try graceful SIGTERM
|
|
1008
|
+
proc.kill('SIGTERM');
|
|
1009
|
+
|
|
1010
|
+
// 3. Setup watchdog timer
|
|
1011
|
+
killTimer = setTimeout(() => {
|
|
1012
|
+
try {
|
|
1013
|
+
if (proc.exitCode === null) {
|
|
1014
|
+
proc.kill('SIGKILL');
|
|
1015
|
+
console.log(`[task] Force killed task ${id} (no SIGTERM response)`);
|
|
1016
|
+
}
|
|
1017
|
+
} catch (e) {}
|
|
1018
|
+
}, SIGTERM_TIMEOUT_MS);
|
|
1019
|
+
|
|
1020
|
+
console.log(`[task] Signal sent to task ${id}`);
|
|
1021
|
+
|
|
1022
|
+
// Remove lock file
|
|
1023
|
+
removeTaskLock(id);
|
|
1024
|
+
} catch (err) {
|
|
1025
|
+
console.error(`[task] Failed to send signal to task ${id}:`, err.message);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Clear the map after signals are sent
|
|
1030
|
+
runningProcesses.clear();
|
|
1031
|
+
|
|
1032
|
+
for (const session of taskSessions.values()) {
|
|
1033
|
+
if (session.status === 'running' || session.status === 'paused' || session.status === 'budget_paused') {
|
|
1034
|
+
session.status = 'stopped';
|
|
1035
|
+
session.updated_at = Date.now();
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* Pause an task task
|
|
1042
|
+
*
|
|
1043
|
+
* @param {string} taskId - Task session ID
|
|
1044
|
+
* @returns {{ success: boolean, reason?: string }}
|
|
1045
|
+
*/
|
|
1046
|
+
export function pauseTask(taskId) {
|
|
1047
|
+
const session = taskSessions.get(taskId);
|
|
1048
|
+
if (!session) {
|
|
1049
|
+
return { success: false, reason: 'Session not found' };
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
if (session.status !== 'running') {
|
|
1053
|
+
return { success: false, reason: `Cannot pause session in ${session.status} status` };
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
session.status = 'paused';
|
|
1057
|
+
session.updated_at = Date.now();
|
|
1058
|
+
|
|
1059
|
+
return { success: true };
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Resume a paused task task
|
|
1064
|
+
*
|
|
1065
|
+
* @param {string} taskId - Task session ID
|
|
1066
|
+
* @param {Function} [onEvent] - Event callback
|
|
1067
|
+
* @returns {{ success: boolean, reason?: string }}
|
|
1068
|
+
*/
|
|
1069
|
+
export function resumeTask(taskId, onEvent) {
|
|
1070
|
+
const session = taskSessions.get(taskId);
|
|
1071
|
+
if (!session) {
|
|
1072
|
+
return { success: false, reason: 'Session not found' };
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
if (session.status !== 'paused' && session.status !== 'budget_paused') {
|
|
1076
|
+
return { success: false, reason: `Cannot resume session in ${session.status} status` };
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
session.status = 'running';
|
|
1080
|
+
session.updated_at = Date.now();
|
|
1081
|
+
|
|
1082
|
+
// Restart execution loop
|
|
1083
|
+
executeTaskLoop(taskId, onEvent).catch(err => {
|
|
1084
|
+
console.error(`[task] Resume loop error for ${taskId}:`, err.message);
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
return { success: true };
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Answer a question for an task task
|
|
1092
|
+
*
|
|
1093
|
+
* @param {string} taskId - Task session ID
|
|
1094
|
+
* @param {string} answer - User's answer to the question
|
|
1095
|
+
* @param {Function} [onEvent] - Event callback
|
|
1096
|
+
* @returns {{ success: boolean, reason?: string }}
|
|
1097
|
+
*/
|
|
1098
|
+
export async function answerTaskQuestion(taskId, answer, onEvent) {
|
|
1099
|
+
const session = taskSessions.get(taskId);
|
|
1100
|
+
if (!session) {
|
|
1101
|
+
return { success: false, reason: 'Session not found' };
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
if (session.status !== 'waiting_input' && session.status !== 'paused') {
|
|
1105
|
+
return { success: false, reason: `Cannot send message in ${session.status} status` };
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// Emit user input event
|
|
1109
|
+
if (onEvent) {
|
|
1110
|
+
onEvent({
|
|
1111
|
+
type: 'task_user_input',
|
|
1112
|
+
source: 'autonomous_task',
|
|
1113
|
+
task_id: taskId,
|
|
1114
|
+
question: session.pending_question,
|
|
1115
|
+
answer,
|
|
1116
|
+
timestamp: Date.now(),
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// Handle based on current status
|
|
1121
|
+
if (session.status === 'paused') {
|
|
1122
|
+
// Task is paused waiting for user message (auto_continue disabled)
|
|
1123
|
+
// Set the message as pending_question and resume the execution loop
|
|
1124
|
+
session.pending_question = answer;
|
|
1125
|
+
session.status = 'running';
|
|
1126
|
+
session.updated_at = Date.now();
|
|
1127
|
+
|
|
1128
|
+
// The execution loop will pick up the pending_question and use it as the prompt
|
|
1129
|
+
return { success: true };
|
|
1130
|
+
} else {
|
|
1131
|
+
// Task is waiting_input (Claude asked a question)
|
|
1132
|
+
// Execute immediately with the user's answer
|
|
1133
|
+
session.pending_question = null;
|
|
1134
|
+
session.status = 'running';
|
|
1135
|
+
session.updated_at = Date.now();
|
|
1136
|
+
|
|
1137
|
+
const remainingBudget = session.budget_usd - session.cost_usd;
|
|
1138
|
+
|
|
1139
|
+
try {
|
|
1140
|
+
session.turn_count++;
|
|
1141
|
+
const result = await executeClaudeHeadless({
|
|
1142
|
+
prompt: answer,
|
|
1143
|
+
cwd: session.cwd,
|
|
1144
|
+
resumeSessionId: session.claude_session_id,
|
|
1145
|
+
budgetUsd: remainingBudget,
|
|
1146
|
+
taskId,
|
|
1147
|
+
parentSessionId: session.teleportation_session_id,
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
// Update session
|
|
1151
|
+
session.claude_session_id = result.session_id || session.claude_session_id;
|
|
1152
|
+
session.cost_usd += result.cost_usd || 0;
|
|
1153
|
+
session.updated_at = Date.now();
|
|
1154
|
+
|
|
1155
|
+
// Add to history
|
|
1156
|
+
session.history.push({
|
|
1157
|
+
turn: session.turn_count,
|
|
1158
|
+
prompt: `[User Answer] ${answer}`,
|
|
1159
|
+
output: result.output?.slice(0, 2000),
|
|
1160
|
+
cost_usd: result.cost_usd,
|
|
1161
|
+
success: result.success,
|
|
1162
|
+
timestamp: Date.now(),
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
// Resume the execution loop
|
|
1166
|
+
executeTaskLoop(taskId, onEvent).catch(err => {
|
|
1167
|
+
console.error(`[task] Answer loop error for ${taskId}:`, err.message);
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
return { success: true };
|
|
1171
|
+
} catch (error) {
|
|
1172
|
+
session.status = 'stopped';
|
|
1173
|
+
session.updated_at = Date.now();
|
|
1174
|
+
return { success: false, reason: error.message };
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* Get task session status
|
|
1181
|
+
*
|
|
1182
|
+
* @param {string} taskId - Task session ID
|
|
1183
|
+
* @returns {TaskSession|null}
|
|
1184
|
+
*/
|
|
1185
|
+
export function getTaskSession(taskId) {
|
|
1186
|
+
return taskSessions.get(taskId) || null;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* List all task sessions for a teleportation session
|
|
1191
|
+
*
|
|
1192
|
+
* @param {string} session_id - Teleportation session ID
|
|
1193
|
+
* @returns {TaskSession[]}
|
|
1194
|
+
*/
|
|
1195
|
+
export function listTaskSessions(session_id) {
|
|
1196
|
+
const sessions = [];
|
|
1197
|
+
for (const [, session] of taskSessions) {
|
|
1198
|
+
if (session.teleportation_session_id === session_id) {
|
|
1199
|
+
sessions.push(session);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
return sessions;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* Increase budget for an task task
|
|
1207
|
+
*
|
|
1208
|
+
* @param {string} taskId - Task session ID
|
|
1209
|
+
* @param {number} additionalBudget - Additional budget in USD
|
|
1210
|
+
* @returns {{ success: boolean, new_budget?: number, reason?: string }}
|
|
1211
|
+
*/
|
|
1212
|
+
export function increaseBudget(taskId, additionalBudget) {
|
|
1213
|
+
const session = taskSessions.get(taskId);
|
|
1214
|
+
if (!session) {
|
|
1215
|
+
return { success: false, reason: 'Session not found' };
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
if (additionalBudget <= 0) {
|
|
1219
|
+
return { success: false, reason: 'Additional budget must be positive' };
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
session.budget_usd += additionalBudget;
|
|
1223
|
+
session.updated_at = Date.now();
|
|
1224
|
+
|
|
1225
|
+
return { success: true, new_budget: session.budget_usd };
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// Export for testing
|
|
1229
|
+
export const __test = {
|
|
1230
|
+
taskSessions,
|
|
1231
|
+
runningProcesses,
|
|
1232
|
+
executeClaudeHeadless,
|
|
1233
|
+
executeTaskLoop,
|
|
1234
|
+
generateTaskId,
|
|
1235
|
+
};
|