@masslessai/push-todo 4.1.9 → 4.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/lib/cli.js +10 -1
- package/lib/cron.js +64 -8
- package/lib/daemon.js +428 -2
- package/package.json +1 -1
package/lib/cli.js
CHANGED
|
@@ -121,6 +121,7 @@ ${bold('CRON (scheduled jobs):')}
|
|
|
121
121
|
--cron <expression> 5-field cron expression
|
|
122
122
|
--notify <message> Send Mac notification
|
|
123
123
|
--create-todo <content> Create todo reminder
|
|
124
|
+
--health-check <path> Run codebase health check (scope: general|tests|deps)
|
|
124
125
|
push-todo cron list List all cron jobs
|
|
125
126
|
push-todo cron remove <id> Remove a cron job by ID
|
|
126
127
|
|
|
@@ -182,6 +183,8 @@ const options = {
|
|
|
182
183
|
'create-todo': { type: 'string' },
|
|
183
184
|
'notify': { type: 'string' },
|
|
184
185
|
'queue-execution': { type: 'string' },
|
|
186
|
+
'health-check': { type: 'string' },
|
|
187
|
+
'scope': { type: 'string' },
|
|
185
188
|
// Skill CLI options (Phase 3)
|
|
186
189
|
'report-progress': { type: 'string' },
|
|
187
190
|
'phase': { type: 'string' },
|
|
@@ -688,8 +691,14 @@ export async function run(argv) {
|
|
|
688
691
|
action = { type: 'notify', content: values.notify };
|
|
689
692
|
} else if (values['queue-execution']) {
|
|
690
693
|
action = { type: 'queue-execution', todoId: values['queue-execution'] };
|
|
694
|
+
} else if (values['health-check']) {
|
|
695
|
+
action = {
|
|
696
|
+
type: 'health-check',
|
|
697
|
+
projectPath: values['health-check'],
|
|
698
|
+
scope: values.scope || 'general',
|
|
699
|
+
};
|
|
691
700
|
} else {
|
|
692
|
-
console.error(red('Action required: --create-todo, --notify, or --
|
|
701
|
+
console.error(red('Action required: --create-todo, --notify, --queue-execution, or --health-check'));
|
|
693
702
|
process.exit(1);
|
|
694
703
|
}
|
|
695
704
|
|
package/lib/cron.js
CHANGED
|
@@ -301,8 +301,11 @@ export function listJobs() {
|
|
|
301
301
|
*
|
|
302
302
|
* @param {Object} job - Job object
|
|
303
303
|
* @param {Function} [logFn] - Optional log function
|
|
304
|
+
* @param {Object} [context] - Injected dependencies from daemon
|
|
305
|
+
* @param {Function} [context.apiRequest] - API request function
|
|
306
|
+
* @param {Function} [context.spawnHealthCheck] - Spawn a health check Claude session
|
|
304
307
|
*/
|
|
305
|
-
async function executeAction(job, logFn) {
|
|
308
|
+
async function executeAction(job, logFn, context = {}) {
|
|
306
309
|
const log = logFn || (() => {});
|
|
307
310
|
|
|
308
311
|
switch (job.action.type) {
|
|
@@ -312,17 +315,69 @@ async function executeAction(job, logFn) {
|
|
|
312
315
|
break;
|
|
313
316
|
|
|
314
317
|
case 'create-todo':
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
+
if (context.apiRequest) {
|
|
319
|
+
try {
|
|
320
|
+
const response = await context.apiRequest('create-todo', {
|
|
321
|
+
method: 'POST',
|
|
322
|
+
body: JSON.stringify({
|
|
323
|
+
title: job.action.content || job.name,
|
|
324
|
+
normalizedContent: job.action.detail || null,
|
|
325
|
+
isBacklog: job.action.backlog || false,
|
|
326
|
+
createdByClient: 'daemon-cron',
|
|
327
|
+
}),
|
|
328
|
+
});
|
|
329
|
+
if (response.ok) {
|
|
330
|
+
const data = await response.json();
|
|
331
|
+
log(`Cron "${job.name}": created todo #${data.todo?.displayNumber || '?'}`);
|
|
332
|
+
} else {
|
|
333
|
+
log(`Cron "${job.name}": create-todo failed (HTTP ${response.status})`);
|
|
334
|
+
// Fall back to notification
|
|
335
|
+
sendMacNotification('Push: Scheduled Todo', job.action.content || job.name);
|
|
336
|
+
}
|
|
337
|
+
} catch (error) {
|
|
338
|
+
log(`Cron "${job.name}": create-todo error: ${error.message}`);
|
|
339
|
+
sendMacNotification('Push: Scheduled Todo', job.action.content || job.name);
|
|
340
|
+
}
|
|
341
|
+
} else {
|
|
342
|
+
sendMacNotification('Push: Scheduled Todo', job.action.content || job.name);
|
|
343
|
+
log(`Cron "${job.name}": todo reminder sent (notification, no API context)`);
|
|
344
|
+
}
|
|
318
345
|
break;
|
|
319
346
|
|
|
320
347
|
case 'queue-execution':
|
|
321
|
-
// Requires todoId — log warning if missing
|
|
322
348
|
if (!job.action.todoId) {
|
|
323
349
|
log(`Cron "${job.name}": queue-execution requires todoId, skipping`);
|
|
350
|
+
} else if (context.apiRequest) {
|
|
351
|
+
try {
|
|
352
|
+
const response = await context.apiRequest('update-task-execution', {
|
|
353
|
+
method: 'PATCH',
|
|
354
|
+
body: JSON.stringify({
|
|
355
|
+
todoId: job.action.todoId,
|
|
356
|
+
status: 'queued',
|
|
357
|
+
}),
|
|
358
|
+
});
|
|
359
|
+
if (response.ok) {
|
|
360
|
+
log(`Cron "${job.name}": queued todo ${job.action.todoId} for execution`);
|
|
361
|
+
} else {
|
|
362
|
+
log(`Cron "${job.name}": queue-execution failed (HTTP ${response.status})`);
|
|
363
|
+
}
|
|
364
|
+
} catch (error) {
|
|
365
|
+
log(`Cron "${job.name}": queue-execution error: ${error.message}`);
|
|
366
|
+
}
|
|
367
|
+
} else {
|
|
368
|
+
log(`Cron "${job.name}": queue-execution not available (no API context)`);
|
|
369
|
+
}
|
|
370
|
+
break;
|
|
371
|
+
|
|
372
|
+
case 'health-check':
|
|
373
|
+
if (context.spawnHealthCheck) {
|
|
374
|
+
try {
|
|
375
|
+
await context.spawnHealthCheck(job, log);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
log(`Cron "${job.name}": health-check error: ${error.message}`);
|
|
378
|
+
}
|
|
324
379
|
} else {
|
|
325
|
-
log(`Cron "${job.name}":
|
|
380
|
+
log(`Cron "${job.name}": health-check not available (no daemon context)`);
|
|
326
381
|
}
|
|
327
382
|
break;
|
|
328
383
|
|
|
@@ -336,8 +391,9 @@ async function executeAction(job, logFn) {
|
|
|
336
391
|
* Called from daemon poll loop on every cycle.
|
|
337
392
|
*
|
|
338
393
|
* @param {Function} [logFn] - Optional log function
|
|
394
|
+
* @param {Object} [context] - Injected dependencies (apiRequest, spawnHealthCheck)
|
|
339
395
|
*/
|
|
340
|
-
export async function checkAndRunDueJobs(logFn) {
|
|
396
|
+
export async function checkAndRunDueJobs(logFn, context = {}) {
|
|
341
397
|
const jobs = loadJobs();
|
|
342
398
|
if (jobs.length === 0) return;
|
|
343
399
|
|
|
@@ -353,7 +409,7 @@ export async function checkAndRunDueJobs(logFn) {
|
|
|
353
409
|
|
|
354
410
|
// Job is due — execute
|
|
355
411
|
try {
|
|
356
|
-
await executeAction(job, logFn);
|
|
412
|
+
await executeAction(job, logFn, context);
|
|
357
413
|
} catch (error) {
|
|
358
414
|
if (logFn) logFn(`Cron "${job.name}" execution failed: ${error.message}`);
|
|
359
415
|
}
|
package/lib/daemon.js
CHANGED
|
@@ -1514,6 +1514,295 @@ function handleCancellation(displayNumber, source) {
|
|
|
1514
1514
|
});
|
|
1515
1515
|
}
|
|
1516
1516
|
|
|
1517
|
+
// ==================== Message Injection (Phase 4) ====================
|
|
1518
|
+
|
|
1519
|
+
const MESSAGE_POLL_INTERVAL_MS = 30000; // Check for messages every 30s
|
|
1520
|
+
let lastMessagePoll = 0;
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* Poll for urgent messages directed to running tasks.
|
|
1524
|
+
* Called from checkTimeouts() on each poll cycle.
|
|
1525
|
+
*
|
|
1526
|
+
* Normal messages: agent picks up via push-todo --check-messages at natural pauses.
|
|
1527
|
+
* Urgent messages: daemon kills the session and respawns with --continue + injected message.
|
|
1528
|
+
*/
|
|
1529
|
+
async function checkUrgentMessages() {
|
|
1530
|
+
const now = Date.now();
|
|
1531
|
+
if (now - lastMessagePoll < MESSAGE_POLL_INTERVAL_MS) return;
|
|
1532
|
+
lastMessagePoll = now;
|
|
1533
|
+
|
|
1534
|
+
if (runningTasks.size === 0) return;
|
|
1535
|
+
|
|
1536
|
+
for (const [displayNumber, taskInfo] of runningTasks) {
|
|
1537
|
+
const info = taskDetails.get(displayNumber) || {};
|
|
1538
|
+
const taskId = info.taskId;
|
|
1539
|
+
if (!taskId) continue;
|
|
1540
|
+
|
|
1541
|
+
// Skip tasks already being cancelled or injected
|
|
1542
|
+
if (info.phase === 'cancelled' || info.phase === 'injecting_message') continue;
|
|
1543
|
+
|
|
1544
|
+
try {
|
|
1545
|
+
const params = new URLSearchParams({
|
|
1546
|
+
todo_id: taskId,
|
|
1547
|
+
direction: 'to_agent',
|
|
1548
|
+
});
|
|
1549
|
+
|
|
1550
|
+
const response = await apiRequest(`task-messages?${params}`, {}, false);
|
|
1551
|
+
if (!response.ok) continue;
|
|
1552
|
+
|
|
1553
|
+
const data = await response.json();
|
|
1554
|
+
const messages = data.messages || [];
|
|
1555
|
+
|
|
1556
|
+
// Only process urgent messages via kill+continue
|
|
1557
|
+
const urgentMessages = messages.filter(m => m.is_urgent || m.type === 'urgent');
|
|
1558
|
+
if (urgentMessages.length === 0) continue;
|
|
1559
|
+
|
|
1560
|
+
// Combine all urgent messages into one injection
|
|
1561
|
+
const combinedMessage = urgentMessages
|
|
1562
|
+
.map(m => m.message)
|
|
1563
|
+
.join('\n\n');
|
|
1564
|
+
|
|
1565
|
+
log(`Task #${displayNumber}: ${urgentMessages.length} urgent message(s) detected, initiating kill+continue`);
|
|
1566
|
+
|
|
1567
|
+
// Mark messages as read
|
|
1568
|
+
for (const msg of urgentMessages) {
|
|
1569
|
+
apiRequest('task-messages', {
|
|
1570
|
+
method: 'PATCH',
|
|
1571
|
+
body: JSON.stringify({ message_id: msg.id }),
|
|
1572
|
+
}).catch(() => {}); // fire-and-forget
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// Kill the current session and respawn with injected message
|
|
1576
|
+
await injectMessageViaKillContinue(displayNumber, combinedMessage);
|
|
1577
|
+
} catch (err) {
|
|
1578
|
+
log(`Task #${displayNumber}: message poll failed (non-fatal): ${err.message}`);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* Kill the current Claude session and respawn with --continue, injecting a human message.
|
|
1585
|
+
*
|
|
1586
|
+
* Flow:
|
|
1587
|
+
* 1. SIGTERM the running process
|
|
1588
|
+
* 2. Wait for it to exit (handleTaskCompletion sees phase='injecting_message' and skips cleanup)
|
|
1589
|
+
* 3. Respawn with `claude --continue <sessionId> -p "Human sent: <message>"`
|
|
1590
|
+
*
|
|
1591
|
+
* The ~12s penalty is for Claude to reload context from the session transcript.
|
|
1592
|
+
*/
|
|
1593
|
+
async function injectMessageViaKillContinue(displayNumber, message) {
|
|
1594
|
+
const taskInfo = runningTasks.get(displayNumber);
|
|
1595
|
+
if (!taskInfo) return;
|
|
1596
|
+
|
|
1597
|
+
const sessionId = taskInfo.sessionId;
|
|
1598
|
+
const projectPath = taskInfo.projectPath || taskProjectPaths.get(displayNumber);
|
|
1599
|
+
const info = taskDetails.get(displayNumber) || {};
|
|
1600
|
+
const taskId = info.taskId;
|
|
1601
|
+
|
|
1602
|
+
// Mark phase so handleTaskCompletion knows to respawn instead of cleaning up
|
|
1603
|
+
updateTaskDetail(displayNumber, {
|
|
1604
|
+
phase: 'injecting_message',
|
|
1605
|
+
detail: `Injecting message: ${message.slice(0, 50)}...`
|
|
1606
|
+
});
|
|
1607
|
+
|
|
1608
|
+
// Report the injection event to Supabase
|
|
1609
|
+
apiRequest('update-task-execution', {
|
|
1610
|
+
method: 'PATCH',
|
|
1611
|
+
body: JSON.stringify({
|
|
1612
|
+
todoId: taskId,
|
|
1613
|
+
displayNumber,
|
|
1614
|
+
event: {
|
|
1615
|
+
type: 'message_injected',
|
|
1616
|
+
timestamp: new Date().toISOString(),
|
|
1617
|
+
machineName: getMachineName() || undefined,
|
|
1618
|
+
summary: `Human message injected: ${message.slice(0, 100)}`,
|
|
1619
|
+
}
|
|
1620
|
+
})
|
|
1621
|
+
}).catch(() => {});
|
|
1622
|
+
|
|
1623
|
+
// Store the message for respawn (handleTaskCompletion will read this)
|
|
1624
|
+
taskInfo.pendingInjection = { message, sessionId, projectPath, taskId };
|
|
1625
|
+
|
|
1626
|
+
// Kill the current process
|
|
1627
|
+
try {
|
|
1628
|
+
taskInfo.process.kill('SIGTERM');
|
|
1629
|
+
} catch (err) {
|
|
1630
|
+
log(`Task #${displayNumber}: SIGTERM for injection failed: ${err.message}`);
|
|
1631
|
+
// Reset phase if kill failed
|
|
1632
|
+
updateTaskDetail(displayNumber, { phase: 'executing' });
|
|
1633
|
+
delete taskInfo.pendingInjection;
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
/**
|
|
1638
|
+
* Respawn a Claude session after kill, with the human message injected.
|
|
1639
|
+
* Called from handleTaskCompletion when phase === 'injecting_message'.
|
|
1640
|
+
*/
|
|
1641
|
+
function respawnWithInjectedMessage(displayNumber) {
|
|
1642
|
+
const taskInfo = runningTasks.get(displayNumber);
|
|
1643
|
+
if (!taskInfo || !taskInfo.pendingInjection) {
|
|
1644
|
+
log(`Task #${displayNumber}: no pending injection found, cannot respawn`);
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
const { message, sessionId, projectPath, taskId } = taskInfo.pendingInjection;
|
|
1649
|
+
delete taskInfo.pendingInjection;
|
|
1650
|
+
|
|
1651
|
+
const injectionPrompt = `IMPORTANT: The human sent you an urgent message while you were working:\n\n---\n${message}\n---\n\nPlease address this message and then continue with your task.`;
|
|
1652
|
+
|
|
1653
|
+
const allowedTools = [
|
|
1654
|
+
'Read', 'Edit', 'Write', 'Glob', 'Grep',
|
|
1655
|
+
'Bash(git *)',
|
|
1656
|
+
'Bash(npm *)', 'Bash(npx *)', 'Bash(yarn *)',
|
|
1657
|
+
'Bash(python *)', 'Bash(python3 *)', 'Bash(pip *)', 'Bash(pip3 *)',
|
|
1658
|
+
'Bash(push-todo *)',
|
|
1659
|
+
'Task'
|
|
1660
|
+
].join(',');
|
|
1661
|
+
|
|
1662
|
+
// Generate new session ID for the respawned session
|
|
1663
|
+
const newSessionId = randomUUID();
|
|
1664
|
+
|
|
1665
|
+
const claudeArgs = [
|
|
1666
|
+
'--continue', sessionId,
|
|
1667
|
+
'-p', injectionPrompt,
|
|
1668
|
+
'--allowedTools', allowedTools,
|
|
1669
|
+
'--output-format', 'stream-json',
|
|
1670
|
+
'--permission-mode', 'bypassPermissions',
|
|
1671
|
+
'--session-id', newSessionId,
|
|
1672
|
+
];
|
|
1673
|
+
|
|
1674
|
+
log(`Task #${displayNumber}: respawning with injected message (session: ${sessionId} -> ${newSessionId})`);
|
|
1675
|
+
|
|
1676
|
+
try {
|
|
1677
|
+
const child = spawn('claude', claudeArgs, {
|
|
1678
|
+
cwd: projectPath || process.cwd(),
|
|
1679
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1680
|
+
env: (() => {
|
|
1681
|
+
const env = { ...process.env, PUSH_TASK_ID: taskId, PUSH_DISPLAY_NUMBER: String(displayNumber) };
|
|
1682
|
+
delete env.CLAUDECODE;
|
|
1683
|
+
delete env.CLAUDE_CODE_ENTRYPOINT;
|
|
1684
|
+
return env;
|
|
1685
|
+
})()
|
|
1686
|
+
});
|
|
1687
|
+
|
|
1688
|
+
// Replace the old process info
|
|
1689
|
+
taskInfo.process = child;
|
|
1690
|
+
taskInfo.sessionId = newSessionId;
|
|
1691
|
+
taskInfo.startTime = taskInfo.startTime; // Keep original start time
|
|
1692
|
+
|
|
1693
|
+
// Re-initialize stream parsing state
|
|
1694
|
+
taskStreamLineBuffer.set(displayNumber, '');
|
|
1695
|
+
taskActivityState.set(displayNumber, {
|
|
1696
|
+
filesRead: new Set(), filesEdited: new Set(),
|
|
1697
|
+
currentTool: null, lastText: '', sessionId: null
|
|
1698
|
+
});
|
|
1699
|
+
taskLastStreamProgress.set(displayNumber, Date.now());
|
|
1700
|
+
taskLastOutput.set(displayNumber, Date.now());
|
|
1701
|
+
taskStdoutBuffer.set(displayNumber, []);
|
|
1702
|
+
taskStderrBuffer.set(displayNumber, []);
|
|
1703
|
+
taskLastHeartbeat.set(displayNumber, Date.now());
|
|
1704
|
+
|
|
1705
|
+
// Re-attach stdout handler (same as executeTask)
|
|
1706
|
+
child.stdout.on('data', (data) => {
|
|
1707
|
+
taskLastOutput.set(displayNumber, Date.now());
|
|
1708
|
+
|
|
1709
|
+
const pending = (taskStreamLineBuffer.get(displayNumber) || '') + data.toString();
|
|
1710
|
+
const lines = pending.split('\n');
|
|
1711
|
+
taskStreamLineBuffer.set(displayNumber, lines.pop() || '');
|
|
1712
|
+
|
|
1713
|
+
for (const line of lines) {
|
|
1714
|
+
if (!line.trim()) continue;
|
|
1715
|
+
|
|
1716
|
+
const buffer = taskStdoutBuffer.get(displayNumber) || [];
|
|
1717
|
+
buffer.push(line);
|
|
1718
|
+
if (buffer.length > 50) buffer.shift();
|
|
1719
|
+
taskStdoutBuffer.set(displayNumber, buffer);
|
|
1720
|
+
|
|
1721
|
+
const event = parseStreamJsonLine(line);
|
|
1722
|
+
if (event) {
|
|
1723
|
+
processStreamEvent(displayNumber, event);
|
|
1724
|
+
} else {
|
|
1725
|
+
if (line.includes('[push-confirm] Waiting for')) {
|
|
1726
|
+
updateTaskDetail(displayNumber, {
|
|
1727
|
+
phase: 'awaiting_confirmation',
|
|
1728
|
+
detail: 'Waiting for user confirmation on iPhone'
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
const stuckReason = checkStuckPatterns(displayNumber, line);
|
|
1732
|
+
if (stuckReason) {
|
|
1733
|
+
log(`Task #${displayNumber} may be stuck: ${stuckReason}`);
|
|
1734
|
+
updateTaskDetail(displayNumber, { phase: 'stuck', detail: `Waiting for input: ${stuckReason}` });
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
});
|
|
1739
|
+
|
|
1740
|
+
child.stderr.on('data', (data) => {
|
|
1741
|
+
const lines = data.toString().split('\n');
|
|
1742
|
+
for (const line of lines) {
|
|
1743
|
+
if (line.trim()) {
|
|
1744
|
+
const buffer = taskStderrBuffer.get(displayNumber) || [];
|
|
1745
|
+
buffer.push(line);
|
|
1746
|
+
if (buffer.length > 20) buffer.shift();
|
|
1747
|
+
taskStderrBuffer.set(displayNumber, buffer);
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
});
|
|
1751
|
+
|
|
1752
|
+
child.on('close', (code) => {
|
|
1753
|
+
handleTaskCompletion(displayNumber, code);
|
|
1754
|
+
});
|
|
1755
|
+
|
|
1756
|
+
child.on('error', async (error) => {
|
|
1757
|
+
logError(`Task #${displayNumber} respawn error: ${error.message}`);
|
|
1758
|
+
runningTasks.delete(displayNumber);
|
|
1759
|
+
await updateTaskStatus(displayNumber, 'failed', { error: `Respawn failed: ${error.message}` }, taskId);
|
|
1760
|
+
taskDetails.delete(displayNumber);
|
|
1761
|
+
taskLastHeartbeat.delete(displayNumber);
|
|
1762
|
+
taskStreamLineBuffer.delete(displayNumber);
|
|
1763
|
+
taskActivityState.delete(displayNumber);
|
|
1764
|
+
taskLastStreamProgress.delete(displayNumber);
|
|
1765
|
+
updateStatusFile();
|
|
1766
|
+
});
|
|
1767
|
+
|
|
1768
|
+
updateTaskDetail(displayNumber, {
|
|
1769
|
+
phase: 'executing',
|
|
1770
|
+
detail: 'Resumed with injected message',
|
|
1771
|
+
claudePid: child.pid
|
|
1772
|
+
});
|
|
1773
|
+
|
|
1774
|
+
// Update session ID in Supabase
|
|
1775
|
+
apiRequest('update-task-execution', {
|
|
1776
|
+
method: 'PATCH',
|
|
1777
|
+
body: JSON.stringify({
|
|
1778
|
+
todoId: taskId,
|
|
1779
|
+
displayNumber,
|
|
1780
|
+
sessionId: newSessionId,
|
|
1781
|
+
event: {
|
|
1782
|
+
type: 'progress',
|
|
1783
|
+
timestamp: new Date().toISOString(),
|
|
1784
|
+
machineName: getMachineName() || undefined,
|
|
1785
|
+
summary: `Respawned with injected human message (new session: ${newSessionId.slice(0, 8)})`,
|
|
1786
|
+
}
|
|
1787
|
+
})
|
|
1788
|
+
}).catch(() => {});
|
|
1789
|
+
|
|
1790
|
+
log(`Task #${displayNumber}: respawned successfully (PID: ${child.pid})`);
|
|
1791
|
+
} catch (error) {
|
|
1792
|
+
logError(`Task #${displayNumber}: respawn failed: ${error.message}`);
|
|
1793
|
+
// Clean up — task is effectively dead
|
|
1794
|
+
runningTasks.delete(displayNumber);
|
|
1795
|
+
updateTaskStatus(displayNumber, 'failed', {
|
|
1796
|
+
error: `Message injection respawn failed: ${error.message}`
|
|
1797
|
+
}, taskId).catch(() => {});
|
|
1798
|
+
taskDetails.delete(displayNumber);
|
|
1799
|
+
taskStreamLineBuffer.delete(displayNumber);
|
|
1800
|
+
taskActivityState.delete(displayNumber);
|
|
1801
|
+
taskLastStreamProgress.delete(displayNumber);
|
|
1802
|
+
updateStatusFile();
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1517
1806
|
function monitorTaskStdout(displayNumber, proc) {
|
|
1518
1807
|
if (!proc.stdout) return;
|
|
1519
1808
|
|
|
@@ -2188,10 +2477,19 @@ async function handleTaskCompletion(displayNumber, exitCode) {
|
|
|
2188
2477
|
const taskInfo = runningTasks.get(displayNumber);
|
|
2189
2478
|
if (!taskInfo) return;
|
|
2190
2479
|
|
|
2480
|
+
const info = taskDetails.get(displayNumber) || {};
|
|
2481
|
+
|
|
2482
|
+
// Message injection: process was killed to inject a human message.
|
|
2483
|
+
// Don't clean up — respawn with --continue instead.
|
|
2484
|
+
if (info.phase === 'injecting_message' && taskInfo.pendingInjection) {
|
|
2485
|
+
log(`Task #${displayNumber}: process exited for message injection (code ${exitCode}), respawning...`);
|
|
2486
|
+
respawnWithInjectedMessage(displayNumber);
|
|
2487
|
+
return;
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2191
2490
|
runningTasks.delete(displayNumber);
|
|
2192
2491
|
|
|
2193
2492
|
const duration = Math.floor((Date.now() - taskInfo.startTime) / 1000);
|
|
2194
|
-
const info = taskDetails.get(displayNumber) || {};
|
|
2195
2493
|
const summary = info.summary || 'Unknown task';
|
|
2196
2494
|
const projectPath = taskProjectPaths.get(displayNumber);
|
|
2197
2495
|
|
|
@@ -2502,6 +2800,13 @@ async function checkTimeouts() {
|
|
|
2502
2800
|
// Level A: Send progress heartbeats for long-running tasks
|
|
2503
2801
|
await sendProgressHeartbeats();
|
|
2504
2802
|
|
|
2803
|
+
// Phase 4: Check for urgent human messages to inject into running tasks
|
|
2804
|
+
try {
|
|
2805
|
+
await checkUrgentMessages();
|
|
2806
|
+
} catch (error) {
|
|
2807
|
+
logError(`Urgent message check failed (non-fatal): ${error.message}`);
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2505
2810
|
// Level B: Kill tasks that have been idle too long (fires at 15 min, before 60 min absolute)
|
|
2506
2811
|
await killIdleTasks();
|
|
2507
2812
|
|
|
@@ -2684,6 +2989,127 @@ function logVersionParityWarnings() {
|
|
|
2684
2989
|
}
|
|
2685
2990
|
}
|
|
2686
2991
|
|
|
2992
|
+
// ==================== Health Check (Phase 5) ====================
|
|
2993
|
+
|
|
2994
|
+
/**
|
|
2995
|
+
* Spawn a health check Claude session for a project.
|
|
2996
|
+
* Called by the cron module when a health-check job fires.
|
|
2997
|
+
*
|
|
2998
|
+
* The session runs in the project directory with a special prompt that asks
|
|
2999
|
+
* Claude to review the codebase and suggest tasks. Results are created as
|
|
3000
|
+
* draft todos via the create-todo API.
|
|
3001
|
+
*
|
|
3002
|
+
* @param {Object} job - Cron job object
|
|
3003
|
+
* @param {Function} logFn - Log function
|
|
3004
|
+
*/
|
|
3005
|
+
async function spawnHealthCheck(job, logFn) {
|
|
3006
|
+
const projectPath = job.action.projectPath;
|
|
3007
|
+
if (!projectPath || !existsSync(projectPath)) {
|
|
3008
|
+
logFn(`Health check: project path not found: ${projectPath}`);
|
|
3009
|
+
return;
|
|
3010
|
+
}
|
|
3011
|
+
|
|
3012
|
+
// Don't run if task slots are full
|
|
3013
|
+
if (runningTasks.size >= MAX_CONCURRENT_TASKS) {
|
|
3014
|
+
logFn(`Health check: all ${MAX_CONCURRENT_TASKS} slots in use, deferring`);
|
|
3015
|
+
return;
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
const scope = job.action.scope || 'general';
|
|
3019
|
+
const customPrompt = job.action.prompt || '';
|
|
3020
|
+
|
|
3021
|
+
const healthPrompts = {
|
|
3022
|
+
general: `Review this codebase briefly. Check for:
|
|
3023
|
+
1. Failing tests (run the test suite if one exists)
|
|
3024
|
+
2. Obvious bugs or issues in recently modified files (last 7 days)
|
|
3025
|
+
3. Outdated dependencies worth updating
|
|
3026
|
+
|
|
3027
|
+
For each issue found, create a todo using: push-todo create "<clear description of the issue>"
|
|
3028
|
+
Only create todos for real, actionable issues — not style preferences or minor improvements.
|
|
3029
|
+
If everything looks good, just say "No issues found" and don't create any todos.`,
|
|
3030
|
+
tests: `Run the test suite for this project. If any tests fail, create a todo for each failure:
|
|
3031
|
+
push-todo create "Fix failing test: <test name> - <brief reason>"
|
|
3032
|
+
If all tests pass, say "All tests pass" and don't create any todos.`,
|
|
3033
|
+
dependencies: `Check for outdated dependencies in this project. Only flag dependencies with:
|
|
3034
|
+
- Known security vulnerabilities
|
|
3035
|
+
- Major version bumps (not minor/patch)
|
|
3036
|
+
For each, create a todo: push-todo create "Update <dep> from <old> to <new> (<reason>)"
|
|
3037
|
+
If dependencies are current, say "All dependencies up to date."`,
|
|
3038
|
+
};
|
|
3039
|
+
|
|
3040
|
+
const prompt = customPrompt || healthPrompts[scope] || healthPrompts.general;
|
|
3041
|
+
|
|
3042
|
+
const allowedTools = [
|
|
3043
|
+
'Read', 'Glob', 'Grep',
|
|
3044
|
+
'Bash(git *)',
|
|
3045
|
+
'Bash(npm *)', 'Bash(npx *)',
|
|
3046
|
+
'Bash(python *)', 'Bash(python3 *)',
|
|
3047
|
+
'Bash(push-todo create *)',
|
|
3048
|
+
].join(',');
|
|
3049
|
+
|
|
3050
|
+
const claudeArgs = [
|
|
3051
|
+
'-p', prompt,
|
|
3052
|
+
'--allowedTools', allowedTools,
|
|
3053
|
+
'--output-format', 'stream-json',
|
|
3054
|
+
'--permission-mode', 'bypassPermissions',
|
|
3055
|
+
];
|
|
3056
|
+
|
|
3057
|
+
logFn(`Health check "${job.name}": spawning Claude in ${projectPath} (scope: ${scope})`);
|
|
3058
|
+
|
|
3059
|
+
try {
|
|
3060
|
+
const child = spawn('claude', claudeArgs, {
|
|
3061
|
+
cwd: projectPath,
|
|
3062
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
3063
|
+
env: (() => {
|
|
3064
|
+
const env = { ...process.env };
|
|
3065
|
+
delete env.CLAUDECODE;
|
|
3066
|
+
delete env.CLAUDE_CODE_ENTRYPOINT;
|
|
3067
|
+
return env;
|
|
3068
|
+
})(),
|
|
3069
|
+
timeout: 300000, // 5 min max for health checks
|
|
3070
|
+
});
|
|
3071
|
+
|
|
3072
|
+
// Simple output tracking — health checks are lightweight, no full task tracking
|
|
3073
|
+
let output = '';
|
|
3074
|
+
child.stdout.on('data', (data) => {
|
|
3075
|
+
output += data.toString();
|
|
3076
|
+
});
|
|
3077
|
+
|
|
3078
|
+
child.stderr.on('data', (data) => {
|
|
3079
|
+
const errLine = data.toString().trim();
|
|
3080
|
+
if (errLine) logFn(`Health check "${job.name}" stderr: ${errLine}`);
|
|
3081
|
+
});
|
|
3082
|
+
|
|
3083
|
+
await new Promise((resolve) => {
|
|
3084
|
+
child.on('close', (code) => {
|
|
3085
|
+
if (code === 0) {
|
|
3086
|
+
logFn(`Health check "${job.name}": completed successfully`);
|
|
3087
|
+
} else {
|
|
3088
|
+
logFn(`Health check "${job.name}": exited with code ${code}`);
|
|
3089
|
+
}
|
|
3090
|
+
resolve();
|
|
3091
|
+
});
|
|
3092
|
+
child.on('error', (err) => {
|
|
3093
|
+
logFn(`Health check "${job.name}": spawn error: ${err.message}`);
|
|
3094
|
+
resolve();
|
|
3095
|
+
});
|
|
3096
|
+
});
|
|
3097
|
+
|
|
3098
|
+
// Extract any text summary from stream-json output
|
|
3099
|
+
const lines = output.split('\n').filter(l => l.trim());
|
|
3100
|
+
for (const line of lines) {
|
|
3101
|
+
try {
|
|
3102
|
+
const event = JSON.parse(line);
|
|
3103
|
+
if (event.type === 'result' && event.result) {
|
|
3104
|
+
logFn(`Health check "${job.name}" result: ${event.result.slice(0, 200)}`);
|
|
3105
|
+
}
|
|
3106
|
+
} catch { /* ignore non-JSON lines */ }
|
|
3107
|
+
}
|
|
3108
|
+
} catch (error) {
|
|
3109
|
+
logFn(`Health check "${job.name}": error: ${error.message}`);
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
|
|
2687
3113
|
// ==================== Main Loop ====================
|
|
2688
3114
|
|
|
2689
3115
|
async function pollAndExecute() {
|
|
@@ -2845,7 +3271,7 @@ async function mainLoop() {
|
|
|
2845
3271
|
|
|
2846
3272
|
// Cron jobs (check every poll cycle, execution throttled by nextRunAt)
|
|
2847
3273
|
try {
|
|
2848
|
-
await checkAndRunDueJobs(log);
|
|
3274
|
+
await checkAndRunDueJobs(log, { apiRequest, spawnHealthCheck });
|
|
2849
3275
|
} catch (error) {
|
|
2850
3276
|
logError(`Cron check error: ${error.message}`);
|
|
2851
3277
|
}
|