@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.
Files changed (2) hide show
  1. package/lib/daemon.js +178 -37
  2. 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
- return response.ok && result?.success !== false;
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
- // Update status to running (auto-generates 'started' event)
1002
- updateTaskStatus(displayNumber, 'running', {
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
- executeTask(task);
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 mark them as failed in Supabase
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
- updateTaskStatus(displayNumber, 'failed', {
1532
- error: `Daemon shutdown after ${duration}s`,
1533
- event: {
1534
- type: 'daemon_shutdown',
1535
- timestamp: new Date().toISOString(),
1536
- machineName: getMachineName() || undefined,
1537
- summary: `Daemon restarted after ${duration}s`,
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 ====================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masslessai/push-todo",
3
- "version": "3.7.7",
3
+ "version": "3.7.8",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {