@lumenflow/core 2.4.0 → 2.5.1

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.
@@ -28,6 +28,7 @@
28
28
  * @see {@link packages/@lumenflow/cli/src/initiative-create.ts} - Initiative creation (WU-1439)
29
29
  */
30
30
  import type { GitAdapter } from './git-adapter.js';
31
+ import type { PushRetryConfig } from './lumenflow-config-schema.js';
31
32
  /**
32
33
  * Context passed to the execute function in withMicroWorktree
33
34
  */
@@ -81,8 +82,17 @@ export declare const MAX_MERGE_RETRIES = 3;
81
82
  * WU-1179: When push fails due to race condition (origin advanced while we
82
83
  * were working), rollback local main to origin/main and retry.
83
84
  * Each retry: fetch -> rebase temp branch -> re-merge -> push.
85
+ *
86
+ * @deprecated Use DEFAULT_PUSH_RETRY_CONFIG.retries instead (WU-1332)
84
87
  */
85
88
  export declare const MAX_PUSH_RETRIES = 3;
89
+ /**
90
+ * WU-1332: Default push retry configuration
91
+ *
92
+ * Provides sensible defaults for micro-worktree push operations.
93
+ * Can be overridden via .lumenflow.config.yaml git.push_retry section.
94
+ */
95
+ export declare const DEFAULT_PUSH_RETRY_CONFIG: PushRetryConfig;
86
96
  /**
87
97
  * Environment variable name for LUMENFLOW_FORCE bypass
88
98
  *
@@ -101,6 +111,102 @@ export declare const LUMENFLOW_FORCE_REASON_ENV = "LUMENFLOW_FORCE_REASON";
101
111
  * Extracted to constant to satisfy sonarjs/no-duplicate-string rule.
102
112
  */
103
113
  export declare const DEFAULT_LOG_PREFIX = "[micro-wt]";
114
+ /**
115
+ * WU-1336: Typed error for retry exhaustion in micro-worktree operations
116
+ *
117
+ * Thrown when push retries are exhausted due to race conditions with parallel agents.
118
+ * CLI commands should use `isRetryExhaustionError` to detect this error type and
119
+ * `formatRetryExhaustionError` to generate actionable user-facing messages.
120
+ *
121
+ * This centralizes retry exhaustion handling so CLI commands do not need to
122
+ * duplicate detection logic or error formatting.
123
+ *
124
+ * @example
125
+ * ```typescript
126
+ * import { RetryExhaustionError, isRetryExhaustionError, formatRetryExhaustionError } from '@lumenflow/core';
127
+ *
128
+ * try {
129
+ * await withMicroWorktree({ ... });
130
+ * } catch (error) {
131
+ * if (isRetryExhaustionError(error)) {
132
+ * console.error(formatRetryExhaustionError(error, { command: 'pnpm initiative:add-wu ...' }));
133
+ * } else {
134
+ * throw error;
135
+ * }
136
+ * }
137
+ * ```
138
+ */
139
+ export declare class RetryExhaustionError extends Error {
140
+ /** Name of the error class (for instanceof checks across module boundaries) */
141
+ readonly name = "RetryExhaustionError";
142
+ /** Operation that was being performed (e.g., 'initiative-add-wu') */
143
+ readonly operation: string;
144
+ /** Number of retry attempts that were exhausted */
145
+ readonly retries: number;
146
+ constructor(operation: string, retries: number);
147
+ }
148
+ /**
149
+ * WU-1336: Options for formatting retry exhaustion error messages
150
+ */
151
+ export interface FormatRetryExhaustionOptions {
152
+ /** Command to suggest for retrying (e.g., 'pnpm initiative:add-wu --wu WU-123 --initiative INIT-001') */
153
+ command: string;
154
+ }
155
+ /**
156
+ * WU-1336: Type guard to check if an error is a retry exhaustion error
157
+ *
158
+ * Detects both the typed `RetryExhaustionError` class and legacy error messages
159
+ * that match the "Push failed after N attempts" pattern.
160
+ *
161
+ * @param {unknown} error - Error to check
162
+ * @returns {boolean} True if this is a retry exhaustion error
163
+ *
164
+ * @example
165
+ * ```typescript
166
+ * if (isRetryExhaustionError(error)) {
167
+ * // Handle retry exhaustion
168
+ * }
169
+ * ```
170
+ */
171
+ export declare function isRetryExhaustionError(error: unknown): error is Error;
172
+ /**
173
+ * WU-1336: Format retry exhaustion error with actionable next steps
174
+ *
175
+ * When push retries are exhausted, provides clear guidance on how to proceed.
176
+ * CLI commands should use this instead of duplicating error formatting logic.
177
+ *
178
+ * @param {Error} error - The retry exhaustion error
179
+ * @param {FormatRetryExhaustionOptions} options - Formatting options
180
+ * @returns {string} Formatted error message with next steps
181
+ *
182
+ * @example
183
+ * ```typescript
184
+ * const message = formatRetryExhaustionError(error, {
185
+ * command: 'pnpm initiative:add-wu --wu WU-123 --initiative INIT-001',
186
+ * });
187
+ * console.error(message);
188
+ * ```
189
+ */
190
+ export declare function formatRetryExhaustionError(error: Error, options: FormatRetryExhaustionOptions): string;
191
+ /**
192
+ * WU-1308: Check if remote operations should be skipped based on git.requireRemote config
193
+ *
194
+ * When git.requireRemote is false, micro-worktree operations skip:
195
+ * - Fetching origin/main before starting
196
+ * - Pushing to origin/main after completion
197
+ *
198
+ * This enables local-only development without a remote repository.
199
+ *
200
+ * @returns {boolean} True if remote operations should be skipped (requireRemote=false)
201
+ *
202
+ * @example
203
+ * ```yaml
204
+ * # .lumenflow.config.yaml
205
+ * git:
206
+ * requireRemote: false # Enable local-only mode
207
+ * ```
208
+ */
209
+ export declare function shouldSkipRemoteOperations(): boolean;
104
210
  /**
105
211
  * Temp branch prefix for micro-worktree operations
106
212
  *
@@ -200,11 +306,16 @@ export declare function mergeWithRetry(tempBranchName: string, microWorktreePath
200
306
  * Push to origin/main with retry logic for race conditions
201
307
  *
202
308
  * WU-1179: When push fails because origin/main advanced (race condition with
203
- * parallel agents), this function rolls back local main to origin/main and
204
- * retries the full sequence: fetch -> rebase temp branch -> re-merge -> push.
309
+ * parallel agents), this function retries with fetch and rebase.
310
+ *
311
+ * WU-1348: The retry logic no longer resets the main checkout. Instead, it:
312
+ * 1. Fetches origin/main to get latest remote state
313
+ * 2. Rebases the temp branch onto origin/main (in the micro-worktree)
314
+ * 3. Re-merges the rebased temp branch to local main (ff-only)
315
+ * 4. Retries the push
205
316
  *
206
- * This prevents the scenario where local main is left diverged from origin
207
- * after a push failure.
317
+ * This preserves micro-worktree isolation - the main checkout files are never
318
+ * hard-reset, preventing file flash and preserving any uncommitted work.
208
319
  *
209
320
  * @param {Object} mainGit - GitAdapter instance for main checkout
210
321
  * @param {Object} worktreeGit - GitAdapter instance for micro-worktree
@@ -215,6 +326,32 @@ export declare function mergeWithRetry(tempBranchName: string, microWorktreePath
215
326
  * @throws {Error} If push fails after all retries
216
327
  */
