@litmers/cursorflow-orchestrator 0.1.28 → 0.1.30

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.
@@ -11,7 +11,6 @@ import { loadConfig, getTasksDir } from '../utils/config';
11
11
  import { Task, RunnerConfig } from '../utils/types';
12
12
  import { safeJoin } from '../utils/path';
13
13
  import { resolveTemplate } from '../utils/template';
14
- import * as git from '../utils/git';
15
14
 
16
15
  // Preset template types
17
16
  type PresetType = 'complex' | 'simple' | 'merge';
@@ -33,9 +32,6 @@ interface PrepareOptions {
33
32
  addLane: string | null; // Add lane to existing task dir
34
33
  addTask: string | null; // Add task to existing lane file
35
34
  dependsOnLanes: string[]; // --depends-on for new lane
36
- // Git options
37
- commit: boolean; // Commit and push current changes before prepare
38
- commitMessage: string | null; // Custom commit message
39
35
  force: boolean;
40
36
  help: boolean;
41
37
  }
@@ -118,10 +114,6 @@ Prepare task files for a new feature - Terminal-first workflow.
118
114
  --add-lane <dir> Add a new lane to existing task directory
119
115
  --add-task <file> Append task(s) to existing lane JSON file
120
116
 
121
- Git (commit before prepare):
122
- --commit Commit and push current changes before prepare
123
- --commit-message <msg> Custom commit message (default: auto-generated)
124
-
125
117
  Advanced:
126
118
  --template <path|url|name> External template JSON file, URL, or built-in name
127
119
  --force Overwrite existing files
@@ -165,8 +157,6 @@ function parseArgs(args: string[]): PrepareOptions {
165
157
  addLane: null,
166
158
  addTask: null,
167
159
  dependsOnLanes: [],
168
- commit: false,
169
- commitMessage: null,
170
160
  force: false,
171
161
  help: false,
172
162
  };
@@ -208,11 +198,6 @@ function parseArgs(args: string[]): PrepareOptions {
208
198
  result.addTask = args[++i];
209
199
  } else if (arg === '--depends-on' && args[i + 1]) {
210
200
  result.dependsOnLanes = args[++i].split(',').map(d => d.trim()).filter(d => d);
211
- } else if (arg === '--commit') {
212
- result.commit = true;
213
- } else if ((arg === '--commit-message' || arg === '-m') && args[i + 1]) {
214
- result.commitMessage = args[++i];
215
- result.commit = true; // Implicitly enable commit if message is provided
216
201
  } else if (!arg.startsWith('--') && !result.featureName) {
217
202
  result.featureName = arg;
218
203
  }
@@ -867,75 +852,6 @@ cursorflow prepare --add-task ${path.relative(config.projectRoot, taskDir)}/01-l
867
852
  console.log('');
868
853
  }
869
854
 
