@masslessai/push-todo 3.7.7 → 3.7.8
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/daemon.js +178 -37
- package/package.json +1 -1
package/lib/daemon.js
CHANGED
|
@@ -439,7 +439,15 @@ async function updateTaskStatus(displayNumber, status, extra = {}) {
|
|
|
439
439
|
});
|
|
440
440
|
|
|
441
441
|
const result = await response.json().catch(() => null);
|
|
442
|
-
|
|
442
|
+
if (!response.ok) {
|
|
443
|
+
logError(`Task status update failed: HTTP ${response.status} for #${displayNumber} -> ${status}`);
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
if (result?.success === false) {
|
|
447
|
+
logError(`Task status update rejected for #${displayNumber} -> ${status}: ${JSON.stringify(result)}`);
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
return true;
|
|
443
451
|
} catch (error) {
|
|
444
452
|
logError(`Failed to update task status: ${error.message}`);
|
|
445
453
|
return false;
|
|
@@ -754,6 +762,110 @@ async function markTaskAsCompleted(displayNumber, taskId, comment) {
|
|
|
754
762
|
}
|
|
755
763
|
}
|
|
756
764
|
|
|
765
|
+
/**
|
|
766
|
+
* Auto-heal: detect if a previous execution already completed work for this task.
|
|
767
|
+
* Checks for existing branch commits and PRs to avoid redundant re-execution.
|
|
768
|
+
* Returns true if the task was healed (status updated, no re-execution needed).
|
|
769
|
+
*/
|
|
770
|
+
async function autoHealExistingWork(displayNumber, summary, projectPath) {
|
|
771
|
+
const suffix = getWorktreeSuffix();
|
|
772
|
+
const branch = `push-${displayNumber}-${suffix}`;
|
|
773
|
+
const gitCwd = projectPath || process.cwd();
|
|
774
|
+
|
|
775
|
+
try {
|
|
776
|
+
// Check if branch has commits ahead of main
|
|
777
|
+
let hasCommits = false;
|
|
778
|
+
try {
|
|
779
|
+
const logResult = execSync(
|
|
780
|
+
`git log HEAD..origin/${branch} --oneline 2>/dev/null || git log HEAD..${branch} --oneline 2>/dev/null`,
|
|
781
|
+
{ cwd: gitCwd, timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] }
|
|
782
|
+
).toString().trim();
|
|
783
|
+
hasCommits = logResult.length > 0;
|
|
784
|
+
} catch {
|
|
785
|
+
// Branch doesn't exist — no previous work
|
|
786
|
+
return false;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (!hasCommits) {
|
|
790
|
+
return false;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
log(`Task #${displayNumber}: found existing commits on branch ${branch}`);
|
|
794
|
+
|
|
795
|
+
// Check for existing PR
|
|
796
|
+
let prUrl = null;
|
|
797
|
+
let prState = null;
|
|
798
|
+
try {
|
|
799
|
+
const prResult = execSync(
|
|
800
|
+
`gh pr list --head ${branch} --json url,state --jq '.[0]' 2>/dev/null`,
|
|
801
|
+
{ cwd: gitCwd, timeout: 15000, stdio: ['ignore', 'pipe', 'pipe'] }
|
|
802
|
+
).toString().trim();
|
|
803
|
+
if (prResult) {
|
|
804
|
+
const pr = JSON.parse(prResult);
|
|
805
|
+
prUrl = pr.url;
|
|
806
|
+
prState = pr.state; // OPEN or MERGED
|
|
807
|
+
}
|
|
808
|
+
} catch {
|
|
809
|
+
// gh not available or no PR found
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (prUrl && prState === 'MERGED') {
|
|
813
|
+
// PR already merged — task is fully done
|
|
814
|
+
log(`Task #${displayNumber}: PR already merged (${prUrl}), updating status`);
|
|
815
|
+
const executionSummary = `Auto-healed: previous execution completed and PR merged. PR: ${prUrl}`;
|
|
816
|
+
await updateTaskStatus(displayNumber, 'session_finished', {
|
|
817
|
+
summary: executionSummary
|
|
818
|
+
});
|
|
819
|
+
completedToday.push({
|
|
820
|
+
displayNumber, summary,
|
|
821
|
+
completedAt: new Date().toISOString(),
|
|
822
|
+
duration: 0, status: 'session_finished', prUrl
|
|
823
|
+
});
|
|
824
|
+
return true;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (prUrl && prState === 'OPEN') {
|
|
828
|
+
// PR is open — work is done, just needs review
|
|
829
|
+
log(`Task #${displayNumber}: PR already open (${prUrl}), updating status`);
|
|
830
|
+
const executionSummary = `Auto-healed: previous execution completed. PR pending review: ${prUrl}`;
|
|
831
|
+
await updateTaskStatus(displayNumber, 'session_finished', {
|
|
832
|
+
summary: executionSummary
|
|
833
|
+
});
|
|
834
|
+
completedToday.push({
|
|
835
|
+
displayNumber, summary,
|
|
836
|
+
completedAt: new Date().toISOString(),
|
|
837
|
+
duration: 0, status: 'session_finished', prUrl
|
|
838
|
+
});
|
|
839
|
+
return true;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (!prUrl) {
|
|
843
|
+
// Commits exist but no PR — create one and update status
|
|
844
|
+
log(`Task #${displayNumber}: commits exist but no PR, creating PR`);
|
|
845
|
+
const newPrUrl = createPRForTask(displayNumber, summary, projectPath);
|
|
846
|
+
if (newPrUrl) {
|
|
847
|
+
const executionSummary = `Auto-healed: previous execution had uncommitted PR. Created PR: ${newPrUrl}`;
|
|
848
|
+
await updateTaskStatus(displayNumber, 'session_finished', {
|
|
849
|
+
summary: executionSummary
|
|
850
|
+
});
|
|
851
|
+
completedToday.push({
|
|
852
|
+
displayNumber, summary,
|
|
853
|
+
completedAt: new Date().toISOString(),
|
|
854
|
+
duration: 0, status: 'session_finished', prUrl: newPrUrl
|
|
855
|
+
});
|
|
856
|
+
return true;
|
|
857
|
+
}
|
|
858
|
+
// PR creation failed — fall through to re-execute
|
|
859
|
+
log(`Task #${displayNumber}: PR creation failed, will re-execute`);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
return false;
|
|
863
|
+
} catch (error) {
|
|
864
|
+
log(`Task #${displayNumber}: auto-heal check failed: ${error.message}`);
|
|
865
|
+
return false;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
757
869
|
// ==================== Stuck Detection ====================
|
|
758
870
|
|
|
759
871
|
function checkStuckPatterns(displayNumber, line) {
|
|
@@ -914,7 +1026,7 @@ function updateTaskDetail(displayNumber, updates) {
|
|
|
914
1026
|
updateStatusFile();
|
|
915
1027
|
}
|
|
916
1028
|
|
|
917
|
-
function executeTask(task) {
|
|
1029
|
+
async function executeTask(task) {
|
|
918
1030
|
// Decrypt E2EE fields
|
|
919
1031
|
task = decryptTaskFields(task);
|
|
920
1032
|
|
|
@@ -951,7 +1063,7 @@ function executeTask(task) {
|
|
|
951
1063
|
|
|
952
1064
|
if (!existsSync(projectPath)) {
|
|
953
1065
|
logError(`Task #${displayNumber}: Project path does not exist: ${projectPath}`);
|
|
954
|
-
updateTaskStatus(displayNumber, 'failed', {
|
|
1066
|
+
await updateTaskStatus(displayNumber, 'failed', {
|
|
955
1067
|
error: `Project path not found: ${projectPath}`
|
|
956
1068
|
});
|
|
957
1069
|
return null;
|
|
@@ -960,8 +1072,16 @@ function executeTask(task) {
|
|
|
960
1072
|
log(`Task #${displayNumber}: Project ${gitRemote} -> ${projectPath}`);
|
|
961
1073
|
}
|
|
962
1074
|
|
|
963
|
-
// Atomic task claiming
|
|
964
|
-
if (!claimTask(displayNumber)) {
|
|
1075
|
+
// Atomic task claiming - must await to actually check the result
|
|
1076
|
+
if (!(await claimTask(displayNumber))) {
|
|
1077
|
+
log(`Task #${displayNumber}: claim failed, skipping`);
|
|
1078
|
+
return null;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// Auto-heal: check if previous execution already completed work for this task
|
|
1082
|
+
const healed = await autoHealExistingWork(displayNumber, summary, projectPath);
|
|
1083
|
+
if (healed) {
|
|
1084
|
+
log(`Task #${displayNumber}: auto-healed from previous execution, skipping re-execution`);
|
|
965
1085
|
return null;
|
|
966
1086
|
}
|
|
967
1087
|
|
|
@@ -981,7 +1101,7 @@ function executeTask(task) {
|
|
|
981
1101
|
// Create worktree
|
|
982
1102
|
const worktreePath = createWorktree(displayNumber, projectPath);
|
|
983
1103
|
if (!worktreePath) {
|
|
984
|
-
updateTaskStatus(displayNumber, 'failed', { error: 'Failed to create git worktree' });
|
|
1104
|
+
await updateTaskStatus(displayNumber, 'failed', { error: 'Failed to create git worktree' });
|
|
985
1105
|
taskDetails.delete(displayNumber);
|
|
986
1106
|
return null;
|
|
987
1107
|
}
|
|
@@ -998,15 +1118,8 @@ IMPORTANT:
|
|
|
998
1118
|
2. ALWAYS commit your changes before finishing. Use a descriptive commit message summarizing what you did. This is critical — uncommitted changes will be lost when the worktree is cleaned up.
|
|
999
1119
|
3. When you're done, the SessionEnd hook will automatically report completion to Supabase.`;
|
|
1000
1120
|
|
|
1001
|
-
//
|
|
1002
|
-
|
|
1003
|
-
event: {
|
|
1004
|
-
type: 'started',
|
|
1005
|
-
timestamp: new Date().toISOString(),
|
|
1006
|
-
machineName: getMachineName() || undefined,
|
|
1007
|
-
summary: summary.slice(0, 100),
|
|
1008
|
-
}
|
|
1009
|
-
});
|
|
1121
|
+
// Note: claimTask() already set status to 'running' with atomic: true
|
|
1122
|
+
// No duplicate status update needed here (was causing race conditions)
|
|
1010
1123
|
|
|
1011
1124
|
// Build Claude command
|
|
1012
1125
|
const allowedTools = [
|
|
@@ -1075,10 +1188,10 @@ IMPORTANT:
|
|
|
1075
1188
|
handleTaskCompletion(displayNumber, code);
|
|
1076
1189
|
});
|
|
1077
1190
|
|
|
1078
|
-
child.on('error', (error) => {
|
|
1191
|
+
child.on('error', async (error) => {
|
|
1079
1192
|
logError(`Task #${displayNumber} error: ${error.message}`);
|
|
1080
1193
|
runningTasks.delete(displayNumber);
|
|
1081
|
-
updateTaskStatus(displayNumber, 'failed', { error: error.message });
|
|
1194
|
+
await updateTaskStatus(displayNumber, 'failed', { error: error.message });
|
|
1082
1195
|
taskDetails.delete(displayNumber);
|
|
1083
1196
|
updateStatusFile();
|
|
1084
1197
|
});
|
|
@@ -1094,13 +1207,13 @@ IMPORTANT:
|
|
|
1094
1207
|
return taskInfo;
|
|
1095
1208
|
} catch (error) {
|
|
1096
1209
|
logError(`Error starting Claude for task #${displayNumber}: ${error.message}`);
|
|
1097
|
-
updateTaskStatus(displayNumber, 'failed', { error: error.message });
|
|
1210
|
+
await updateTaskStatus(displayNumber, 'failed', { error: error.message });
|
|
1098
1211
|
taskDetails.delete(displayNumber);
|
|
1099
1212
|
return null;
|
|
1100
1213
|
}
|
|
1101
1214
|
}
|
|
1102
1215
|
|
|
1103
|
-
function handleTaskCompletion(displayNumber, exitCode) {
|
|
1216
|
+
async function handleTaskCompletion(displayNumber, exitCode) {
|
|
1104
1217
|
const taskInfo = runningTasks.get(displayNumber);
|
|
1105
1218
|
if (!taskInfo) return;
|
|
1106
1219
|
|
|
@@ -1143,11 +1256,18 @@ function handleTaskCompletion(displayNumber, exitCode) {
|
|
|
1143
1256
|
executionSummary += ` PR: ${prUrl}`;
|
|
1144
1257
|
}
|
|
1145
1258
|
|
|
1146
|
-
updateTaskStatus(displayNumber, 'session_finished', {
|
|
1259
|
+
const statusUpdated = await updateTaskStatus(displayNumber, 'session_finished', {
|
|
1147
1260
|
duration,
|
|
1148
1261
|
sessionId,
|
|
1149
1262
|
summary: executionSummary
|
|
1150
1263
|
});
|
|
1264
|
+
if (!statusUpdated) {
|
|
1265
|
+
logError(`Task #${displayNumber}: Failed to update status to session_finished — will retry`);
|
|
1266
|
+
// Retry once
|
|
1267
|
+
await updateTaskStatus(displayNumber, 'session_finished', {
|
|
1268
|
+
duration, sessionId, summary: executionSummary
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1151
1271
|
|
|
1152
1272
|
if (NOTIFY_ON_COMPLETE) {
|
|
1153
1273
|
const prNote = prUrl ? ' PR ready for review.' : '';
|
|
@@ -1170,7 +1290,10 @@ function handleTaskCompletion(displayNumber, exitCode) {
|
|
|
1170
1290
|
const comment = semanticSummary
|
|
1171
1291
|
? `${semanticSummary} (${durationStr} on ${machineName})`
|
|
1172
1292
|
: `Completed in ${durationStr} on ${machineName}`;
|
|
1173
|
-
markTaskAsCompleted(displayNumber, taskId, comment);
|
|
1293
|
+
const completed = await markTaskAsCompleted(displayNumber, taskId, comment);
|
|
1294
|
+
if (!completed) {
|
|
1295
|
+
logError(`Task #${displayNumber}: Failed to mark as completed — status is session_finished but not completed`);
|
|
1296
|
+
}
|
|
1174
1297
|
}
|
|
1175
1298
|
|
|
1176
1299
|
completedToday.push({
|
|
@@ -1191,7 +1314,7 @@ function handleTaskCompletion(displayNumber, exitCode) {
|
|
|
1191
1314
|
? `${failureSummary}\nExit code ${exitCode}. Ran for ${durationStr} on ${machineName}.`
|
|
1192
1315
|
: `Exit code ${exitCode}: ${stderr.slice(0, 200)}`;
|
|
1193
1316
|
|
|
1194
|
-
updateTaskStatus(displayNumber, 'failed', { error: errorMsg });
|
|
1317
|
+
await updateTaskStatus(displayNumber, 'failed', { error: errorMsg });
|
|
1195
1318
|
|
|
1196
1319
|
if (NOTIFY_ON_FAILURE) {
|
|
1197
1320
|
sendMacNotification(
|
|
@@ -1442,7 +1565,13 @@ async function pollAndExecute() {
|
|
|
1442
1565
|
continue;
|
|
1443
1566
|
}
|
|
1444
1567
|
|
|
1445
|
-
|
|
1568
|
+
// Skip tasks already completed this daemon session (prevents re-execution loop)
|
|
1569
|
+
if (completedToday.some(c => c.displayNumber === displayNumber)) {
|
|
1570
|
+
log(`Task #${displayNumber} already completed this session, skipping`);
|
|
1571
|
+
continue;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
await executeTask(task);
|
|
1446
1575
|
}
|
|
1447
1576
|
|
|
1448
1577
|
updateStatusFile();
|
|
@@ -1517,10 +1646,11 @@ async function mainLoop() {
|
|
|
1517
1646
|
|
|
1518
1647
|
// ==================== Signal Handling ====================
|
|
1519
1648
|
|
|
1520
|
-
function cleanup() {
|
|
1649
|
+
async function cleanup() {
|
|
1521
1650
|
log('Daemon shutting down...');
|
|
1522
1651
|
|
|
1523
|
-
// Kill running tasks and
|
|
1652
|
+
// Kill running tasks and collect status update promises
|
|
1653
|
+
const statusPromises = [];
|
|
1524
1654
|
for (const [displayNumber, taskInfo] of runningTasks) {
|
|
1525
1655
|
log(`Killing task #${displayNumber}`);
|
|
1526
1656
|
try {
|
|
@@ -1528,19 +1658,30 @@ function cleanup() {
|
|
|
1528
1658
|
} catch {}
|
|
1529
1659
|
// Mark as failed so the task doesn't stay as 'running' forever
|
|
1530
1660
|
const duration = Math.floor((Date.now() - taskInfo.startTime) / 1000);
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1661
|
+
statusPromises.push(
|
|
1662
|
+
updateTaskStatus(displayNumber, 'failed', {
|
|
1663
|
+
error: `Daemon shutdown after ${duration}s`,
|
|
1664
|
+
event: {
|
|
1665
|
+
type: 'daemon_shutdown',
|
|
1666
|
+
timestamp: new Date().toISOString(),
|
|
1667
|
+
machineName: getMachineName() || undefined,
|
|
1668
|
+
summary: `Daemon restarted after ${duration}s`,
|
|
1669
|
+
}
|
|
1670
|
+
})
|
|
1671
|
+
);
|
|
1540
1672
|
const projectPath = taskProjectPaths.get(displayNumber);
|
|
1541
1673
|
cleanupWorktree(displayNumber, projectPath);
|
|
1542
1674
|
}
|
|
1543
1675
|
|
|
1676
|
+
// Wait for all status updates to land (max 5s timeout)
|
|
1677
|
+
if (statusPromises.length > 0) {
|
|
1678
|
+
log(`Waiting for ${statusPromises.length} status update(s) to complete...`);
|
|
1679
|
+
await Promise.race([
|
|
1680
|
+
Promise.allSettled(statusPromises),
|
|
1681
|
+
new Promise(resolve => setTimeout(resolve, 5000))
|
|
1682
|
+
]);
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1544
1685
|
// Clean up files
|
|
1545
1686
|
try { unlinkSync(PID_FILE); } catch {}
|
|
1546
1687
|
|
|
@@ -1555,11 +1696,11 @@ function cleanup() {
|
|
|
1555
1696
|
process.exit(0);
|
|
1556
1697
|
}
|
|
1557
1698
|
|
|
1558
|
-
process.on('SIGTERM', cleanup);
|
|
1559
|
-
process.on('SIGINT', cleanup);
|
|
1699
|
+
process.on('SIGTERM', () => cleanup().catch(() => process.exit(1)));
|
|
1700
|
+
process.on('SIGINT', () => cleanup().catch(() => process.exit(1)));
|
|
1560
1701
|
process.on('uncaughtException', (error) => {
|
|
1561
1702
|
logError(`Uncaught exception: ${error.message}`);
|
|
1562
|
-
cleanup();
|
|
1703
|
+
cleanup().catch(() => process.exit(1));
|
|
1563
1704
|
});
|
|
1564
1705
|
|
|
1565
1706
|
// ==================== Entry Point ====================
|