217
328
  export declare function pushWithRetry(mainGit: GitAdapter, worktreeGit: GitAdapter, remote: string, branch: string, tempBranchName: string, logPrefix?: string): Promise<void>;
329
+ /**
330
+ * WU-1332: Push to origin with configurable retry using p-retry
331
+ *
332
+ * Enhanced version of pushWithRetry that uses p-retry for exponential backoff
333
+ * and supports configuration via PushRetryConfig. When push fails due to
334
+ * non-fast-forward (origin moved), automatically rebases and retries.
335
+ *
336
+ * WU-1348: The retry logic no longer resets the main checkout. Instead, it:
337
+ * 1. Fetches origin/main to get latest remote state
338
+ * 2. Rebases the temp branch onto origin/main (in the micro-worktree)
339
+ * 3. Re-merges the rebased temp branch to local main (ff-only)
340
+ * 4. Retries the push
341
+ *
342
+ * This preserves micro-worktree isolation - the main checkout files are never
343
+ * hard-reset, preventing file flash and preserving any uncommitted work.
344
+ *
345
+ * @param {Object} mainGit - GitAdapter instance for main checkout
346
+ * @param {Object} worktreeGit - GitAdapter instance for micro-worktree
347
+ * @param {string} remote - Remote name (e.g., 'origin')
348
+ * @param {string} branch - Branch name (e.g., 'main')
349
+ * @param {string} tempBranchName - Temp branch that was merged (for rebase)
350
+ * @param {string} logPrefix - Log prefix for console output
351
+ * @param {PushRetryConfig} config - Push retry configuration
352
+ * @throws {Error} If push fails after all retries or if retry is disabled
353
+ */
354
+ export declare function pushWithRetryConfig(mainGit: GitAdapter, worktreeGit: GitAdapter, remote: string, branch: string, tempBranchName: string, logPrefix?: string, config?: PushRetryConfig): Promise<void>;
218
355
  /**
219
356
  * Push using refspec with LUMENFLOW_FORCE to bypass pre-push hooks
220
357
  *
@@ -234,6 +371,33 @@ export declare function pushWithRetry(mainGit: GitAdapter, worktreeGit: GitAdapt
234
371
  * @throws {Error} If push fails (env vars still restored)
235
372
  */