870
- /**
871
- * Commit and push current changes before prepare
872
- */
873
- async function commitAndPush(featureName: string, customMessage: string | null): Promise<boolean> {
874
- // Check if there are uncommitted changes
875
- const statusResult = git.runGitResult(['status', '--porcelain']);
876
- if (!statusResult.success) {
877
- logger.error('Failed to check git status');
878
- return false;
879
- }
880
-
881
- const hasChanges = statusResult.stdout.trim().length > 0;
882
-
883
- if (!hasChanges) {
884
- logger.info('No uncommitted changes to commit');
885
- return true;
886
- }
887
-
888
- // Show what will be committed
889
- logger.section('📦 Committing Current Changes');
890
- const changedFiles = statusResult.stdout.trim().split('\n');
891
- logger.info(`${changedFiles.length} file(s) to commit:`);
892
- for (const file of changedFiles.slice(0, 5)) {
893
- console.log(` ${file}`);
894
- }
895
- if (changedFiles.length > 5) {
896
- console.log(` ... and ${changedFiles.length - 5} more`);
897
- }
898
- console.log('');
899
-
900
- // Stage all changes
901
- const addResult = git.runGitResult(['add', '-A']);
902
- if (!addResult.success) {
903
- logger.error(`Failed to stage changes: ${addResult.stderr}`);
904
- return false;
905
- }
906
-
907
- // Generate commit message
908
- const message = customMessage || `chore: pre-prepare checkpoint for ${featureName}`;
909
-
910
- // Commit
911
- const commitResult = git.runGitResult(['commit', '-m', message]);
912
- if (!commitResult.success) {
913
- logger.error(`Failed to commit: ${commitResult.stderr}`);
914
- return false;
915
- }
916
- logger.success(`Committed: ${message}`);
917
-
918
- // Push
919
- logger.info('Pushing to remote...');
920
- const currentBranch = git.getCurrentBranch();
921
- const pushResult = git.runGitResult(['push', 'origin', currentBranch]);
922
- if (!pushResult.success) {
923
- // Try to provide helpful error message
924
- if (pushResult.stderr.includes('rejected') || pushResult.stderr.includes('non-fast-forward')) {
925
- logger.warn('Push rejected. Try pulling first:');
926
- console.log(` git pull --rebase origin ${currentBranch}`);
927
- console.log(' Then run prepare again');
928
- } else {
929
- logger.error(`Failed to push: ${pushResult.stderr}`);
930
- }
931
- return false;
932
- }
933
- logger.success(`Pushed to origin/${currentBranch}`);
934
- console.log('');
935
-
936
- return true;
937
- }
938
-
939
855
  async function prepare(args: string[]): Promise<void> {
940
856
  const options = parseArgs(args);
941
857
 
@@ -944,15 +860,6 @@ async function prepare(args: string[]): Promise<void> {
944
860
  return;
945
861
  }
946
862
 
947
- // Handle --commit option first (before any prepare operation)
948
- if (options.commit) {
949
- const featureName = options.featureName || options.addLane || options.addTask || 'tasks';
950
- const success = await commitAndPush(featureName, options.commitMessage);
951
- if (!success) {
952
- throw new Error('Failed to commit and push. Fix the issue and try again.');
953
- }
954
- }
955
-
956
863
  // Mode 1: Add task to existing lane
957
864
  if (options.addTask) {
958
865
  await addTaskToLane(options);
@@ -283,9 +283,11 @@ export class AutoRecoveryManager {
283
283
  if (!state) return;
284
284
 
285
285
  const now = Date.now();
286
- state.lastActivityTime = now;
287
286
 
287
+ // Only update activity time if we actually received bytes
288
+ // This allows heartbeats to be recorded (for logs/bytes) without resetting the idle timer
288
289
  if (bytesReceived > 0) {
290
+ state.lastActivityTime = now;
289
291
  state.lastBytesReceived = bytesReceived;
290
292
  state.totalBytesReceived += bytesReceived;
291
293
  }
@@ -149,7 +149,14 @@ export function analyzeStall(context: StallContext, config: StallDetectionConfig
149
149
 
150
150
  // Check if this might be a long operation
151
151
  const isLongOperation = lastOutput && config.longOperationPatterns.some(p => p.test(lastOutput));
152
- const effectiveIdleTimeout = isLongOperation ? config.longOperationGraceMs : config.idleTimeoutMs;
152
+
153
+ // If it's a long operation but we've received 0 real bytes for a while,
154
+ // reduce the grace period to avoid waiting forever for a hung process.
155
+ // We use 2x the normal idle timeout as a "sanity check" for silent long operations.
156
+ const silentLongOpCappedTimeout = config.idleTimeoutMs * 2;
157
+ const effectiveIdleTimeout = isLongOperation
158
+ ? (bytesReceived === 0 ? Math.min(config.longOperationGraceMs, silentLongOpCappedTimeout) : config.longOperationGraceMs)
159
+ : config.idleTimeoutMs;
153
160
 
154
161
  // Check for task timeout
155
162
  if (taskStartTimeMs && (Date.now() - taskStartTimeMs) > config.taskTimeoutMs) {
@@ -86,6 +86,26 @@ interface RunningLaneInfo {
86
86
  continueSignalsSent: number; // Number of continue signals sent
87
87
  }
88
88
 
89
+ /**
90
+ * Log the tail of a file
91
+ */
92
+ function logFileTail(filePath: string, lines: number = 10): void {
93
+ if (!fs.existsSync(filePath)) return;
94
+ try {
95
+ const content = fs.readFileSync(filePath, 'utf8');
96
+ const allLines = content.split('\n');
97
+ const tail = allLines.slice(-lines).filter(l => l.trim());
98
+ if (tail.length > 0) {
99
+ logger.error(` Last ${tail.length} lines of log:`);
100
+ for (const line of tail) {
101
+ logger.error(` ${line}`);
102
+ }
103
+ }
104
+ } catch (e) {
105
+ // Ignore log reading errors
106
+ }
107
+ }
108
+
89
109
  /**
90
110
  * Spawn a lane process
91
111
  */
@@ -651,7 +671,28 @@ export async function orchestrate(tasksDir: string, options: {
651
671
  printLaneStatus(lanes, laneRunDirs);
652
672
  }, options.pollInterval || 60000);
653
673
 
654
- while (completedLanes.size + failedLanes.size + blockedLanes.size < lanes.length || (blockedLanes.size > 0 && running.size === 0)) {
674
+ // Handle process interruption
675
+ const sigIntHandler = () => {
676
+ logger.warn('\n⚠️ Orchestration interrupted! Stopping all lanes...');
677
+ for (const [name, info] of running.entries()) {
678
+ logger.info(`Stopping lane: ${name}`);
679
+ try {
680
+ info.child.kill('SIGTERM');
681
+ } catch {
682
+ // Ignore kill errors
683
+ }
684
+ }
685
+ printLaneStatus(lanes, laneRunDirs);
686
+ process.exit(130);
687
+ };
688
+
689
+ process.on('SIGINT', sigIntHandler);
690
+ process.on('SIGTERM', sigIntHandler);
691
+
692
+ let lastStallCheck = Date.now();
693
+
694
+ try {
695
+ while (completedLanes.size + failedLanes.size + blockedLanes.size < lanes.length || (blockedLanes.size > 0 && running.size === 0)) {
655
696
  // 1. Identify lanes ready to start
656
697
  const readyToStart = lanes.filter(lane => {
657
698
  // Not already running or completed or failed or blocked
@@ -692,6 +733,23 @@ export async function orchestrate(tasksDir: string, options: {
692
733
 
693
734
  logger.info(`Lane started: ${lane.name}${lane.startIndex ? ` (resuming from ${lane.startIndex})` : ''}`);
694
735
 
736
+ const now = Date.now();
737
+ // Pre-register lane in running map so onActivity can find it immediately
738
+ running.set(lane.name, {
739
+ child: {} as any, // Placeholder, will be replaced below
740
+ logManager: undefined,
741
+ logPath: '',
742
+ lastActivity: now,
743
+ lastStateUpdate: now,
744
+ stallPhase: 0,
745
+ taskStartTime: now,
746
+ lastOutput: '',
747
+ statePath: laneStatePath,
748
+ bytesReceived: 0,
749
+ lastBytesCheck: 0,
750
+ continueSignalsSent: 0,
751
+ });
752
+
695
753
  let lastOutput = '';
696
754
  const spawnResult = spawnLane({
697
755
  laneName: lane.name,
@@ -706,44 +764,45 @@ export async function orchestrate(tasksDir: string, options: {
706
764
  onActivity: () => {
707
765
  const info = running.get(lane.name);
708
766
  if (info) {
709
- const now = Date.now();
710
- info.lastActivity = now;
711
- // Also reset progress tracking when there's activity (THNK/TOOL events)
712
- // This prevents STALL_NO_PROGRESS from firing when agent is actively working
713
- info.lastStateUpdate = now;
714
- info.stallPhase = 0; // Reset stall phase since agent is responding
767
+ const actNow = Date.now();
768
+ info.lastActivity = actNow;
769
+ info.lastStateUpdate = actNow;
770
+ info.stallPhase = 0;
715
771
  }
716
772
  }
717
773
  });
718
774
 
775
+ // Update with actual spawn result
776
+ const existingInfo = running.get(lane.name)!;
777
+ Object.assign(existingInfo, spawnResult);
778
+
719
779
  // Track last output and bytes received for long operation and stall detection
720
780
  if (spawnResult.child.stdout) {
721
781
  spawnResult.child.stdout.on('data', (data: Buffer) => {
722
782
  const info = running.get(lane.name);
723
783
  if (info) {
724
- info.lastOutput = data.toString().trim().split('\n').pop() || '';
725
- info.bytesReceived += data.length;
784
+ const output = data.toString();
785
+ const lines = output.split('\n').filter(l => l.trim());
786
+
787
+ // Filter out heartbeats from activity tracking to avoid resetting stall detection
788
+ const realLines = lines.filter(line => !(line.includes('Heartbeat') && line.includes('bytes received')));
726
789
 
727
- // Update auto-recovery manager
728
- autoRecoveryManager.recordActivity(lane.name, data.length, info.lastOutput);
790
+ if (realLines.length > 0) {
791
+ // Real activity detected
792
+ const lastRealLine = realLines[realLines.length - 1]!;
793
+ info.lastOutput = lastRealLine;
794
+ info.bytesReceived += data.length;
795
+
796
+ // Update auto-recovery manager with real activity
797
+ autoRecoveryManager.recordActivity(lane.name, data.length, info.lastOutput);
798
+ } else if (lines.length > 0) {
799
+ // Only heartbeats received - update auto-recovery manager with 0 bytes to avoid resetting idle timer
800
+ autoRecoveryManager.recordActivity(lane.name, 0, info.lastOutput);
801
+ }
729
802
  }
730
803
  });
731
804
  }
732
805
 
733
- const now = Date.now();
734
- running.set(lane.name, {
735
- ...spawnResult,
736
- lastActivity: now,
737
- lastStateUpdate: now,
738
- stallPhase: 0,
739
- taskStartTime: now,
740
- lastOutput: '',
741
- statePath: laneStatePath,
742
- bytesReceived: 0,
743
- lastBytesCheck: 0,
744
- continueSignalsSent: 0,
745
- });
746
-
747
806
  // Register lane with auto-recovery manager
748
807
  autoRecoveryManager.registerLane(lane.name);
749
808
 
@@ -773,13 +832,19 @@ export async function orchestrate(tasksDir: string, options: {
773
832
  const result = await Promise.race([...promises, pollPromise]);
774
833
  if (pollTimeout) clearTimeout(pollTimeout);
775
834
 
776
- if (result.name === '__poll__') {
835
+ const now = Date.now();
836
+ if (result.name === '__poll__' || (now - lastStallCheck >= 10000)) {
837
+ lastStallCheck = now;
838
+
777
839
  // Periodic stall check with multi-layer detection and escalating recovery
778
840
  for (const [laneName, info] of running.entries()) {
779
- const now = Date.now();
780
841
  const idleTime = now - info.lastActivity;
781
842
  const lane = lanes.find(l => l.name === laneName)!;
782
843
 
844
+ if (process.env['DEBUG_STALL']) {
845
+ logger.debug(`[${laneName}] Stall check: idle=${Math.round(idleTime/1000)}s, bytesDelta=${info.bytesReceived - info.lastBytesCheck}, phase=${info.stallPhase}`);
846
+ }
847
+
783
848
  // Check state file for progress updates
784
849
  let progressTime = 0;
785
850
  try {
@@ -1002,74 +1067,98 @@ export async function orchestrate(tasksDir: string, options: {
1002
1067
  }
1003
1068
  }
1004
1069
  continue;
1005
- }
1006
-
1007
- const finished = result;
1008
- const info = running.get(finished.name)!;
1009
- running.delete(finished.name);
1010
- exitCodes[finished.name] = finished.code;
1011
-
1012
- // Unregister from auto-recovery manager
1013
- autoRecoveryManager.unregisterLane(finished.name);
1014
-
1015
- if (finished.code === 0) {
1016
- completedLanes.add(finished.name);
1017
- events.emit('lane.completed', {
1018
- laneName: finished.name,
1019
- exitCode: finished.code,
1020
- });
1021
- } else if (finished.code === 2) {
1022
- // Blocked by dependency
1023
- const statePath = safeJoin(laneRunDirs[finished.name]!, 'state.json');
1024
- const state = loadState<LaneState>(statePath);
1070
+ } else {
1071
+ const finished = result;
1072
+ const info = running.get(finished.name)!;
1073
+ running.delete(finished.name);
1074
+ exitCodes[finished.name] = finished.code;
1025
1075
 
1026
- if (state && state.dependencyRequest) {
1027
- blockedLanes.set(finished.name, state.dependencyRequest);
1028
- const lane = lanes.find(l => l.name === finished.name);
1029
- if (lane) {
1030
- lane.startIndex = Math.max(0, state.currentTaskIndex - 1); // Task was blocked, retry it
1031
- }
1032
-
1033
- events.emit('lane.blocked', {
1076
+ // Unregister from auto-recovery manager
1077
+ autoRecoveryManager.unregisterLane(finished.name);
1078
+
1079
+ if (finished.code === 0) {
1080
+ completedLanes.add(finished.name);
1081
+ events.emit('lane.completed', {
1034
1082
  laneName: finished.name,
1035
- dependencyRequest: state.dependencyRequest,
1083
+ exitCode: finished.code,
1036
1084
  });
1037
- logger.warn(`Lane ${finished.name} is blocked on dependency change request`);
1038
- } else {
1039
- failedLanes.add(finished.name);
1040
- logger.error(`Lane ${finished.name} exited with code 2 but no dependency request found`);
1041
- }
1042
- } else {
1043
- // Check if it was a restart request
1044
- if (info.stallPhase === 2) {
1045
- logger.info(`🔄 Lane ${finished.name} is being restarted due to stall...`);
1046
-
1047
- // Update startIndex from current state to resume from the same task
1085
+ } else if (finished.code === 2) {
1086
+ // Blocked by dependency
1048
1087
  const statePath = safeJoin(laneRunDirs[finished.name]!, 'state.json');
1049
1088
  const state = loadState<LaneState>(statePath);
1050
- if (state) {
1089
+
1090
+ if (state && state.dependencyRequest) {
1091
+ blockedLanes.set(finished.name, state.dependencyRequest);
1051
1092
  const lane = lanes.find(l => l.name === finished.name);
1052
1093
  if (lane) {
1053
- lane.startIndex = state.currentTaskIndex;
1094
+ lane.startIndex = Math.max(0, state.currentTaskIndex - 1); // Task was blocked, retry it
1054
1095
  }
1096
+
1097
+ events.emit('lane.blocked', {
1098
+ laneName: finished.name,
1099
+ dependencyRequest: state.dependencyRequest,
1100
+ });
1101
+ logger.warn(`Lane ${finished.name} is blocked on dependency change request`);
1102
+ } else {
1103
+ failedLanes.add(finished.name);
1104
+ logger.error(`Lane ${finished.name} exited with code 2 but no dependency request found`);
1055
1105
  }
1106
+ } else {
1107
+ // Check if it was a restart request
1108
+ if (info.stallPhase === 2) {
1109
+ logger.info(`🔄 Lane ${finished.name} is being restarted due to stall...`);
1110
+
1111
+ // Update startIndex from current state to resume from the same task
1112
+ const statePath = safeJoin(laneRunDirs[finished.name]!, 'state.json');
1113
+ const state = loadState<LaneState>(statePath);
1114
+ if (state) {
1115
+ const lane = lanes.find(l => l.name === finished.name);
1116
+ if (lane) {
1117
+ lane.startIndex = state.currentTaskIndex;
1118
+ }
1119
+ }
1120
+
1121
+ // Note: we don't add to failedLanes or completedLanes,
1122
+ // so it will be eligible to start again in the next iteration.
1123
+ continue;
1124
+ }
1125
+
1126
+ failedLanes.add(finished.name);
1056
1127
 
1057
- // Note: we don't add to failedLanes or completedLanes,
1058
- // so it will be eligible to start again in the next iteration.
1059
- continue;
1060
- }
1128
+ let errorMsg = 'Process exited with non-zero code';
1129
+ if (info.stallPhase === 3) {
1130
+ errorMsg = 'Stopped due to repeated stall';
1131
+ } else if (info.logManager) {
1132
+ const lastError = info.logManager.getLastError();
1133
+ if (lastError) {
1134
+ errorMsg = `Process failed: ${lastError}`;
1135
+ }
1136
+ }
1061
1137
 
1062
- failedLanes.add(finished.name);
1063
- events.emit('lane.failed', {
1064
- laneName: finished.name,
1065
- exitCode: finished.code,
1066
- error: info.stallPhase === 3 ? 'Stopped due to repeated stall' : 'Process exited with non-zero code',
1067
- });
1138
+ logger.error(`[${finished.name}] Lane failed with exit code ${finished.code}: ${errorMsg}`);
1139
+
1140
+ // Log log tail for visibility
1141
+ if (info.logPath) {
1142
+ logFileTail(info.logPath, 15);
1143
+ }
1144
+
1145
+ events.emit('lane.failed', {
1146
+ laneName: finished.name,
1147
+ exitCode: finished.code,
1148
+ error: errorMsg,
1149
+ });
1150
+ }
1151
+
1152
+ printLaneStatus(lanes, laneRunDirs);
1068
1153
  }
1069
-
1070
- printLaneStatus(lanes, laneRunDirs);
1071
1154
  } else {
1072
1155
  // Nothing running. Are we blocked?
1156
+
1157
+ // Wait a bit to avoid busy-spin while waiting for dependencies or new slots
1158
+ if (completedLanes.size + failedLanes.size + blockedLanes.size < lanes.length) {
1159
+ await new Promise(resolve => setTimeout(resolve, 2000));
1160
+ }
1161
+
1073
1162
  if (blockedLanes.size > 0 && autoResolve) {
1074
1163
  logger.section('🛠 Auto-Resolving Dependencies');
1075
1164
 
@@ -1098,10 +1187,14 @@ export async function orchestrate(tasksDir: string, options: {
1098
1187
  // All finished
1099
1188
  break;
1100
1189
  }
1190
+ }
1101
1191
  }
1192
+ } finally {
1193
+ clearInterval(monitorInterval);
1194
+ process.removeListener('SIGINT', sigIntHandler);
1195
+ process.removeListener('SIGTERM', sigIntHandler);
1102
1196
  }
1103
1197
 
1104
- clearInterval(monitorInterval);
1105
1198
  printLaneStatus(lanes, laneRunDirs);
1106
1199
 
1107
1200
  // Check for failures
@@ -6,6 +6,7 @@
6
6
 
7
7
  import * as path from 'path';
8
8
  import * as fs from 'fs';
9
+ import * as git from './git';
9
10
  import { CursorFlowConfig } from './types';
10
11
  import { safeJoin } from './path';
11
12
  export { CursorFlowConfig };
@@ -167,6 +168,7 @@ export function validateConfig(config: CursorFlowConfig): boolean {
167
168
  */
168
169
  export function createDefaultConfig(projectRoot: string, force = false): string {
169
170
  const configPath = safeJoin(projectRoot, 'cursorflow.config.js');
171
+ const currentBranch = git.getCurrentBranch() || 'main';
170
172
 
171
173
  const template = `module.exports = {
172
174
  // Directory configuration
@@ -175,7 +177,7 @@ export function createDefaultConfig(projectRoot: string, force = false): string
175
177
  pofDir: '_cursorflow/pof',
176
178
 
177
179
  // Git configuration
178
- baseBranch: git.getCurrentBranch() || 'main',
180
+ baseBranch: '${currentBranch}',
179
181
  branchPrefix: 'feature/',
180
182
 
181
183
  // Execution configuration
@@ -1024,7 +1024,7 @@ export class EnhancedLogManager {
1024
1024
  }
1025
1025
 
1026
1026
  /**
1027
- * Close all log files
1027
+ * Close all log files and ensure all data is flushed to disk
1028
1028
  */
1029
1029
  public close(): void {
1030
1030
  // Flush transform stream
@@ -1051,46 +1051,87 @@ export class EnhancedLogManager {
1051
1051
  `;
1052
1052
 
1053
1053
  if (this.cleanLogFd !== null) {
1054
- fs.writeSync(this.cleanLogFd, endMarker);
1055
- fs.closeSync(this.cleanLogFd);
1054
+ try {
1055
+ fs.writeSync(this.cleanLogFd, endMarker);
1056
+ fs.fsyncSync(this.cleanLogFd);
1057
+ fs.closeSync(this.cleanLogFd);
1058
+ } catch {}
1056
1059
  this.cleanLogFd = null;
1057
1060
  }
1058
1061
 
1059
1062
  if (this.rawLogFd !== null) {
1060
- fs.writeSync(this.rawLogFd, endMarker);
1061
- fs.closeSync(this.rawLogFd);
1063
+ try {
1064
+ fs.writeSync(this.rawLogFd, endMarker);
1065
+ fs.fsyncSync(this.rawLogFd);
1066
+ fs.closeSync(this.rawLogFd);
1067
+ } catch {}
1062
1068
  this.rawLogFd = null;
1063
1069
  }
1064
1070
 
1065
1071
  if (this.absoluteRawLogFd !== null) {
1066
- fs.closeSync(this.absoluteRawLogFd);
1072
+ try {
1073
+ fs.fsyncSync(this.absoluteRawLogFd);
1074
+ fs.closeSync(this.absoluteRawLogFd);
1075
+ } catch {}
1067
1076
  this.absoluteRawLogFd = null;
1068
1077
  }
1069
1078
 
1070
1079
  if (this.jsonLogFd !== null) {
1071
- this.writeJsonEntry({
1072
- timestamp: new Date().toISOString(),
1073
- level: 'session',
1074
- source: 'system',
1075
- lane: this.session.laneName,
1076
- message: 'Session ended',
1077
- metadata: {
1078
- sessionId: this.session.id,
1079
- duration: Date.now() - this.session.startTime,
1080
- },
1081
- });
1082
- fs.closeSync(this.jsonLogFd);
1080
+ try {
1081
+ this.writeJsonEntry({
1082
+ timestamp: new Date().toISOString(),
1083
+ level: 'session',
1084
+ source: 'system',
1085
+ lane: this.session.laneName,
1086
+ message: 'Session ended',
1087
+ metadata: {
1088
+ sessionId: this.session.id,
1089
+ duration: Date.now() - this.session.startTime,
1090
+ },
1091
+ });
1092
+ fs.fsyncSync(this.jsonLogFd);
1093
+ fs.closeSync(this.jsonLogFd);
1094
+ } catch {}
1083
1095
  this.jsonLogFd = null;
1084
1096
  }
1085
1097
 
1086
1098
  // Close readable log
1087
1099
  if (this.readableLogFd !== null) {
1088
- fs.writeSync(this.readableLogFd, endMarker);
1089
- fs.closeSync(this.readableLogFd);
1100
+ try {
1101
+ fs.writeSync(this.readableLogFd, endMarker);
1102
+ fs.fsyncSync(this.readableLogFd);
1103
+ fs.closeSync(this.readableLogFd);
1104
+ } catch {}
1090
1105
  this.readableLogFd = null;
1091
1106
  }
1092
1107
  }
1093
1108
 
1109
+ /**
1110
+ * Extract the last error message from the clean log file
1111
+ */
1112
+ public getLastError(): string | null {
1113
+ try {
1114
+ if (!fs.existsSync(this.cleanLogPath)) return null;
1115
+ const content = fs.readFileSync(this.cleanLogPath, 'utf8');
1116
+ // Look for lines containing error markers
1117
+ const lines = content.split('\n').filter(l =>
1118
+ l.includes('[ERROR]') ||
1119
+ l.includes('❌') ||
1120
+ l.includes('error:') ||
1121
+ l.includes('Fatal') ||
1122
+ l.includes('fail')
1123
+ );
1124
+ if (lines.length === 0) {
1125
+ // Fallback to last 5 lines if no specific error marker found
1126
+ const allLines = content.split('\n').filter(l => l.trim());
1127
+ return allLines.slice(-5).join('\n');
1128
+ }
1129
+ return lines[lines.length - 1]!.trim();
1130
+ } catch {
1131
+ return null;
1132
+ }
1133
+ }
1134
+
1094
1135
  /**
1095
1136
  * Format duration for display
1096
1137
  */