@masslessai/push-todo 3.7.7 → 3.7.9
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 +332 -52
- package/lib/fetch.js +10 -1
- 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;
|
|
@@ -678,15 +686,13 @@ Automated PR from Push daemon for task #${displayNumber}.
|
|
|
678
686
|
}
|
|
679
687
|
|
|
680
688
|
/**
|
|
681
|
-
*
|
|
682
|
-
* Uses `gh pr merge` for clean remote merge, then pulls locally.
|
|
689
|
+
* Attempt to merge a PR. If merge conflicts, use Claude to resolve them.
|
|
683
690
|
*
|
|
684
691
|
* @returns {boolean} True if merge succeeded
|
|
685
692
|
*/
|
|
686
693
|
function mergePRForTask(displayNumber, prUrl, projectPath) {
|
|
687
694
|
const gitCwd = projectPath || process.cwd();
|
|
688
695
|
|
|
689
|
-
// Extract PR number from URL (e.g., https://github.com/user/repo/pull/42)
|
|
690
696
|
const prMatch = prUrl.match(/\/pull\/(\d+)/);
|
|
691
697
|
if (!prMatch) {
|
|
692
698
|
logError(`Could not extract PR number from: ${prUrl}`);
|
|
@@ -694,6 +700,26 @@ function mergePRForTask(displayNumber, prUrl, projectPath) {
|
|
|
694
700
|
}
|
|
695
701
|
const prNumber = prMatch[1];
|
|
696
702
|
|
|
703
|
+
// First attempt: direct merge
|
|
704
|
+
const firstResult = attemptPRMerge(prNumber, displayNumber, gitCwd);
|
|
705
|
+
if (firstResult === 'success') return true;
|
|
706
|
+
if (firstResult !== 'conflict') return false; // non-conflict failure
|
|
707
|
+
|
|
708
|
+
// Conflict detected — try to resolve with Claude
|
|
709
|
+
const suffix = getWorktreeSuffix();
|
|
710
|
+
const branch = `push-${displayNumber}-${suffix}`;
|
|
711
|
+
const resolved = resolveConflictsWithClaude(displayNumber, branch, gitCwd);
|
|
712
|
+
if (!resolved) return false;
|
|
713
|
+
|
|
714
|
+
// Second attempt after conflict resolution
|
|
715
|
+
log(`Retrying merge for PR #${prNumber} after conflict resolution`);
|
|
716
|
+
return attemptPRMerge(prNumber, displayNumber, gitCwd) === 'success';
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Try gh pr merge. Returns 'success', 'conflict', or 'error'.
|
|
721
|
+
*/
|
|
722
|
+
function attemptPRMerge(prNumber, displayNumber, gitCwd) {
|
|
697
723
|
try {
|
|
698
724
|
execFileSync('gh', ['pr', 'merge', prNumber, '--merge', '--delete-branch'], {
|
|
699
725
|
cwd: gitCwd,
|
|
@@ -714,16 +740,132 @@ function mergePRForTask(displayNumber, prUrl, projectPath) {
|
|
|
714
740
|
log('Could not pull main (may not be checked out), skipping local update');
|
|
715
741
|
}
|
|
716
742
|
|
|
717
|
-
return
|
|
743
|
+
return 'success';
|
|
718
744
|
} catch (e) {
|
|
719
745
|
const stderr = e.stderr?.toString() || e.message || '';
|
|
720
|
-
if (stderr.includes('
|
|
721
|
-
logError(`PR #${prNumber} has merge conflicts, skipping auto-merge`);
|
|
722
|
-
} else if (stderr.includes('not found') || stderr.includes('ENOENT')) {
|
|
746
|
+
if (stderr.includes('not found') || stderr.includes('ENOENT')) {
|
|
723
747
|
log('GitHub CLI (gh) not installed, skipping merge');
|
|
724
|
-
|
|
725
|
-
|
|
748
|
+
return 'error';
|
|
749
|
+
}
|
|
750
|
+
if (stderr.includes('merge conflict') || stderr.includes('conflict')) {
|
|
751
|
+
log(`PR #${prNumber} has merge conflicts, will attempt resolution`);
|
|
752
|
+
return 'conflict';
|
|
753
|
+
}
|
|
754
|
+
logError(`PR merge failed for #${displayNumber}: ${stderr.slice(0, 200)}`);
|
|
755
|
+
return 'error';
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Resolve merge conflicts by creating a worktree, merging main, using Claude
|
|
761
|
+
* to fix conflicts, then committing and pushing the resolution.
|
|
762
|
+
*
|
|
763
|
+
* @returns {boolean} True if conflicts were resolved and pushed
|
|
764
|
+
*/
|
|
765
|
+
function resolveConflictsWithClaude(displayNumber, branch, projectPath) {
|
|
766
|
+
log(`Attempting conflict resolution for task #${displayNumber}`);
|
|
767
|
+
|
|
768
|
+
const worktreePath = getWorktreePath(displayNumber, projectPath);
|
|
769
|
+
|
|
770
|
+
// Recreate worktree from the existing branch
|
|
771
|
+
try {
|
|
772
|
+
if (!existsSync(worktreePath)) {
|
|
773
|
+
execFileSync('git', ['worktree', 'add', worktreePath, branch], {
|
|
774
|
+
cwd: projectPath,
|
|
775
|
+
timeout: 30000,
|
|
776
|
+
stdio: 'pipe'
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
} catch (e) {
|
|
780
|
+
logError(`Could not create worktree for conflict resolution: ${e.message}`);
|
|
781
|
+
return false;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
try {
|
|
785
|
+
// Fetch latest main
|
|
786
|
+
execFileSync('git', ['fetch', 'origin'], {
|
|
787
|
+
cwd: worktreePath,
|
|
788
|
+
timeout: 30000,
|
|
789
|
+
stdio: 'pipe'
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
// Attempt merge — this will create conflict markers if conflicts exist
|
|
793
|
+
try {
|
|
794
|
+
execFileSync('git', ['merge', 'origin/main', '--no-edit'], {
|
|
795
|
+
cwd: worktreePath,
|
|
796
|
+
timeout: 30000,
|
|
797
|
+
stdio: 'pipe'
|
|
798
|
+
});
|
|
799
|
+
// No conflicts — merge succeeded cleanly
|
|
800
|
+
log(`No actual conflicts for #${displayNumber}, merge succeeded locally`);
|
|
801
|
+
execFileSync('git', ['push', 'origin', branch], {
|
|
802
|
+
cwd: worktreePath,
|
|
803
|
+
timeout: 30000,
|
|
804
|
+
stdio: 'pipe'
|
|
805
|
+
});
|
|
806
|
+
cleanupWorktree(displayNumber, projectPath);
|
|
807
|
+
return true;
|
|
808
|
+
} catch {
|
|
809
|
+
// Expected: merge conflicts exist, continue to resolution
|
|
810
|
+
log(`Conflicts detected for #${displayNumber}, invoking Claude to resolve`);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Use Claude to resolve the conflicts
|
|
814
|
+
const prompt = [
|
|
815
|
+
'There are merge conflicts in this repository from merging origin/main.',
|
|
816
|
+
'Run `git diff` to see the conflict markers.',
|
|
817
|
+
'Resolve ALL conflicts by choosing the correct code for each one.',
|
|
818
|
+
'After resolving, stage the files with `git add` and commit with:',
|
|
819
|
+
`git commit -m "Resolve merge conflicts for task #${displayNumber}"`,
|
|
820
|
+
'Do NOT push. Just resolve and commit.'
|
|
821
|
+
].join(' ');
|
|
822
|
+
|
|
823
|
+
execFileSync('claude', [
|
|
824
|
+
'-p', prompt,
|
|
825
|
+
'--allowedTools', 'Bash(git *),Read,Edit,Write,Glob,Grep',
|
|
826
|
+
'--output-format', 'json',
|
|
827
|
+
'--permission-mode', 'bypassPermissions'
|
|
828
|
+
], {
|
|
829
|
+
cwd: worktreePath,
|
|
830
|
+
timeout: 120000, // 2 min for conflict resolution
|
|
831
|
+
stdio: 'pipe'
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
// Verify the merge completed (no unmerged files remaining)
|
|
835
|
+
try {
|
|
836
|
+
const status = execFileSync('git', ['status', '--porcelain'], {
|
|
837
|
+
cwd: worktreePath,
|
|
838
|
+
timeout: 10000,
|
|
839
|
+
encoding: 'utf8',
|
|
840
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
841
|
+
}).toString();
|
|
842
|
+
|
|
843
|
+
if (status.includes('UU ') || status.includes('AA ') || status.includes('DD ')) {
|
|
844
|
+
logError(`Conflicts not fully resolved for #${displayNumber}`);
|
|
845
|
+
try { execFileSync('git', ['merge', '--abort'], { cwd: worktreePath, stdio: 'pipe' }); } catch {}
|
|
846
|
+
cleanupWorktree(displayNumber, projectPath);
|
|
847
|
+
return false;
|
|
848
|
+
}
|
|
849
|
+
} catch {
|
|
850
|
+
try { execFileSync('git', ['merge', '--abort'], { cwd: worktreePath, stdio: 'pipe' }); } catch {}
|
|
851
|
+
cleanupWorktree(displayNumber, projectPath);
|
|
852
|
+
return false;
|
|
726
853
|
}
|
|
854
|
+
|
|
855
|
+
// Push the resolved branch
|
|
856
|
+
execFileSync('git', ['push', 'origin', branch], {
|
|
857
|
+
cwd: worktreePath,
|
|
858
|
+
timeout: 30000,
|
|
859
|
+
stdio: 'pipe'
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
log(`Conflict resolution pushed for task #${displayNumber}`);
|
|
863
|
+
cleanupWorktree(displayNumber, projectPath);
|
|
864
|
+
return true;
|
|
865
|
+
} catch (e) {
|
|
866
|
+
logError(`Conflict resolution failed for #${displayNumber}: ${e.message}`);
|
|
867
|
+
try { execFileSync('git', ['merge', '--abort'], { cwd: worktreePath, stdio: 'pipe' }); } catch {}
|
|
868
|
+
cleanupWorktree(displayNumber, projectPath);
|
|
727
869
|
return false;
|
|
728
870
|
}
|
|
729
871
|
}
|
|
@@ -754,6 +896,110 @@ async function markTaskAsCompleted(displayNumber, taskId, comment) {
|
|
|
754
896
|
}
|
|
755
897
|
}
|
|
756
898
|
|
|
899
|
+
/**
|
|
900
|
+
* Auto-heal: detect if a previous execution already completed work for this task.
|
|
901
|
+
* Checks for existing branch commits and PRs to avoid redundant re-execution.
|
|
902
|
+
* Returns true if the task was healed (status updated, no re-execution needed).
|
|
903
|
+
*/
|
|
904
|
+
async function autoHealExistingWork(displayNumber, summary, projectPath) {
|
|
905
|
+
const suffix = getWorktreeSuffix();
|
|
906
|
+
const branch = `push-${displayNumber}-${suffix}`;
|
|
907
|
+
const gitCwd = projectPath || process.cwd();
|
|
908
|
+
|
|
909
|
+
try {
|
|
910
|
+
// Check if branch has commits ahead of main
|
|
911
|
+
let hasCommits = false;
|
|
912
|
+
try {
|
|
913
|
+
const logResult = execSync(
|
|
914
|
+
`git log HEAD..origin/${branch} --oneline 2>/dev/null || git log HEAD..${branch} --oneline 2>/dev/null`,
|
|
915
|
+
{ cwd: gitCwd, timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] }
|
|
916
|
+
).toString().trim();
|
|
917
|
+
hasCommits = logResult.length > 0;
|
|
918
|
+
} catch {
|
|
919
|
+
// Branch doesn't exist — no previous work
|
|
920
|
+
return false;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (!hasCommits) {
|
|
924
|
+
return false;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
log(`Task #${displayNumber}: found existing commits on branch ${branch}`);
|
|
928
|
+
|
|
929
|
+
// Check for existing PR
|
|
930
|
+
let prUrl = null;
|
|
931
|
+
let prState = null;
|
|
932
|
+
try {
|
|
933
|
+
const prResult = execSync(
|
|
934
|
+
`gh pr list --head ${branch} --json url,state --jq '.[0]' 2>/dev/null`,
|
|
935
|
+
{ cwd: gitCwd, timeout: 15000, stdio: ['ignore', 'pipe', 'pipe'] }
|
|
936
|
+
).toString().trim();
|
|
937
|
+
if (prResult) {
|
|
938
|
+
const pr = JSON.parse(prResult);
|
|
939
|
+
prUrl = pr.url;
|
|
940
|
+
prState = pr.state; // OPEN or MERGED
|
|
941
|
+
}
|
|
942
|
+
} catch {
|
|
943
|
+
// gh not available or no PR found
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if (prUrl && prState === 'MERGED') {
|
|
947
|
+
// PR already merged — task is fully done
|
|
948
|
+
log(`Task #${displayNumber}: PR already merged (${prUrl}), updating status`);
|
|
949
|
+
const executionSummary = `Auto-healed: previous execution completed and PR merged. PR: ${prUrl}`;
|
|
950
|
+
await updateTaskStatus(displayNumber, 'session_finished', {
|
|
951
|
+
summary: executionSummary
|
|
952
|
+
});
|
|
953
|
+
completedToday.push({
|
|
954
|
+
displayNumber, summary,
|
|
955
|
+
completedAt: new Date().toISOString(),
|
|
956
|
+
duration: 0, status: 'session_finished', prUrl
|
|
957
|
+
});
|
|
958
|
+
return true;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
if (prUrl && prState === 'OPEN') {
|
|
962
|
+
// PR is open — work is done, just needs review
|
|
963
|
+
log(`Task #${displayNumber}: PR already open (${prUrl}), updating status`);
|
|
964
|
+
const executionSummary = `Auto-healed: previous execution completed. PR pending review: ${prUrl}`;
|
|
965
|
+
await updateTaskStatus(displayNumber, 'session_finished', {
|
|
966
|
+
summary: executionSummary
|
|
967
|
+
});
|
|
968
|
+
completedToday.push({
|
|
969
|
+
displayNumber, summary,
|
|
970
|
+
completedAt: new Date().toISOString(),
|
|
971
|
+
duration: 0, status: 'session_finished', prUrl
|
|
972
|
+
});
|
|
973
|
+
return true;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
if (!prUrl) {
|
|
977
|
+
// Commits exist but no PR — create one and update status
|
|
978
|
+
log(`Task #${displayNumber}: commits exist but no PR, creating PR`);
|
|
979
|
+
const newPrUrl = createPRForTask(displayNumber, summary, projectPath);
|
|
980
|
+
if (newPrUrl) {
|
|
981
|
+
const executionSummary = `Auto-healed: previous execution had uncommitted PR. Created PR: ${newPrUrl}`;
|
|
982
|
+
await updateTaskStatus(displayNumber, 'session_finished', {
|
|
983
|
+
summary: executionSummary
|
|
984
|
+
});
|
|
985
|
+
completedToday.push({
|
|
986
|
+
displayNumber, summary,
|
|
987
|
+
completedAt: new Date().toISOString(),
|
|
988
|
+
duration: 0, status: 'session_finished', prUrl: newPrUrl
|
|
989
|
+
});
|
|
990
|
+
return true;
|
|
991
|
+
}
|
|
992
|
+
// PR creation failed — fall through to re-execute
|
|
993
|
+
log(`Task #${displayNumber}: PR creation failed, will re-execute`);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
return false;
|
|
997
|
+
} catch (error) {
|
|
998
|
+
log(`Task #${displayNumber}: auto-heal check failed: ${error.message}`);
|
|
999
|
+
return false;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
757
1003
|
// ==================== Stuck Detection ====================
|
|
758
1004
|
|
|
759
1005
|
function checkStuckPatterns(displayNumber, line) {
|
|
@@ -914,7 +1160,7 @@ function updateTaskDetail(displayNumber, updates) {
|
|
|
914
1160
|
updateStatusFile();
|
|
915
1161
|
}
|
|
916
1162
|
|
|
917
|
-
function executeTask(task) {
|
|
1163
|
+
async function executeTask(task) {
|
|
918
1164
|
// Decrypt E2EE fields
|
|
919
1165
|
task = decryptTaskFields(task);
|
|
920
1166
|
|
|
@@ -951,7 +1197,7 @@ function executeTask(task) {
|
|
|
951
1197
|
|
|
952
1198
|
if (!existsSync(projectPath)) {
|
|
953
1199
|
logError(`Task #${displayNumber}: Project path does not exist: ${projectPath}`);
|
|
954
|
-
updateTaskStatus(displayNumber, 'failed', {
|
|
1200
|
+
await updateTaskStatus(displayNumber, 'failed', {
|
|
955
1201
|
error: `Project path not found: ${projectPath}`
|
|
956
1202
|
});
|
|
957
1203
|
return null;
|
|
@@ -960,8 +1206,16 @@ function executeTask(task) {
|
|
|
960
1206
|
log(`Task #${displayNumber}: Project ${gitRemote} -> ${projectPath}`);
|
|
961
1207
|
}
|
|
962
1208
|
|
|
963
|
-
// Atomic task claiming
|
|
964
|
-
if (!claimTask(displayNumber)) {
|
|
1209
|
+
// Atomic task claiming - must await to actually check the result
|
|
1210
|
+
if (!(await claimTask(displayNumber))) {
|
|
1211
|
+
log(`Task #${displayNumber}: claim failed, skipping`);
|
|
1212
|
+
return null;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// Auto-heal: check if previous execution already completed work for this task
|
|
1216
|
+
const healed = await autoHealExistingWork(displayNumber, summary, projectPath);
|
|
1217
|
+
if (healed) {
|
|
1218
|
+
log(`Task #${displayNumber}: auto-healed from previous execution, skipping re-execution`);
|
|
965
1219
|
return null;
|
|
966
1220
|
}
|
|
967
1221
|
|
|
@@ -981,7 +1235,7 @@ function executeTask(task) {
|
|
|
981
1235
|
// Create worktree
|
|
982
1236
|
const worktreePath = createWorktree(displayNumber, projectPath);
|
|
983
1237
|
if (!worktreePath) {
|
|
984
|
-
updateTaskStatus(displayNumber, 'failed', { error: 'Failed to create git worktree' });
|
|
1238
|
+
await updateTaskStatus(displayNumber, 'failed', { error: 'Failed to create git worktree' });
|
|
985
1239
|
taskDetails.delete(displayNumber);
|
|
986
1240
|
return null;
|
|
987
1241
|
}
|
|
@@ -998,15 +1252,8 @@ IMPORTANT:
|
|
|
998
1252
|
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
1253
|
3. When you're done, the SessionEnd hook will automatically report completion to Supabase.`;
|
|
1000
1254
|
|
|
1001
|
-
//
|
|
1002
|
-
|
|
1003
|
-
event: {
|
|
1004
|
-
type: 'started',
|
|
1005
|
-
timestamp: new Date().toISOString(),
|
|
1006
|
-
machineName: getMachineName() || undefined,
|
|
1007
|
-
summary: summary.slice(0, 100),
|
|
1008
|
-
}
|
|
1009
|
-
});
|
|
1255
|
+
// Note: claimTask() already set status to 'running' with atomic: true
|
|
1256
|
+
// No duplicate status update needed here (was causing race conditions)
|
|
1010
1257
|
|
|
1011
1258
|
// Build Claude command
|
|
1012
1259
|
const allowedTools = [
|
|
@@ -1075,10 +1322,10 @@ IMPORTANT:
|
|
|
1075
1322
|
handleTaskCompletion(displayNumber, code);
|
|
1076
1323
|
});
|
|
1077
1324
|
|
|
1078
|
-
child.on('error', (error) => {
|
|
1325
|
+
child.on('error', async (error) => {
|
|
1079
1326
|
logError(`Task #${displayNumber} error: ${error.message}`);
|
|
1080
1327
|
runningTasks.delete(displayNumber);
|
|
1081
|
-
updateTaskStatus(displayNumber, 'failed', { error: error.message });
|
|
1328
|
+
await updateTaskStatus(displayNumber, 'failed', { error: error.message });
|
|
1082
1329
|
taskDetails.delete(displayNumber);
|
|
1083
1330
|
updateStatusFile();
|
|
1084
1331
|
});
|
|
@@ -1094,13 +1341,13 @@ IMPORTANT:
|
|
|
1094
1341
|
return taskInfo;
|
|
1095
1342
|
} catch (error) {
|
|
1096
1343
|
logError(`Error starting Claude for task #${displayNumber}: ${error.message}`);
|
|
1097
|
-
updateTaskStatus(displayNumber, 'failed', { error: error.message });
|
|
1344
|
+
await updateTaskStatus(displayNumber, 'failed', { error: error.message });
|
|
1098
1345
|
taskDetails.delete(displayNumber);
|
|
1099
1346
|
return null;
|
|
1100
1347
|
}
|
|
1101
1348
|
}
|
|
1102
1349
|
|
|
1103
|
-
function handleTaskCompletion(displayNumber, exitCode) {
|
|
1350
|
+
async function handleTaskCompletion(displayNumber, exitCode) {
|
|
1104
1351
|
const taskInfo = runningTasks.get(displayNumber);
|
|
1105
1352
|
if (!taskInfo) return;
|
|
1106
1353
|
|
|
@@ -1130,9 +1377,14 @@ function handleTaskCompletion(displayNumber, exitCode) {
|
|
|
1130
1377
|
// Auto-create PR first so we can include it in the summary
|
|
1131
1378
|
const prUrl = createPRForTask(displayNumber, summary, projectPath);
|
|
1132
1379
|
|
|
1133
|
-
// Ask Claude to summarize what it accomplished
|
|
1380
|
+
// Ask Claude to summarize what it accomplished (needs worktree path)
|
|
1134
1381
|
const semanticSummary = extractSemanticSummary(worktreePath, sessionId);
|
|
1135
1382
|
|
|
1383
|
+
// Clean up worktree BEFORE merge — gh pr merge --delete-branch fails if
|
|
1384
|
+
// the local branch is still referenced by a worktree. The branch itself
|
|
1385
|
+
// is preserved (only the worktree checkout is removed).
|
|
1386
|
+
cleanupWorktree(displayNumber, projectPath);
|
|
1387
|
+
|
|
1136
1388
|
// Combine: semantic summary first (what), then machine metadata (how)
|
|
1137
1389
|
let executionSummary = '';
|
|
1138
1390
|
if (semanticSummary) {
|
|
@@ -1143,11 +1395,18 @@ function handleTaskCompletion(displayNumber, exitCode) {
|
|
|
1143
1395
|
executionSummary += ` PR: ${prUrl}`;
|
|
1144
1396
|
}
|
|
1145
1397
|
|
|
1146
|
-
updateTaskStatus(displayNumber, 'session_finished', {
|
|
1398
|
+
const statusUpdated = await updateTaskStatus(displayNumber, 'session_finished', {
|
|
1147
1399
|
duration,
|
|
1148
1400
|
sessionId,
|
|
1149
1401
|
summary: executionSummary
|
|
1150
1402
|
});
|
|
1403
|
+
if (!statusUpdated) {
|
|
1404
|
+
logError(`Task #${displayNumber}: Failed to update status to session_finished — will retry`);
|
|
1405
|
+
// Retry once
|
|
1406
|
+
await updateTaskStatus(displayNumber, 'session_finished', {
|
|
1407
|
+
duration, sessionId, summary: executionSummary
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1151
1410
|
|
|
1152
1411
|
if (NOTIFY_ON_COMPLETE) {
|
|
1153
1412
|
const prNote = prUrl ? ' PR ready for review.' : '';
|
|
@@ -1170,7 +1429,10 @@ function handleTaskCompletion(displayNumber, exitCode) {
|
|
|
1170
1429
|
const comment = semanticSummary
|
|
1171
1430
|
? `${semanticSummary} (${durationStr} on ${machineName})`
|
|
1172
1431
|
: `Completed in ${durationStr} on ${machineName}`;
|
|
1173
|
-
markTaskAsCompleted(displayNumber, taskId, comment);
|
|
1432
|
+
const completed = await markTaskAsCompleted(displayNumber, taskId, comment);
|
|
1433
|
+
if (!completed) {
|
|
1434
|
+
logError(`Task #${displayNumber}: Failed to mark as completed — status is session_finished but not completed`);
|
|
1435
|
+
}
|
|
1174
1436
|
}
|
|
1175
1437
|
|
|
1176
1438
|
completedToday.push({
|
|
@@ -1185,13 +1447,17 @@ function handleTaskCompletion(displayNumber, exitCode) {
|
|
|
1185
1447
|
} else {
|
|
1186
1448
|
const stderr = taskInfo.process.stderr?.read()?.toString() || '';
|
|
1187
1449
|
|
|
1188
|
-
// Ask Claude to explain what went wrong (
|
|
1450
|
+
// Ask Claude to explain what went wrong (needs worktree path)
|
|
1189
1451
|
const failureSummary = extractSemanticSummary(worktreePath, sessionId);
|
|
1452
|
+
|
|
1453
|
+
// Clean up worktree after summary extraction
|
|
1454
|
+
cleanupWorktree(displayNumber, projectPath);
|
|
1455
|
+
|
|
1190
1456
|
const errorMsg = failureSummary
|
|
1191
1457
|
? `${failureSummary}\nExit code ${exitCode}. Ran for ${durationStr} on ${machineName}.`
|
|
1192
1458
|
: `Exit code ${exitCode}: ${stderr.slice(0, 200)}`;
|
|
1193
1459
|
|
|
1194
|
-
updateTaskStatus(displayNumber, 'failed', { error: errorMsg });
|
|
1460
|
+
await updateTaskStatus(displayNumber, 'failed', { error: errorMsg });
|
|
1195
1461
|
|
|
1196
1462
|
if (NOTIFY_ON_FAILURE) {
|
|
1197
1463
|
sendMacNotification(
|
|
@@ -1215,10 +1481,6 @@ function handleTaskCompletion(displayNumber, exitCode) {
|
|
|
1215
1481
|
taskLastOutput.delete(displayNumber);
|
|
1216
1482
|
taskStdoutBuffer.delete(displayNumber);
|
|
1217
1483
|
taskProjectPaths.delete(displayNumber);
|
|
1218
|
-
|
|
1219
|
-
// Always clean up worktree — the branch preserves all committed work.
|
|
1220
|
-
// On re-run, createWorktree() recreates from the existing branch.
|
|
1221
|
-
cleanupWorktree(displayNumber, projectPath);
|
|
1222
1484
|
updateStatusFile();
|
|
1223
1485
|
}
|
|
1224
1486
|
|
|
@@ -1442,7 +1704,13 @@ async function pollAndExecute() {
|
|
|
1442
1704
|
continue;
|
|
1443
1705
|
}
|
|
1444
1706
|
|
|
1445
|
-
|
|
1707
|
+
// Skip tasks already completed this daemon session (prevents re-execution loop)
|
|
1708
|
+
if (completedToday.some(c => c.displayNumber === displayNumber)) {
|
|
1709
|
+
log(`Task #${displayNumber} already completed this session, skipping`);
|
|
1710
|
+
continue;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
await executeTask(task);
|
|
1446
1714
|
}
|
|
1447
1715
|
|
|
1448
1716
|
updateStatusFile();
|
|
@@ -1517,10 +1785,11 @@ async function mainLoop() {
|
|
|
1517
1785
|
|
|
1518
1786
|
// ==================== Signal Handling ====================
|
|
1519
1787
|
|
|
1520
|
-
function cleanup() {
|
|
1788
|
+
async function cleanup() {
|
|
1521
1789
|
log('Daemon shutting down...');
|
|
1522
1790
|
|
|
1523
|
-
// Kill running tasks and
|
|
1791
|
+
// Kill running tasks and collect status update promises
|
|
1792
|
+
const statusPromises = [];
|
|
1524
1793
|
for (const [displayNumber, taskInfo] of runningTasks) {
|
|
1525
1794
|
log(`Killing task #${displayNumber}`);
|
|
1526
1795
|
try {
|
|
@@ -1528,19 +1797,30 @@ function cleanup() {
|
|
|
1528
1797
|
} catch {}
|
|
1529
1798
|
// Mark as failed so the task doesn't stay as 'running' forever
|
|
1530
1799
|
const duration = Math.floor((Date.now() - taskInfo.startTime) / 1000);
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1800
|
+
statusPromises.push(
|
|
1801
|
+
updateTaskStatus(displayNumber, 'failed', {
|
|
1802
|
+
error: `Daemon shutdown after ${duration}s`,
|
|
1803
|
+
event: {
|
|
1804
|
+
type: 'daemon_shutdown',
|
|
1805
|
+
timestamp: new Date().toISOString(),
|
|
1806
|
+
machineName: getMachineName() || undefined,
|
|
1807
|
+
summary: `Daemon restarted after ${duration}s`,
|
|
1808
|
+
}
|
|
1809
|
+
})
|
|
1810
|
+
);
|
|
1540
1811
|
const projectPath = taskProjectPaths.get(displayNumber);
|
|
1541
1812
|
cleanupWorktree(displayNumber, projectPath);
|
|
1542
1813
|
}
|
|
1543
1814
|
|
|
1815
|
+
// Wait for all status updates to land (max 5s timeout)
|
|
1816
|
+
if (statusPromises.length > 0) {
|
|
1817
|
+
log(`Waiting for ${statusPromises.length} status update(s) to complete...`);
|
|
1818
|
+
await Promise.race([
|
|
1819
|
+
Promise.allSettled(statusPromises),
|
|
1820
|
+
new Promise(resolve => setTimeout(resolve, 5000))
|
|
1821
|
+
]);
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1544
1824
|
// Clean up files
|
|
1545
1825
|
try { unlinkSync(PID_FILE); } catch {}
|
|
1546
1826
|
|
|
@@ -1555,11 +1835,11 @@ function cleanup() {
|
|
|
1555
1835
|
process.exit(0);
|
|
1556
1836
|
}
|
|
1557
1837
|
|
|
1558
|
-
process.on('SIGTERM', cleanup);
|
|
1559
|
-
process.on('SIGINT', cleanup);
|
|
1838
|
+
process.on('SIGTERM', () => cleanup().catch(() => process.exit(1)));
|
|
1839
|
+
process.on('SIGINT', () => cleanup().catch(() => process.exit(1)));
|
|
1560
1840
|
process.on('uncaughtException', (error) => {
|
|
1561
1841
|
logError(`Uncaught exception: ${error.message}`);
|
|
1562
|
-
cleanup();
|
|
1842
|
+
cleanup().catch(() => process.exit(1));
|
|
1563
1843
|
});
|
|
1564
1844
|
|
|
1565
1845
|
// ==================== Entry Point ====================
|
package/lib/fetch.js
CHANGED
|
@@ -11,7 +11,7 @@ import { getGitRemote, isGitRepo } from './utils/git.js';
|
|
|
11
11
|
import { formatTaskForDisplay, formatSearchResult } from './utils/format.js';
|
|
12
12
|
import { bold, green, yellow, red, cyan, dim, muted } from './utils/colors.js';
|
|
13
13
|
import { decryptTodoField, isE2EEAvailable } from './encryption.js';
|
|
14
|
-
import { getAutoCommitEnabled, getMaxBatchSize } from './config.js';
|
|
14
|
+
import { getAutoCommitEnabled, getAutoMergeEnabled, getAutoCompleteEnabled, getAutoUpdateEnabled, getMaxBatchSize } from './config.js';
|
|
15
15
|
import { writeFileSync, mkdirSync } from 'fs';
|
|
16
16
|
import { homedir } from 'os';
|
|
17
17
|
import { join } from 'path';
|
|
@@ -333,6 +333,9 @@ export async function showStatus(options = {}) {
|
|
|
333
333
|
const registry = getRegistry();
|
|
334
334
|
const [e2eeAvailable, e2eeMessage] = isE2EEAvailable();
|
|
335
335
|
const autoCommit = getAutoCommitEnabled();
|
|
336
|
+
const autoMerge = getAutoMergeEnabled();
|
|
337
|
+
const autoComplete = getAutoCompleteEnabled();
|
|
338
|
+
const autoUpdate = getAutoUpdateEnabled();
|
|
336
339
|
const maxBatch = getMaxBatchSize();
|
|
337
340
|
|
|
338
341
|
// Validate API key
|
|
@@ -358,6 +361,9 @@ export async function showStatus(options = {}) {
|
|
|
358
361
|
},
|
|
359
362
|
settings: {
|
|
360
363
|
autoCommit,
|
|
364
|
+
autoMerge,
|
|
365
|
+
autoComplete,
|
|
366
|
+
autoUpdate,
|
|
361
367
|
maxBatchSize: maxBatch
|
|
362
368
|
},
|
|
363
369
|
registeredProjects: registry.projectCount()
|
|
@@ -401,6 +407,9 @@ export async function showStatus(options = {}) {
|
|
|
401
407
|
|
|
402
408
|
// Settings
|
|
403
409
|
console.log(`${bold('Auto-commit:')} ${autoCommit ? 'Enabled' : 'Disabled'}`);
|
|
410
|
+
console.log(`${bold('Auto-merge:')} ${autoMerge ? 'Enabled' : 'Disabled'}`);
|
|
411
|
+
console.log(`${bold('Auto-complete:')} ${autoComplete ? 'Enabled' : 'Disabled'}`);
|
|
412
|
+
console.log(`${bold('Auto-update:')} ${autoUpdate ? 'Enabled' : 'Disabled'}`);
|
|
404
413
|
console.log(`${bold('Max batch size:')} ${maxBatch}`);
|
|
405
414
|
console.log(`${bold('Registered projects:')} ${status.registeredProjects}`);
|
|
406
415
|
}
|