236
373
  export declare function pushRefspecWithForce(gitAdapter: GitAdapter, remote: string, localRef: string, remoteRef: string, reason: string): Promise<void>;
374
+ /**
375
+ * WU-1337: Push using refspec with LUMENFLOW_FORCE and retry logic
376
+ *
377
+ * Enhanced version of pushRefspecWithForce that adds retry with rebase
378
+ * on non-fast-forward errors. Uses p-retry for exponential backoff and
379
+ * respects git.push_retry configuration.
380
+ *
381
+ * On each retry:
382
+ * 1. Fetch origin/main to get latest state
383
+ * 2. Rebase the temp branch onto the updated main
384
+ * 3. Retry the push with LUMENFLOW_FORCE
385
+ *
386
+ * This is used by pushOnly mode in withMicroWorktree to handle race conditions
387
+ * when multiple agents are pushing to origin/main concurrently.
388
+ *
389
+ * @param {GitAdapter} gitWorktree - GitAdapter instance for the worktree (for rebase)
390
+ * @param {GitAdapter} mainGit - GitAdapter instance for main checkout (for fetch)
391
+ * @param {string} remote - Remote name (e.g., 'origin')
392
+ * @param {string} localRef - Local ref to push (e.g., 'tmp/wu-claim/wu-123')
393
+ * @param {string} remoteRef - Remote ref to update (e.g., 'main')
394
+ * @param {string} reason - Audit reason for the LUMENFLOW_FORCE bypass
395
+ * @param {string} logPrefix - Log prefix for console output
396
+ * @param {PushRetryConfig} config - Push retry configuration
397
+ * @returns {Promise<void>}
398
+ * @throws {RetryExhaustionError} If push fails after all retries
399
+ */
400
+ export declare function pushRefspecWithRetry(gitWorktree: GitAdapter, mainGit: GitAdapter, remote: string, localRef: string, remoteRef: string, reason: string, logPrefix?: string, config?: PushRetryConfig): Promise<void>;
237
401
  /**
238
402
  * Execute an operation in a micro-worktree with full isolation
239
403
  *
@@ -243,6 +407,7 @@ export declare function pushRefspecWithForce(gitAdapter: GitAdapter, remote: str
243
407
  *
244
408
  * WU-1435: Added pushOnly option to keep local main pristine.
245
409
  * WU-2237: Added pre-creation cleanup of orphaned temp branches/worktrees.
410
+ * WU-1337: Push-only path now uses retry with rebase.
246
411
  *
247
412
  * @param {Object} options - Options for the operation
248
413
  * @param {string} options.operation - Operation name (e.g., 'wu-create', 'wu-edit')
@@ -32,7 +32,9 @@ import { existsSync, rmSync, mkdtempSync } from 'node:fs';
32
32
  import { execSync } from 'node:child_process';
33
33
  import { tmpdir } from 'node:os';
34
34
  import { join } from 'node:path';
35
+ import pRetry from 'p-retry';
35
36
  import { BRANCHES, REMOTES, GIT_REFS, PKG_MANAGER, SCRIPTS, PRETTIER_FLAGS, STDIO_MODES, } from './wu-constants.js';
37
+ import { getConfig } from './lumenflow-config.js';
36
38
  /**
37
39
  * Maximum retry attempts for ff-only merge when main moves
38
40
  *
@@ -46,8 +48,23 @@ export const MAX_MERGE_RETRIES = 3;
46
48
  * WU-1179: When push fails due to race condition (origin advanced while we
47
49
  * were working), rollback local main to origin/main and retry.
48
50
  * Each retry: fetch -> rebase temp branch -> re-merge -> push.
51
+ *
52
+ * @deprecated Use DEFAULT_PUSH_RETRY_CONFIG.retries instead (WU-1332)
49
53
  */
50
54
  export const MAX_PUSH_RETRIES = 3;
55
+ /**
56
+ * WU-1332: Default push retry configuration
57
+ *
58
+ * Provides sensible defaults for micro-worktree push operations.
59
+ * Can be overridden via .lumenflow.config.yaml git.push_retry section.
60
+ */
61
+ export const DEFAULT_PUSH_RETRY_CONFIG = {
62
+ enabled: true,
63
+ retries: 3,
64
+ min_delay_ms: 100,
65
+ max_delay_ms: 1000,
66
+ jitter: true,
67
+ };
51
68
  /**
52
69
  * Environment variable name for LUMENFLOW_FORCE bypass
53
70
  *
@@ -66,6 +83,130 @@ export const LUMENFLOW_FORCE_REASON_ENV = 'LUMENFLOW_FORCE_REASON';
66
83
  * Extracted to constant to satisfy sonarjs/no-duplicate-string rule.
67
84
  */
68
85
  export const DEFAULT_LOG_PREFIX = '[micro-wt]';
86
+ /**
87
+ * WU-1336: Pattern to detect retry exhaustion errors from error messages
88
+ *
89
+ * Matches error messages like "Push failed after N attempts"
90
+ * Used for backwards compatibility with legacy error messages.
91
+ */
92
+ const RETRY_EXHAUSTION_PATTERN = /Push failed after \d+ attempts/;
93
+ /**
94
+ * WU-1336: Typed error for retry exhaustion in micro-worktree operations
95
+ *
96
+ * Thrown when push retries are exhausted due to race conditions with parallel agents.
97
+ * CLI commands should use `isRetryExhaustionError` to detect this error type and
98
+ * `formatRetryExhaustionError` to generate actionable user-facing messages.
99
+ *
100
+ * This centralizes retry exhaustion handling so CLI commands do not need to
101
+ * duplicate detection logic or error formatting.
102
+ *
103
+ * @example
104
+ * ```typescript
105
+ * import { RetryExhaustionError, isRetryExhaustionError, formatRetryExhaustionError } from '@lumenflow/core';
106
+ *
107
+ * try {
108
+ * await withMicroWorktree({ ... });
109
+ * } catch (error) {
110
+ * if (isRetryExhaustionError(error)) {
111
+ * console.error(formatRetryExhaustionError(error, { command: 'pnpm initiative:add-wu ...' }));
112
+ * } else {
113
+ * throw error;
114
+ * }
115
+ * }
116
+ * ```
117
+ */
118
+ export class RetryExhaustionError extends Error {
119
+ /** Name of the error class (for instanceof checks across module boundaries) */
120
+ name = 'RetryExhaustionError';
121
+ /** Operation that was being performed (e.g., 'initiative-add-wu') */
122
+ operation;
123
+ /** Number of retry attempts that were exhausted */
124
+ retries;
125
+ constructor(operation, retries) {
126
+ super(`Push failed after ${retries} attempts. ` +
127
+ `Origin main may have significant traffic during ${operation}.`);
128
+ this.operation = operation;
129
+ this.retries = retries;
130
+ // Maintain proper prototype chain for instanceof checks
131
+ Object.setPrototypeOf(this, RetryExhaustionError.prototype);
132
+ }
133
+ }
134
+ /**
135
+ * WU-1336: Type guard to check if an error is a retry exhaustion error
136
+ *
137
+ * Detects both the typed `RetryExhaustionError` class and legacy error messages
138
+ * that match the "Push failed after N attempts" pattern.
139
+ *
140
+ * @param {unknown} error - Error to check
141
+ * @returns {boolean} True if this is a retry exhaustion error
142
+ *
143
+ * @example
144
+ * ```typescript
145
+ * if (isRetryExhaustionError(error)) {
146
+ * // Handle retry exhaustion
147
+ * }
148
+ * ```
149
+ */
150
+ export function isRetryExhaustionError(error) {
151
+ if (error instanceof RetryExhaustionError) {
152
+ return true;
153
+ }
154
+ // Also detect legacy error messages for backwards compatibility
155
+ if (error instanceof Error) {
156
+ return RETRY_EXHAUSTION_PATTERN.test(error.message);
157
+ }
158
+ return false;
159
+ }
160
+ /**
161
+ * WU-1336: Format retry exhaustion error with actionable next steps
162
+ *
163
+ * When push retries are exhausted, provides clear guidance on how to proceed.
164
+ * CLI commands should use this instead of duplicating error formatting logic.
165
+ *
166
+ * @param {Error} error - The retry exhaustion error
167
+ * @param {FormatRetryExhaustionOptions} options - Formatting options
168
+ * @returns {string} Formatted error message with next steps
169
+ *
170
+ * @example
171
+ * ```typescript
172
+ * const message = formatRetryExhaustionError(error, {
173
+ * command: 'pnpm initiative:add-wu --wu WU-123 --initiative INIT-001',
174
+ * });
175
+ * console.error(message);
176
+ * ```
177
+ */
178
+ export function formatRetryExhaustionError(error, options) {
179
+ const { command } = options;
180
+ return (`${error.message}\n\n` +
181
+ `Next steps:\n` +
182
+ ` 1. Wait a few seconds and retry the operation:\n` +
183
+ ` ${command}\n` +
184
+ ` 2. If the issue persists, check if another agent is rapidly pushing changes\n` +
185
+ ` 3. Consider increasing git.push_retry.retries in .lumenflow.config.yaml`);
186
+ }
187
+ /**
188
+ * WU-1308: Check if remote operations should be skipped based on git.requireRemote config
189
+ *
190
+ * When git.requireRemote is false, micro-worktree operations skip:
191
+ * - Fetching origin/main before starting
192
+ * - Pushing to origin/main after completion
193
+ *
194
+ * This enables local-only development without a remote repository.
195
+ *
196
+ * @returns {boolean} True if remote operations should be skipped (requireRemote=false)
197
+ *
198
+ * @example
199
+ * ```yaml
200
+ * # .lumenflow.config.yaml
201
+ * git:
202
+ * requireRemote: false # Enable local-only mode
203
+ * ```
204
+ */
205
+ export function shouldSkipRemoteOperations() {
206
+ const config = getConfig();
207
+ // Default is requireRemote=true, so only skip if explicitly set to false
208
+ return config.git.requireRemote === false;
209
+ }
69
210
  /**
70
211
  * Temp branch prefix for micro-worktree operations
71
212
  *
@@ -377,11 +518,16 @@ export async function mergeWithRetry(tempBranchName, microWorktreePath, logPrefi
377
518
  * Push to origin/main with retry logic for race conditions
378
519
  *
379
520
  * WU-1179: When push fails because origin/main advanced (race condition with
380
- * parallel agents), this function rolls back local main to origin/main and
381
- * retries the full sequence: fetch -> rebase temp branch -> re-merge -> push.
521
+ * parallel agents), this function retries with fetch and rebase.
382
522
  *
383
- * This prevents the scenario where local main is left diverged from origin
384
- * after a push failure.
523
+ * WU-1348: The retry logic no longer resets the main checkout. Instead, it:
524
+ * 1. Fetches origin/main to get latest remote state
525
+ * 2. Rebases the temp branch onto origin/main (in the micro-worktree)
526
+ * 3. Re-merges the rebased temp branch to local main (ff-only)
527
+ * 4. Retries the push
528
+ *
529
+ * This preserves micro-worktree isolation - the main checkout files are never
530
+ * hard-reset, preventing file flash and preserving any uncommitted work.
385
531
  *
386
532
  * @param {Object} mainGit - GitAdapter instance for main checkout
387
533
  * @param {Object} worktreeGit - GitAdapter instance for micro-worktree
@@ -392,41 +538,119 @@ export async function mergeWithRetry(tempBranchName, microWorktreePath, logPrefi
392
538
  * @throws {Error} If push fails after all retries
393
539
  */
394
540
  export async function pushWithRetry(mainGit, worktreeGit, remote, branch, tempBranchName, logPrefix = DEFAULT_LOG_PREFIX) {
395
- for (let attempt = 1; attempt <= MAX_PUSH_RETRIES; attempt++) {
541
+ // eslint-disable-next-line sonarjs/deprecation -- Using deprecated constant for backwards compatibility
542
+ const maxRetries = MAX_PUSH_RETRIES;
543
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
396
544
  try {
397
- console.log(`${logPrefix} Pushing to ${remote}/${branch} (attempt ${attempt}/${MAX_PUSH_RETRIES})...`);
545
+ console.log(`${logPrefix} Pushing to ${remote}/${branch} (attempt ${attempt}/${maxRetries})...`);
398
546
  await mainGit.push(remote, branch);
399
547
  console.log(`${logPrefix} ✅ Pushed to ${remote}/${branch}`);
400
548
  return;
401
549
  }
402
550
  catch (pushErr) {
403
- if (attempt < MAX_PUSH_RETRIES) {
404
- console.log(`${logPrefix} ⚠️ Push failed (origin moved). Rolling back and retrying...`);
405
- // Step 1: Rollback local main to origin/main
406
- console.log(`${logPrefix} Rolling back local ${branch} to ${remote}/${branch}...`);
407
- await mainGit.reset(`${remote}/${branch}`, { hard: true });
408
- // Step 2: Fetch latest origin/main
551
+ if (attempt < maxRetries) {
552
+ console.log(`${logPrefix} ⚠️ Push failed (origin moved). Fetching and rebasing before retry...`);
553
+ // WU-1348: Do NOT reset main checkout - preserve micro-worktree isolation
554
+ // Instead, fetch latest remote state and rebase the temp branch
555
+ // Step 1: Fetch latest origin/main
409
556
  console.log(`${logPrefix} Fetching ${remote}/${branch}...`);
410
557
  await mainGit.fetch(remote, branch);
411
- // Step 3: Update local main to match origin/main (ff-only)
412
- console.log(`${logPrefix} Updating local ${branch}...`);
413
- await mainGit.merge(`${remote}/${branch}`, { ffOnly: true });
414
- // Step 4: Rebase temp branch onto updated main
415
- console.log(`${logPrefix} Rebasing temp branch onto ${branch}...`);
416
- await worktreeGit.rebase(branch);
417
- // Step 5: Re-merge temp branch to local main
558
+ // Step 2: Rebase temp branch onto updated origin/main
559
+ console.log(`${logPrefix} Rebasing temp branch onto ${remote}/${branch}...`);
560
+ await worktreeGit.rebase(`${remote}/${branch}`);
561
+ // Step 3: Re-merge temp branch to local main (ff-only)
562
+ // This updates local main to include the rebased commits
418
563
  console.log(`${logPrefix} Re-merging temp branch to ${branch}...`);
419
564
  await mainGit.merge(tempBranchName, { ffOnly: true });
420
565
  }
421
566
  else {
422
567
  const errMsg = pushErr instanceof Error ? pushErr.message : String(pushErr);
423
- throw new Error(`Push failed after ${MAX_PUSH_RETRIES} attempts. ` +
424
- `Origin ${branch} may have significant traffic.\n` +
568
+ throw new Error(`Push failed after ${maxRetries} attempts. ` +
569
+ `Origin ${branch} may have significant traffic.\n\n` +
570
+ `Suggestions:\n` +
571
+ ` - Wait a few seconds and retry the operation\n` +
572
+ ` - Check if another agent is rapidly pushing changes\n` +
425
573
  `Error: ${errMsg}`);
426
574
  }
427
575
  }
428
576
  }
429
577
  }
578
+ /**
579
+ * WU-1332: Push to origin with configurable retry using p-retry
580
+ *
581
+ * Enhanced version of pushWithRetry that uses p-retry for exponential backoff
582
+ * and supports configuration via PushRetryConfig. When push fails due to
583
+ * non-fast-forward (origin moved), automatically rebases and retries.
584
+ *
585
+ * WU-1348: The retry logic no longer resets the main checkout. Instead, it:
586
+ * 1. Fetches origin/main to get latest remote state
587
+ * 2. Rebases the temp branch onto origin/main (in the micro-worktree)
588
+ * 3. Re-merges the rebased temp branch to local main (ff-only)
589
+ * 4. Retries the push
590
+ *
591
+ * This preserves micro-worktree isolation - the main checkout files are never
592
+ * hard-reset, preventing file flash and preserving any uncommitted work.
593
+ *
594
+ * @param {Object} mainGit - GitAdapter instance for main checkout
595
+ * @param {Object} worktreeGit - GitAdapter instance for micro-worktree
596
+ * @param {string} remote - Remote name (e.g., 'origin')
597
+ * @param {string} branch - Branch name (e.g., 'main')
598
+ * @param {string} tempBranchName - Temp branch that was merged (for rebase)
599
+ * @param {string} logPrefix - Log prefix for console output
600
+ * @param {PushRetryConfig} config - Push retry configuration
601
+ * @throws {Error} If push fails after all retries or if retry is disabled
602
+ */
603
+ export async function pushWithRetryConfig(mainGit, worktreeGit, remote, branch, tempBranchName, logPrefix = DEFAULT_LOG_PREFIX, config = DEFAULT_PUSH_RETRY_CONFIG) {
604
+ // If retry is disabled, just try once and throw on failure
605
+ if (!config.enabled) {
606
+ console.log(`${logPrefix} Pushing to ${remote}/${branch} (retry disabled)...`);
607
+ await mainGit.push(remote, branch);
608
+ console.log(`${logPrefix} ✅ Pushed to ${remote}/${branch}`);
609
+ return;
610
+ }
611
+ let attemptNumber = 0;
612
+ await pRetry(async () => {
613
+ attemptNumber++;
614
+ console.log(`${logPrefix} Pushing to ${remote}/${branch} (attempt ${attemptNumber}/${config.retries})...`);
615
+ try {
616
+ await mainGit.push(remote, branch);
617
+ console.log(`${logPrefix} ✅ Pushed to ${remote}/${branch}`);
618
+ }
619
+ catch (pushErr) {
620
+ console.log(`${logPrefix} ⚠️ Push failed (origin moved). Fetching and rebasing before retry...`);
621
+ // WU-1348: Do NOT reset main checkout - preserve micro-worktree isolation
622
+ // Instead, fetch latest remote state and rebase the temp branch
623
+ // Fetch latest origin/main
624
+ console.log(`${logPrefix} Fetching ${remote}/${branch}...`);
625
+ await mainGit.fetch(remote, branch);
626
+ // Rebase temp branch onto updated origin/main
627
+ console.log(`${logPrefix} Rebasing temp branch onto ${remote}/${branch}...`);
628
+ await worktreeGit.rebase(`${remote}/${branch}`);
629
+ // Re-merge temp branch to local main (ff-only)
630
+ // This updates local main to include the rebased commits
631
+ console.log(`${logPrefix} Re-merging temp branch to ${branch}...`);
632
+ await mainGit.merge(tempBranchName, { ffOnly: true });
633
+ // Re-throw to trigger p-retry
634
+ throw pushErr;
635
+ }
636
+ }, {
637
+ retries: config.retries - 1, // p-retry counts retries after first attempt
638
+ minTimeout: config.min_delay_ms,
639
+ maxTimeout: config.max_delay_ms,
640
+ randomize: config.jitter,
641
+ onFailedAttempt: () => {
642
+ // Logging is handled in the try/catch above
643
+ },
644
+ }).catch(() => {
645
+ // p-retry exhausted all retries, throw descriptive error
646
+ throw new Error(`Push failed after ${config.retries} attempts. ` +
647
+ `Origin ${branch} may have significant traffic.\n\n` +
648
+ `Suggestions:\n` +
649
+ ` - Wait a few seconds and retry the operation\n` +
650
+ ` - Increase git.push_retry.retries in .lumenflow.config.yaml\n` +
651
+ ` - Check if another agent is rapidly pushing changes`);
652
+ });
653
+ }
430
654
  /**
431
655
  * Push using refspec with LUMENFLOW_FORCE to bypass pre-push hooks
432
656
  *
@@ -472,6 +696,72 @@ export async function pushRefspecWithForce(gitAdapter, remote, localRef, remoteR
472
696
  }
473
697
  }
474
698
  }
699
+ /**
700
+ * WU-1337: Push using refspec with LUMENFLOW_FORCE and retry logic
701
+ *
702
+ * Enhanced version of pushRefspecWithForce that adds retry with rebase
703
+ * on non-fast-forward errors. Uses p-retry for exponential backoff and
704
+ * respects git.push_retry configuration.
705
+ *
706
+ * On each retry:
707
+ * 1. Fetch origin/main to get latest state
708
+ * 2. Rebase the temp branch onto the updated main
709
+ * 3. Retry the push with LUMENFLOW_FORCE
710
+ *
711
+ * This is used by pushOnly mode in withMicroWorktree to handle race conditions
712
+ * when multiple agents are pushing to origin/main concurrently.
713
+ *
714
+ * @param {GitAdapter} gitWorktree - GitAdapter instance for the worktree (for rebase)
715
+ * @param {GitAdapter} mainGit - GitAdapter instance for main checkout (for fetch)
716
+ * @param {string} remote - Remote name (e.g., 'origin')
717
+ * @param {string} localRef - Local ref to push (e.g., 'tmp/wu-claim/wu-123')
718
+ * @param {string} remoteRef - Remote ref to update (e.g., 'main')
719
+ * @param {string} reason - Audit reason for the LUMENFLOW_FORCE bypass
720
+ * @param {string} logPrefix - Log prefix for console output
721
+ * @param {PushRetryConfig} config - Push retry configuration
722
+ * @returns {Promise<void>}
723
+ * @throws {RetryExhaustionError} If push fails after all retries
724
+ */
725
+ export async function pushRefspecWithRetry(gitWorktree, mainGit, remote, localRef, remoteRef, reason, logPrefix = DEFAULT_LOG_PREFIX, config = DEFAULT_PUSH_RETRY_CONFIG) {
726
+ // If retry is disabled, just try once and throw on failure
727
+ if (!config.enabled) {
728
+ console.log(`${logPrefix} Pushing to ${remote}/${remoteRef} (push-only, retry disabled)...`);
729
+ await pushRefspecWithForce(gitWorktree, remote, localRef, remoteRef, reason);
730
+ console.log(`${logPrefix} ✅ Pushed to ${remote}/${remoteRef}`);
731
+ return;
732
+ }
733
+ let attemptNumber = 0;
734
+ await pRetry(async () => {
735
+ attemptNumber++;
736
+ console.log(`${logPrefix} Pushing to ${remote}/${remoteRef} (push-only, attempt ${attemptNumber}/${config.retries})...`);
737
+ try {
738
+ await pushRefspecWithForce(gitWorktree, remote, localRef, remoteRef, reason);
739
+ console.log(`${logPrefix} ✅ Pushed to ${remote}/${remoteRef}`);
740
+ }
741
+ catch (pushErr) {
742
+ console.log(`${logPrefix} ⚠️ Push failed (origin moved). Fetching and rebasing before retry...`);
743
+ // Fetch latest origin/main
744
+ console.log(`${logPrefix} Fetching ${remote}/${remoteRef}...`);
745
+ await mainGit.fetch(remote, remoteRef);
746
+ // Rebase temp branch onto updated main
747
+ console.log(`${logPrefix} Rebasing temp branch onto ${remoteRef}...`);
748
+ await gitWorktree.rebase(remoteRef);
749
+ // Re-throw to trigger p-retry
750
+ throw pushErr;
751
+ }
752
+ }, {
753
+ retries: config.retries - 1, // p-retry counts retries after first attempt
754
+ minTimeout: config.min_delay_ms,
755
+ maxTimeout: config.max_delay_ms,
756
+ randomize: config.jitter,
757
+ onFailedAttempt: () => {
758
+ // Logging is handled in the try/catch above
759
+ },
760
+ }).catch(() => {
761
+ // p-retry exhausted all retries, throw typed error
762
+ throw new RetryExhaustionError('push-only', config.retries);
763
+ });
764
+ }
475
765
  /**
476
766
  * Execute an operation in a micro-worktree with full isolation
477
767
  *
@@ -481,6 +771,7 @@ export async function pushRefspecWithForce(gitAdapter, remote, localRef, remoteR
481
771
  *
482
772
  * WU-1435: Added pushOnly option to keep local main pristine.
483
773
  * WU-2237: Added pre-creation cleanup of orphaned temp branches/worktrees.
774
+ * WU-1337: Push-only path now uses retry with rebase.
484
775
  *
485
776
  * @param {Object} options - Options for the operation
486
777
  * @param {string} options.operation - Operation name (e.g., 'wu-create', 'wu-edit')
@@ -496,18 +787,24 @@ export async function pushRefspecWithForce(gitAdapter, remote, localRef, remoteR
496
787
  export async function withMicroWorktree(options) {
497
788
  const { operation, id, logPrefix = `[${operation}]`, execute, pushOnly = false } = options;
498
789
  const mainGit = getGitForCwd();
790
+ // WU-1308: Check if remote operations should be skipped (local-only mode)
791
+ const skipRemote = shouldSkipRemoteOperations();
499
792
  // WU-2237: Clean up any orphaned temp branch/worktree from previous interrupted operations
500
793
  // This makes the operation idempotent - a retry after crash/timeout will succeed
501
794
  await cleanupOrphanedMicroWorktree(operation, id, mainGit, logPrefix);
502
795
  // WU-1179: Fetch origin/main before starting to minimize race condition window
503
796
  // This ensures we start from the latest origin state, reducing push failures
504
- if (!pushOnly) {
797
+ // WU-1308: Skip when git.requireRemote=false (local-only mode)
798
+ if (!pushOnly && !skipRemote) {
505
799
  console.log(`${logPrefix} Fetching ${REMOTES.ORIGIN}/${BRANCHES.MAIN} before starting...`);
506
800
  await mainGit.fetch(REMOTES.ORIGIN, BRANCHES.MAIN);
507
801
  // Update local main to match origin/main
508
802
  await mainGit.merge(`${REMOTES.ORIGIN}/${BRANCHES.MAIN}`, { ffOnly: true });
509
803
  console.log(`${logPrefix} ✅ Local main synced with ${REMOTES.ORIGIN}/${BRANCHES.MAIN}`);
510
804
  }
805
+ else if (skipRemote) {
806
+ console.log(`${logPrefix} Local-only mode (git.requireRemote=false): skipping origin sync`);
807
+ }
511
808
  const tempBranchName = getTempBranchName(operation, id);
512
809
  const microWorktreePath = createMicroWorktreeDir(`${operation}-`);
513
810
  console.log(`${logPrefix} Using micro-worktree isolation (WU-1262)`);
@@ -536,12 +833,22 @@ export async function withMicroWorktree(options) {
536
833
  await gitWorktree.commit(result.commitMessage);
537
834
  console.log(`${logPrefix} ✅ Committed: ${result.commitMessage}`);
538
835
  // Step 6: Push to origin (different paths for pushOnly vs standard)
539
- if (pushOnly) {
836
+ // WU-1308: Skip push when git.requireRemote=false (local-only mode)
837
+ if (skipRemote) {
838
+ // Local-only mode: merge to local main but skip push
839
+ console.log(`${logPrefix} Local-only mode: merging to local main (skipping push)`);
840
+ await mainGit.merge(tempBranchName, { ffOnly: true });
841
+ console.log(`${logPrefix} ✅ Merged to local main (no remote push)`);
842
+ return { ...result, ref: BRANCHES.MAIN };
843
+ }
844
+ else if (pushOnly) {
540
845
  // WU-1435: Push directly to origin/main without touching local main
541
846
  // WU-1081: Use LUMENFLOW_FORCE to bypass pre-push hooks for micro-worktree pushes
542
- console.log(`${logPrefix} Pushing directly to ${REMOTES.ORIGIN}/${BRANCHES.MAIN} (push-only)...`);
543
- await pushRefspecWithForce(gitWorktree, REMOTES.ORIGIN, tempBranchName, BRANCHES.MAIN, `micro-worktree push for ${operation} (automated)`);
544
- console.log(`${logPrefix} Pushed to ${REMOTES.ORIGIN}/${BRANCHES.MAIN}`);
847
+ // WU-1337: Use pushRefspecWithRetry to handle race conditions with rebase
848
+ // Get push_retry config from LumenFlow config
849
+ const config = getConfig();
850
+ const pushRetryConfig = config.git.push_retry || DEFAULT_PUSH_RETRY_CONFIG;
851
+ await pushRefspecWithRetry(gitWorktree, mainGit, REMOTES.ORIGIN, tempBranchName, BRANCHES.MAIN, `micro-worktree push for ${operation} (automated)`, logPrefix, pushRetryConfig);
545
852
  // Fetch to update remote tracking ref (FETCH_HEAD)
546
853
  console.log(`${logPrefix} Fetching ${REMOTES.ORIGIN}/${BRANCHES.MAIN}...`);
547
854
  await mainGit.fetch(REMOTES.ORIGIN, BRANCHES.MAIN);