祁连海 4 месяцев назад
Родитель
Сommit
73f9deb53e

+ 109 - 1
background.js

@@ -308,6 +308,7 @@ const DEFAULT_STATE = {
   autoRunCurrentRun: 0, // 自动运行当前执行到第几轮。
   autoRunTotalRuns: 1, // 自动运行计划总轮数。
   autoRunAttemptRun: 0, // 当前轮次的重试序号。
+  autoRunSessionId: 0,
   autoRunRoundSummaries: [], // 自动运行轮次摘要。
   scheduledAutoRunAt: null, // 自动运行计划启动时间戳。
   autoRunTimerPlan: null, // 自动运行可恢复计时计划快照。
@@ -426,6 +427,48 @@ function normalizeAutoRunTimerKind(value = '') {
   return '';
 }
 
+function normalizeAutoRunSessionId(value) {
+  const numeric = Math.floor(Number(value) || 0);
+  return numeric > 0 ? numeric : 0;
+}
+
+function createAutoRunSessionId() {
+  autoRunSessionSeed = Math.max(autoRunSessionSeed + 1, Date.now());
+  autoRunSessionId = autoRunSessionSeed;
+  return autoRunSessionId;
+}
+
+function setCurrentAutoRunSessionId(value) {
+  autoRunSessionId = normalizeAutoRunSessionId(value);
+  return autoRunSessionId;
+}
+
+function clearCurrentAutoRunSessionId(expectedSessionId = null) {
+  if (expectedSessionId === null) {
+    autoRunSessionId = 0;
+    return autoRunSessionId;
+  }
+
+  const normalizedExpected = normalizeAutoRunSessionId(expectedSessionId);
+  if (!normalizedExpected || normalizedExpected === autoRunSessionId) {
+    autoRunSessionId = 0;
+  }
+  return autoRunSessionId;
+}
+
+function isCurrentAutoRunSessionId(value) {
+  const normalized = normalizeAutoRunSessionId(value);
+  return normalized > 0 && normalized === autoRunSessionId;
+}
+
+function throwIfAutoRunSessionStopped(sessionId) {
+  const normalizedSessionId = normalizeAutoRunSessionId(sessionId);
+  if (normalizedSessionId && !isCurrentAutoRunSessionId(normalizedSessionId)) {
+    throw new Error(STOP_ERROR_MESSAGE);
+  }
+  throwIfStopped();
+}
+
 function normalizeAutoRunTimerPlan(plan) {
   if (!plan || typeof plan !== 'object' || Array.isArray(plan)) {
     return null;
@@ -449,6 +492,7 @@ function normalizeAutoRunTimerPlan(plan) {
     0,
     Math.min(AUTO_RUN_MAX_RETRIES_PER_ROUND + 1, Math.floor(Number(plan.attemptRun) || 0))
   );
+  const autoRunSessionId = normalizeAutoRunSessionId(plan.autoRunSessionId ?? plan.sessionId);
   const roundSummaries = serializeAutoRunRoundSummaries(totalRuns, plan.roundSummaries);
   const countdownTitle = String(plan.countdownTitle || '').trim();
   const countdownNote = String(plan.countdownNote || '').trim();
@@ -462,6 +506,7 @@ function normalizeAutoRunTimerPlan(plan) {
       mode,
       currentRun: 0,
       attemptRun: 0,
+      autoRunSessionId,
       roundSummaries: [],
       countdownTitle: countdownTitle || '已计划自动运行',
       countdownNote: countdownNote || `计划于 ${formatAutoRunScheduleTime(fireAt)} 开始`,
@@ -479,6 +524,7 @@ function normalizeAutoRunTimerPlan(plan) {
       mode: 'restart',
       currentRun: normalizedCurrentRun,
       attemptRun: normalizedAttemptRun,
+      autoRunSessionId,
       roundSummaries,
       countdownTitle: countdownTitle || '线程间隔中',
       countdownNote: countdownNote || `第 ${Math.min(normalizedCurrentRun + 1, totalRuns)}/${totalRuns} 轮即将开始`,
@@ -495,6 +541,7 @@ function normalizeAutoRunTimerPlan(plan) {
     mode: 'restart',
     currentRun: normalizedCurrentRun,
     attemptRun: normalizedAttemptRun,
+    autoRunSessionId,
     roundSummaries,
     countdownTitle: countdownTitle || '线程间隔中',
     countdownNote: countdownNote || `第 ${normalizedCurrentRun}/${totalRuns} 轮第 ${normalizedAttemptRun} 次尝试即将开始`,
@@ -521,6 +568,7 @@ function normalizeAutoRunTimerPlanFromState(state = {}) {
     fireAt: legacyScheduledAt,
     totalRuns: state.scheduledAutoRunPlan?.totalRuns ?? state.autoRunTotalRuns,
     autoRunSkipFailures: state.scheduledAutoRunPlan?.autoRunSkipFailures ?? state.autoRunSkipFailures,
+    autoRunSessionId: state.autoRunSessionId,
     mode: state.scheduledAutoRunPlan?.mode,
   });
 }
@@ -541,6 +589,7 @@ function getAutoRunTimerStatusPayload(plan) {
     currentRun: normalizedPlan.currentRun,
     totalRuns: normalizedPlan.totalRuns,
     attemptRun: normalizedPlan.attemptRun,
+    sessionId: normalizedPlan.autoRunSessionId,
     scheduledAt: phase === 'scheduled' ? normalizedPlan.fireAt : null,
     countdownAt: normalizedPlan.fireAt,
     countdownTitle: normalizedPlan.countdownTitle,
@@ -3794,6 +3843,7 @@ const tabRuntime = self.MultiPageBackgroundTabRuntime?.createTabRuntime({
   LOG_PREFIX,
   matchesSourceUrlFamily,
   setState,
+  sleepWithStop,
   STOP_ERROR_MESSAGE,
   throwIfStopped,
 });
@@ -3978,6 +4028,7 @@ function getAutoRunStatusPayload(phase, payload = {}) {
     currentRun: payload.currentRun ?? autoRunCurrentRun,
     totalRuns: payload.totalRuns ?? autoRunTotalRuns,
     attemptRun: payload.attemptRun ?? autoRunAttemptRun,
+    sessionId: payload.sessionId ?? payload.autoRunSessionId ?? autoRunSessionId,
   };
   if (typeof loggingStatus !== 'undefined' && loggingStatus?.getAutoRunStatusPayload) {
     return loggingStatus.getAutoRunStatusPayload(phase, normalizedPayload);
@@ -3993,6 +4044,7 @@ function getAutoRunStatusPayload(phase, payload = {}) {
     autoRunCurrentRun: normalizedPayload.currentRun ?? 0,
     autoRunTotalRuns: normalizedPayload.totalRuns ?? 1,
     autoRunAttemptRun: normalizedPayload.attemptRun ?? 0,
+    autoRunSessionId: normalizeAutoRunSessionId(normalizedPayload.sessionId),
     scheduledAutoRunAt: Number.isFinite(Number(normalizedPayload.scheduledAt)) ? Number(normalizedPayload.scheduledAt) : null,
     autoRunCountdownAt: Number.isFinite(Number(normalizedPayload.countdownAt)) ? Number(normalizedPayload.countdownAt) : null,
     autoRunCountdownTitle: normalizedPayload.countdownTitle === undefined ? '' : String(normalizedPayload.countdownTitle || ''),
@@ -4010,6 +4062,7 @@ async function broadcastAutoRunStatus(phase, payload = {}, extraState = {}) {
     currentRun: payload.currentRun ?? autoRunCurrentRun,
     totalRuns: payload.totalRuns ?? autoRunTotalRuns,
     attemptRun: payload.attemptRun ?? autoRunAttemptRun,
+    sessionId: payload.sessionId ?? payload.autoRunSessionId ?? autoRunSessionId,
     scheduledAt: rawScheduledAt === null ? null : Number(rawScheduledAt),
     countdownAt: rawCountdownAt === null ? null : Number(rawCountdownAt),
     countdownTitle: payload.countdownTitle === undefined ? '' : String(payload.countdownTitle || ''),
@@ -4119,6 +4172,7 @@ function getAutoRunTimerResumeOptions(plan) {
   if (normalizedPlan.kind === AUTO_RUN_TIMER_KIND_SCHEDULED_START) {
     return {
       loopOptions: {
+        autoRunSessionId: normalizedPlan.autoRunSessionId,
         autoRunSkipFailures: normalizedPlan.autoRunSkipFailures,
         mode: normalizedPlan.mode,
       },
@@ -4126,6 +4180,7 @@ function getAutoRunTimerResumeOptions(plan) {
         currentRun: 0,
         totalRuns: normalizedPlan.totalRuns,
         attemptRun: 0,
+        sessionId: normalizedPlan.autoRunSessionId,
       },
     };
   }
@@ -4134,6 +4189,7 @@ function getAutoRunTimerResumeOptions(plan) {
     const nextRun = Math.min(normalizedPlan.currentRun + 1, normalizedPlan.totalRuns);
     return {
       loopOptions: {
+        autoRunSessionId: normalizedPlan.autoRunSessionId,
         autoRunSkipFailures: normalizedPlan.autoRunSkipFailures,
         mode: 'restart',
         resumeCurrentRun: nextRun,
@@ -4144,12 +4200,14 @@ function getAutoRunTimerResumeOptions(plan) {
         currentRun: nextRun,
         totalRuns: normalizedPlan.totalRuns,
         attemptRun: 1,
+        sessionId: normalizedPlan.autoRunSessionId,
       },
     };
   }
 
   return {
     loopOptions: {
+      autoRunSessionId: normalizedPlan.autoRunSessionId,
       autoRunSkipFailures: normalizedPlan.autoRunSkipFailures,
       mode: 'restart',
       resumeCurrentRun: normalizedPlan.currentRun,
@@ -4160,6 +4218,7 @@ function getAutoRunTimerResumeOptions(plan) {
       currentRun: normalizedPlan.currentRun,
       totalRuns: normalizedPlan.totalRuns,
       attemptRun: normalizedPlan.attemptRun,
+      sessionId: normalizedPlan.autoRunSessionId,
     },
   };
 }
@@ -4185,6 +4244,9 @@ async function launchAutoRunTimerPlan(trigger = 'alarm', options = {}) {
     if (autoRunActive) {
       return false;
     }
+    if (plan.autoRunSessionId && !isCurrentAutoRunSessionId(plan.autoRunSessionId)) {
+      return false;
+    }
 
     const resumeOptions = getAutoRunTimerResumeOptions(plan);
     if (!resumeOptions) {
@@ -4202,9 +4264,13 @@ async function launchAutoRunTimerPlan(trigger = 'alarm', options = {}) {
     }
 
     await clearAutoRunTimerAlarm();
+    if (plan.autoRunSessionId && !isCurrentAutoRunSessionId(plan.autoRunSessionId)) {
+      return false;
+    }
     autoRunCurrentRun = resumeOptions.statusPayload.currentRun;
     autoRunTotalRuns = plan.totalRuns;
     autoRunAttemptRun = resumeOptions.statusPayload.attemptRun;
+    autoRunSessionId = normalizeAutoRunSessionId(plan.autoRunSessionId);
     if (plan.kind === AUTO_RUN_TIMER_KIND_SCHEDULED_START && trigger !== 'manual' && state.autoRunDelayEnabled) {
       await setAutoRunDelayEnabledState(false);
     }
@@ -4219,6 +4285,9 @@ async function launchAutoRunTimerPlan(trigger = 'alarm', options = {}) {
       }
     );
 
+    if (plan.autoRunSessionId && !isCurrentAutoRunSessionId(plan.autoRunSessionId)) {
+      return false;
+    }
     clearStopRequest();
     let logMessage = '倒计时结束,自动运行开始执行。';
     if (plan.kind === AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS) {
@@ -4233,6 +4302,9 @@ async function launchAutoRunTimerPlan(trigger = 'alarm', options = {}) {
       logMessage = '已手动跳过倒计时,自动运行立即开始。';
     }
     await addLog(logMessage, 'info');
+    if (plan.autoRunSessionId && !isCurrentAutoRunSessionId(plan.autoRunSessionId)) {
+      return false;
+    }
 
     startAutoRunLoop(plan.totalRuns, resumeOptions.loopOptions);
     return true;
@@ -4251,17 +4323,20 @@ async function scheduleAutoRun(totalRuns, options = {}) {
   }
 
   const delayMinutes = normalizeAutoRunDelayMinutes(options.delayMinutes);
+  const sessionId = createAutoRunSessionId();
   const timerPlan = normalizeAutoRunTimerPlan({
     kind: AUTO_RUN_TIMER_KIND_SCHEDULED_START,
     fireAt: Date.now() + delayMinutes * 60 * 1000,
     totalRuns,
     autoRunSkipFailures: options.autoRunSkipFailures,
+    autoRunSessionId: sessionId,
     mode: options.mode,
   });
 
   autoRunCurrentRun = 0;
   autoRunTotalRuns = timerPlan.totalRuns;
   autoRunAttemptRun = 0;
+  autoRunSessionId = sessionId;
 
   await persistAutoRunTimerPlan(timerPlan, {
     autoRunSkipFailures: timerPlan.autoRunSkipFailures,
@@ -4284,14 +4359,17 @@ async function cancelScheduledAutoRun(options = {}) {
   autoRunCurrentRun = 0;
   autoRunTotalRuns = plan.totalRuns;
   autoRunAttemptRun = 0;
+  clearCurrentAutoRunSessionId(plan.autoRunSessionId);
   await broadcastAutoRunStatus(
     'idle',
     {
       currentRun: 0,
       totalRuns: plan.totalRuns,
       attemptRun: 0,
+      sessionId: 0,
     },
     {
+      autoRunSessionId: 0,
       autoRunRoundSummaries: [],
       autoRunTimerPlan: null,
       scheduledAutoRunPlan: null,
@@ -4306,15 +4384,18 @@ async function cancelScheduledAutoRun(options = {}) {
 
 async function restoreAutoRunTimerIfNeeded() {
   const state = await getState();
-  const plan = getPendingAutoRunTimerPlan(state);
+  let plan = getPendingAutoRunTimerPlan(state);
   if (!plan) {
+    clearCurrentAutoRunSessionId();
     if (state.autoRunPhase === 'scheduled' || state.autoRunPhase === 'waiting_interval') {
       await clearAutoRunTimerAlarm();
       await broadcastAutoRunStatus('idle', {
         currentRun: 0,
         totalRuns: 1,
         attemptRun: 0,
+        sessionId: 0,
       }, {
+        autoRunSessionId: 0,
         autoRunRoundSummaries: [],
         autoRunTimerPlan: null,
         scheduledAutoRunPlan: null,
@@ -4323,6 +4404,19 @@ async function restoreAutoRunTimerIfNeeded() {
     return;
   }
 
+  if (!plan.autoRunSessionId) {
+    const restoredSessionId = createAutoRunSessionId();
+    plan = await persistAutoRunTimerPlan({
+      ...plan,
+      autoRunSessionId: restoredSessionId,
+    }, {
+      autoRunSkipFailures: plan.autoRunSkipFailures,
+      autoRunRoundSummaries: serializeAutoRunRoundSummaries(plan.totalRuns, plan.roundSummaries),
+    });
+  } else {
+    setCurrentAutoRunSessionId(plan.autoRunSessionId);
+  }
+
   if (plan.fireAt <= Date.now()) {
     await launchAutoRunTimerPlan('restore');
     return;
@@ -4333,6 +4427,7 @@ async function restoreAutoRunTimerIfNeeded() {
     statusPayload.phase,
     statusPayload,
     {
+      autoRunSessionId: plan.autoRunSessionId,
       autoRunSkipFailures: plan.autoRunSkipFailures,
       autoRunRoundSummaries: serializeAutoRunRoundSummaries(plan.totalRuns, plan.roundSummaries),
       autoRunTimerPlan: plan,
@@ -4809,6 +4904,7 @@ async function requestStop(options = {}) {
     autoRunCurrentRun = timerPlan.currentRun;
     autoRunTotalRuns = timerPlan.totalRuns;
     autoRunAttemptRun = timerPlan.attemptRun;
+    clearCurrentAutoRunSessionId(timerPlan.autoRunSessionId);
     if (options.logMessage !== false) {
       await addLog(options.logMessage || '已停止等待中的自动流程。', 'warn');
     }
@@ -4816,7 +4912,9 @@ async function requestStop(options = {}) {
       currentRun: timerPlan.currentRun,
       totalRuns: timerPlan.totalRuns,
       attemptRun: timerPlan.attemptRun,
+      sessionId: 0,
     }, {
+      autoRunSessionId: 0,
       autoRunSkipFailures: timerPlan.autoRunSkipFailures,
       autoRunRoundSummaries: serializeAutoRunRoundSummaries(timerPlan.totalRuns, timerPlan.roundSummaries),
       autoRunTimerPlan: null,
@@ -4830,6 +4928,7 @@ async function requestStop(options = {}) {
   if (stopRequested) return;
 
   stopRequested = true;
+  clearCurrentAutoRunSessionId();
   cancelPendingCommands();
   cleanupStep8NavigationListeners();
   rejectPendingStep8(new Error(STOP_ERROR_MESSAGE));
@@ -4853,7 +4952,9 @@ async function requestStop(options = {}) {
     currentRun: autoRunCurrentRun,
     totalRuns: autoRunTotalRuns,
     attemptRun: autoRunAttemptRun,
+    sessionId: 0,
   }, {
+    autoRunSessionId: 0,
     autoRunTimerPlan: null,
     scheduledAutoRunPlan: null,
   });
@@ -5017,6 +5118,8 @@ let autoRunActive = false;
 let autoRunCurrentRun = 0;
 let autoRunTotalRuns = 1;
 let autoRunAttemptRun = 0;
+let autoRunSessionId = 0;
+let autoRunSessionSeed = 0;
 const EMAIL_FETCH_MAX_ATTEMPTS = 5;
 const VERIFICATION_POLL_MAX_ROUNDS = 5;
 const STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS = 25000;
@@ -5088,6 +5191,7 @@ const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoR
   broadcastStopToContentScripts,
   cancelPendingCommands,
   clearStopRequest: () => clearStopRequest(),
+  createAutoRunSessionId: () => createAutoRunSessionId(),
   getAutoRunStatusPayload,
   getErrorMessage,
   getFirstUnfinishedStep,
@@ -5109,16 +5213,19 @@ const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoR
       autoRunCurrentRun,
       autoRunTotalRuns,
       autoRunAttemptRun,
+      autoRunSessionId,
     }),
     set: (updates = {}) => {
       if (updates.autoRunActive !== undefined) autoRunActive = Boolean(updates.autoRunActive);
       if (updates.autoRunCurrentRun !== undefined) autoRunCurrentRun = Number(updates.autoRunCurrentRun) || 0;
       if (updates.autoRunTotalRuns !== undefined) autoRunTotalRuns = Number(updates.autoRunTotalRuns) || 0;
       if (updates.autoRunAttemptRun !== undefined) autoRunAttemptRun = Number(updates.autoRunAttemptRun) || 0;
+      if (updates.autoRunSessionId !== undefined) autoRunSessionId = normalizeAutoRunSessionId(updates.autoRunSessionId);
     },
   },
   setState,
   sleepWithStop,
+  throwIfAutoRunSessionStopped: (sessionId) => throwIfAutoRunSessionStopped(sessionId),
   waitForRunningStepsToFinish,
   throwIfStopped: () => throwIfStopped(),
   chrome,
@@ -5558,6 +5665,7 @@ async function resumeAutoRun() {
 
   await addLog('检测到自动流程暂停上下文已丢失,正在从当前进度恢复自动运行...', 'warn');
   startAutoRunLoop(totalRuns, {
+    autoRunSessionId: normalizeAutoRunSessionId(state.autoRunSessionId),
     autoRunSkipFailures: Boolean(state.autoRunSkipFailures),
     mode: 'continue',
     resumeCurrentRun: currentRun,

+ 36 - 5
background/auto-run-controller.js

@@ -13,6 +13,7 @@
       broadcastStopToContentScripts,
       cancelPendingCommands,
       clearStopRequest,
+      createAutoRunSessionId,
       getAutoRunStatusPayload,
       getErrorMessage,
       getFirstUnfinishedStep,
@@ -30,6 +31,7 @@
       runtime,
       setState,
       sleepWithStop,
+      throwIfAutoRunSessionStopped,
       waitForRunningStepsToFinish,
     } = deps;
 
@@ -175,6 +177,7 @@
         currentRun: targetRun,
         totalRuns,
         attemptRun: currentRuntime.autoRunAttemptRun,
+        autoRunSessionId: currentRuntime.autoRunSessionId,
         autoRunSkipFailures,
         roundSummaries,
         countdownTitle: '线程间隔中',
@@ -206,6 +209,7 @@
         currentRun: targetRun,
         totalRuns,
         attemptRun: nextAttemptRun,
+        autoRunSessionId: runtime.get().autoRunSessionId,
         autoRunSkipFailures,
         roundSummaries,
         countdownTitle: '线程间隔中',
@@ -225,12 +229,14 @@
         await addLog(`自动运行异常终止:${getErrorMessage(error) || '未知错误'}`, 'error');
       }
 
-      runtime.set({ autoRunActive: false });
+      runtime.set({ autoRunActive: false, autoRunSessionId: 0 });
       await broadcastAutoRunStatus('stopped', {
         currentRun: currentRuntime.autoRunCurrentRun,
         totalRuns: currentRuntime.autoRunTotalRuns,
         attemptRun: currentRuntime.autoRunAttemptRun,
+        sessionId: 0,
       }, {
+        autoRunSessionId: 0,
         autoRunTimerPlan: null,
         scheduledAutoRunPlan: null,
       });
@@ -250,12 +256,22 @@
         return;
       }
 
+      let sessionId = Number.isInteger(options.autoRunSessionId) && options.autoRunSessionId > 0
+        ? options.autoRunSessionId
+        : 0;
+      if (sessionId) {
+        throwIfAutoRunSessionStopped(sessionId);
+      } else {
+        sessionId = createAutoRunSessionId();
+      }
+
       clearStopRequest();
       runtime.set({
         autoRunActive: true,
         autoRunTotalRuns: totalRuns,
         autoRunCurrentRun: 0,
         autoRunAttemptRun: 0,
+        autoRunSessionId: sessionId,
       });
       currentRuntime = runtime.get();
 
@@ -293,12 +309,14 @@
       const showResumePosition = continueCurrentOnFirstAttempt || resumeCurrentRun > 1 || resumeAttemptRun > 1;
 
       await setState({
+        autoRunSessionId: sessionId,
         autoRunSkipFailures,
         autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
         ...getAutoRunStatusPayload(initialPhase, {
           currentRun: showResumePosition ? resumeCurrentRun : 0,
           totalRuns,
           attemptRun: showResumePosition ? resumeAttemptRun : 0,
+          sessionId,
         }),
       });
 
@@ -360,9 +378,10 @@
               cloudflareDomain: prevState.cloudflareDomain,
               cloudflareDomains: prevState.cloudflareDomains,
               autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
+              autoRunSessionId: sessionId,
               tabRegistry: {},
               sourceLastUrls: {},
-              ...getAutoRunStatusPayload('running', { currentRun: targetRun, totalRuns, attemptRun }),
+              ...getAutoRunStatusPayload('running', { currentRun: targetRun, totalRuns, attemptRun, sessionId }),
             };
             await resetState();
             await setState(keepSettings);
@@ -370,9 +389,10 @@
             await sleepWithStop(500);
           } else {
             await setState({
+              autoRunSessionId: sessionId,
               autoRunSkipFailures,
               autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
-              ...getAutoRunStatusPayload('running', { currentRun: targetRun, totalRuns, attemptRun }),
+              ...getAutoRunStatusPayload('running', { currentRun: targetRun, totalRuns, attemptRun, sessionId }),
             });
           }
 
@@ -397,11 +417,12 @@
           };
 
           try {
-            deps.throwIfStopped();
+            throwIfAutoRunSessionStopped(sessionId);
             await broadcastAutoRunStatus('running', {
               currentRun: targetRun,
               totalRuns,
               attemptRun,
+              sessionId,
             });
 
             await runAutoSequenceFromStep(startStep, {
@@ -428,6 +449,7 @@
                 currentRun: targetRun,
                 totalRuns,
                 attemptRun,
+                sessionId: 0,
               });
               break;
             }
@@ -453,6 +475,7 @@
                 currentRun: targetRun,
                 totalRuns,
                 attemptRun,
+                sessionId,
               });
               forceFreshTabsNextRun = true;
               await addLog(
@@ -470,6 +493,7 @@
                     currentRun: targetRun,
                     totalRuns,
                     attemptRun,
+                    sessionId: 0,
                   });
                   break;
                 }
@@ -493,6 +517,7 @@
                     currentRun: targetRun,
                     totalRuns,
                     attemptRun,
+                    sessionId: 0,
                   });
                   break;
                 }
@@ -518,6 +543,7 @@
                 currentRun: targetRun,
                 totalRuns,
                 attemptRun,
+                sessionId: 0,
               });
               break;
             }
@@ -559,6 +585,7 @@
               currentRun: targetRun,
               totalRuns,
               attemptRun: runtime.get().autoRunAttemptRun,
+              sessionId: 0,
             });
             break;
           }
@@ -584,6 +611,7 @@
           currentRun: finalRuntime.autoRunCurrentRun,
           totalRuns: finalRuntime.autoRunTotalRuns,
           attemptRun: finalRuntime.autoRunAttemptRun,
+          sessionId: 0,
         });
       } else {
         await addLog(`=== 全部 ${finalRuntime.autoRunTotalRuns} 轮已执行完成,成功 ${successfulRuns} 轮 ===`, 'ok');
@@ -591,11 +619,13 @@
           currentRun: finalRuntime.autoRunTotalRuns,
           totalRuns: finalRuntime.autoRunTotalRuns,
           attemptRun: finalRuntime.autoRunAttemptRun,
+          sessionId: 0,
         });
       }
-      runtime.set({ autoRunActive: false });
+      runtime.set({ autoRunActive: false, autoRunSessionId: 0 });
       const afterRuntime = runtime.get();
       await setState({
+        autoRunSessionId: 0,
         autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
         autoRunTimerPlan: null,
         scheduledAutoRunPlan: null,
@@ -603,6 +633,7 @@
           currentRun: deps.getStopRequested() || stoppedEarly ? afterRuntime.autoRunCurrentRun : afterRuntime.autoRunTotalRuns,
           totalRuns: afterRuntime.autoRunTotalRuns,
           attemptRun: afterRuntime.autoRunAttemptRun,
+          sessionId: 0,
         }),
       });
       clearStopRequest();

+ 1 - 0
background/logging-status.js

@@ -137,6 +137,7 @@
         autoRunCurrentRun: payload.currentRun ?? 0,
         autoRunTotalRuns: payload.totalRuns ?? 1,
         autoRunAttemptRun: payload.attemptRun ?? 0,
+        autoRunSessionId: Math.max(0, Math.floor(Number(payload.sessionId ?? payload.autoRunSessionId) || 0)),
         scheduledAutoRunAt: Number.isFinite(Number(payload.scheduledAt)) ? Number(payload.scheduledAt) : null,
         autoRunCountdownAt: Number.isFinite(Number(payload.countdownAt)) ? Number(payload.countdownAt) : null,
         autoRunCountdownTitle: payload.countdownTitle === undefined ? '' : String(payload.countdownTitle || ''),

+ 72 - 41
background/tab-runtime.js

@@ -12,12 +12,73 @@
       LOG_PREFIX,
       matchesSourceUrlFamily,
       setState,
+      sleepWithStop,
       STOP_ERROR_MESSAGE,
       throwIfStopped,
     } = deps;
 
     const pendingCommands = new Map();
 
+    async function sleepOrStop(ms) {
+      if (typeof sleepWithStop === 'function') {
+        await sleepWithStop(ms);
+        return;
+      }
+
+      const start = Date.now();
+      while (Date.now() - start < ms) {
+        throwIfStopped();
+        await new Promise((resolve) => setTimeout(resolve, Math.min(100, ms - (Date.now() - start))));
+      }
+    }
+
+    function waitForTabUpdateComplete(tabId, timeoutMs = 30000) {
+      return new Promise((resolve, reject) => {
+        let settled = false;
+        let stopTimer = null;
+
+        const cleanup = () => {
+          if (settled) return;
+          settled = true;
+          clearTimeout(timer);
+          clearTimeout(stopTimer);
+          chrome.tabs.onUpdated.removeListener(listener);
+        };
+
+        const resolveSafely = () => {
+          cleanup();
+          resolve();
+        };
+
+        const rejectSafely = (error) => {
+          cleanup();
+          reject(error);
+        };
+
+        const listener = (updatedTabId, info) => {
+          if (updatedTabId === tabId && info.status === 'complete') {
+            resolveSafely();
+          }
+        };
+
+        const timer = setTimeout(resolveSafely, timeoutMs);
+        chrome.tabs.onUpdated.addListener(listener);
+
+        const pollStop = () => {
+          if (settled) return;
+          try {
+            throwIfStopped();
+          } catch (error) {
+            rejectSafely(error);
+            return;
+          }
+          stopTimer = setTimeout(pollStop, 100);
+        };
+
+        pollStop();
+      });
+    }
+
     async function getTabRegistry() {
       const state = await getState();
       return state.tabRegistry || {};
@@ -179,7 +240,7 @@
         } catch {
           return null;
         }
-        await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
+        await sleepOrStop(retryDelayMs);
       }
       return null;
     }
@@ -197,7 +258,7 @@
         } catch {
           return null;
         }
-        await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
+        await sleepOrStop(retryDelayMs);
       }
       return null;
     }
@@ -215,7 +276,7 @@
         } catch {
           return null;
         }
-        await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
+        await sleepOrStop(retryDelayMs);
       }
 
       try {
@@ -296,7 +357,7 @@
           logged = true;
         }
 
-        await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
+        await sleepOrStop(retryDelayMs);
       }
 
       throw lastError || new Error(`${getSourceLabel(source)} 内容脚本长时间未就绪。`);
@@ -418,17 +479,7 @@
             if (registry[source]) registry[source].ready = false;
             await setState({ tabRegistry: registry });
             await chrome.tabs.reload(tabId);
-            await new Promise((resolve) => {
-              const timer = setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, 30000);
-              const listener = (tid, info) => {
-                if (tid === tabId && info.status === 'complete') {
-                  chrome.tabs.onUpdated.removeListener(listener);
-                  clearTimeout(timer);
-                  resolve();
-                }
-              };
-              chrome.tabs.onUpdated.addListener(listener);
-            });
+            await waitForTabUpdateComplete(tabId);
           }
 
           if (options.inject) {
@@ -447,7 +498,7 @@
               target: { tabId },
               files: options.inject,
             });
-            await new Promise((resolve) => setTimeout(resolve, 500));
+            await sleepOrStop(500);
           }
 
           await rememberSourceLastUrl(source, url);
@@ -458,17 +509,7 @@
         await setState({ tabRegistry: registry });
         await chrome.tabs.update(tabId, { url, active: true });
 
-        await new Promise((resolve) => {
-          const timer = setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, 30000);
-          const listener = (tid, info) => {
-            if (tid === tabId && info.status === 'complete') {
-              chrome.tabs.onUpdated.removeListener(listener);
-              clearTimeout(timer);
-              resolve();
-            }
-          };
-          chrome.tabs.onUpdated.addListener(listener);
-        });
+        await waitForTabUpdateComplete(tabId);
 
         if (options.inject) {
           if (options.injectSource) {
@@ -486,7 +527,7 @@
           });
         }
 
-        await new Promise((resolve) => setTimeout(resolve, 500));
+        await sleepOrStop(500);
         await rememberSourceLastUrl(source, url);
         return tabId;
       }
@@ -495,17 +536,7 @@
       const tab = await chrome.tabs.create({ url, active: true });
 
       if (options.inject) {
-        await new Promise((resolve) => {
-          const timer = setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, 30000);
-          const listener = (tabId, info) => {
-            if (tabId === tab.id && info.status === 'complete') {
-              chrome.tabs.onUpdated.removeListener(listener);
-              clearTimeout(timer);
-              resolve();
-            }
-          };
-          chrome.tabs.onUpdated.addListener(listener);
-        });
+        await waitForTabUpdateComplete(tab.id);
         if (options.injectSource) {
           await chrome.scripting.executeScript({
             target: { tabId: tab.id },
@@ -580,7 +611,7 @@
             logged = true;
           }
 
-          await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
+          await sleepOrStop(retryDelayMs);
         }
       }
 
@@ -628,7 +659,7 @@
             injectSource: mail.injectSource,
             reloadIfSameUrl: true,
           });
-          await new Promise((resolve) => setTimeout(resolve, 800));
+          await sleepOrStop(800);
         }
       }
 

+ 8 - 3
content/signup-page.js

@@ -523,9 +523,12 @@ async function fillSignupEmailAndContinue(email, step) {
   log(`步骤 ${step}:邮箱已准备提交,正在前往密码页...`);
   window.setTimeout(() => {
     try {
+      throwIfStopped();
       simulateClick(continueButton);
     } catch (error) {
-      console.error('[MultiPage:signup-page] deferred signup email submit failed:', error?.message || error);
+      if (!isStopError(error)) {
+        console.error('[MultiPage:signup-page] deferred signup email submit failed:', error?.message || error);
+      }
     }
   }, 120);
 
@@ -601,12 +604,15 @@ async function step3_fillEmailPassword(payload) {
   if (submitBtn) {
     window.setTimeout(async () => {
       try {
+        throwIfStopped();
         await sleep(500);
         await humanPause(500, 1300);
         simulateClick(submitBtn);
         log('步骤 3:表单已提交');
       } catch (error) {
-        console.error('[MultiPage:signup-page] deferred step 3 submit failed:', error?.message || error);
+        if (!isStopError(error)) {
+          console.error('[MultiPage:signup-page] deferred step 3 submit failed:', error?.message || error);
+        }
       }
     }, 120);
   }
@@ -2344,4 +2350,3 @@ async function step5_fillNameBirthday(payload) {
   log('步骤 5:已点击“完成帐户创建”,当前步骤直接完成,不再等待页面结果。');
   return completionPayload;
 }
-

+ 18 - 0
tests/auto-run-fresh-attempt-reset.test.js

@@ -53,6 +53,7 @@ function extractFunction(source, name) {
 
 const helperBundle = [
   extractFunction(helperSource, 'clearStopRequest'),
+  extractFunction(helperSource, 'normalizeAutoRunSessionId'),
   extractFunction(helperSource, 'throwIfStopped'),
   extractFunction(helperSource, 'isStopError'),
   extractFunction(helperSource, 'isStepDoneStatus'),
@@ -88,6 +89,8 @@ const DEFAULT_STATE = {
 
 let stopRequested = false;
 let runCalls = 0;
+let autoRunSessionId = 0;
+let autoRunSessionSeed = 1000;
 
 const logs = [];
 const broadcasts = [];
@@ -191,6 +194,17 @@ async function persistAutoRunTimerPlan() {}
 async function launchAutoRunTimerPlan() { return false; }
 function getPendingAutoRunTimerPlan() { return null; }
 function getErrorMessage(error) { return error?.message || String(error || ''); }
+function createAutoRunSessionId() {
+  autoRunSessionSeed += 1;
+  autoRunSessionId = autoRunSessionSeed;
+  return autoRunSessionId;
+}
+function throwIfAutoRunSessionStopped(sessionId) {
+  if (sessionId && sessionId !== autoRunSessionId) {
+    throw new Error(STOP_ERROR_MESSAGE);
+  }
+  throwIfStopped();
+}
 const chrome = {
   runtime: {
     sendMessage() {
@@ -242,6 +256,7 @@ const runtime = {
     autoRunCurrentRun: 0,
     autoRunTotalRuns: 1,
     autoRunAttemptRun: 0,
+    autoRunSessionId: 0,
   },
   get() {
     return { ...this.state };
@@ -261,6 +276,7 @@ const controller = self.MultiPageBackgroundAutoRunController.createAutoRunContro
   broadcastStopToContentScripts,
   cancelPendingCommands,
   clearStopRequest,
+  createAutoRunSessionId,
   getAutoRunStatusPayload,
   getErrorMessage,
   getFirstUnfinishedStep,
@@ -279,6 +295,7 @@ const controller = self.MultiPageBackgroundAutoRunController.createAutoRunContro
   runtime,
   setState,
   sleepWithStop,
+  throwIfAutoRunSessionStopped,
   waitForRunningStepsToFinish,
   throwIfStopped,
   chrome,
@@ -309,6 +326,7 @@ return {
   assert.strictEqual(snapshot.currentState.autoRunPhase, 'complete', 'both runs should complete after reset');
   assert.strictEqual(snapshot.currentState.autoRunCurrentRun, 2, 'final run index should be recorded');
   assert.strictEqual(snapshot.autoRunActive, false, 'auto-run should exit active state after completion');
+  assert.strictEqual(snapshot.currentState.autoRunSessionId, 0, 'session id should be cleared after completion');
   assert.strictEqual(snapshot.currentState.gmailBaseEmail, 'demo@gmail.com', 'gmail base email should survive fresh-attempt reset');
   assert.strictEqual(snapshot.currentState.mail2925BaseEmail, 'demo@2925.com', '2925 base email should survive fresh-attempt reset');
 

+ 147 - 0
tests/auto-run-timer-session-guard.test.js

@@ -0,0 +1,147 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+const helperSource = fs.readFileSync('background.js', 'utf8');
+
+function extractFunction(source, name) {
+  const markers = [`async function ${name}(`, `function ${name}(`];
+  const start = markers
+    .map((marker) => source.indexOf(marker))
+    .find((index) => index >= 0);
+  if (start < 0) {
+    throw new Error(`missing function ${name}`);
+  }
+
+  let parenDepth = 0;
+  let signatureEnded = false;
+  let braceStart = -1;
+  for (let i = start; i < source.length; i += 1) {
+    const ch = source[i];
+    if (ch === '(') {
+      parenDepth += 1;
+    } else if (ch === ')') {
+      parenDepth -= 1;
+      if (parenDepth === 0) {
+        signatureEnded = true;
+      }
+    } else if (ch === '{' && signatureEnded) {
+      braceStart = i;
+      break;
+    }
+  }
+  if (braceStart < 0) {
+    throw new Error(`missing body for function ${name}`);
+  }
+
+  let depth = 0;
+  let end = braceStart;
+  for (; end < source.length; end += 1) {
+    const ch = source[end];
+    if (ch === '{') depth += 1;
+    if (ch === '}') {
+      depth -= 1;
+      if (depth === 0) {
+        end += 1;
+        break;
+      }
+    }
+  }
+
+  return source.slice(start, end);
+}
+
+const helperBundle = [
+  extractFunction(helperSource, 'normalizeRunCount'),
+  extractFunction(helperSource, 'normalizeAutoRunTimerKind'),
+  extractFunction(helperSource, 'normalizeAutoRunSessionId'),
+  extractFunction(helperSource, 'isCurrentAutoRunSessionId'),
+  extractFunction(helperSource, 'normalizeAutoRunTimerPlan'),
+  extractFunction(helperSource, 'getAutoRunTimerResumeOptions'),
+  extractFunction(helperSource, 'launchAutoRunTimerPlan'),
+].join('\n');
+
+test('launchAutoRunTimerPlan ignores stale timer plans after stop invalidates the session', async () => {
+  const api = new Function(`
+const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start';
+const AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS = 'between_rounds';
+const AUTO_RUN_TIMER_KIND_BEFORE_RETRY = 'before_retry';
+const AUTO_RUN_MAX_RETRIES_PER_ROUND = 3;
+
+let autoRunTimerLaunching = false;
+let autoRunActive = false;
+let autoRunCurrentRun = 0;
+let autoRunTotalRuns = 1;
+let autoRunAttemptRun = 0;
+let autoRunSessionId = 0;
+
+const state = {
+  autoRunDelayEnabled: false,
+  autoRunTimerPlan: {
+    kind: AUTO_RUN_TIMER_KIND_SCHEDULED_START,
+    fireAt: Date.now() + 60_000,
+    totalRuns: 2,
+    autoRunSkipFailures: false,
+    autoRunSessionId: 42,
+    countdownTitle: '已计划自动运行',
+    countdownNote: '等待启动',
+  },
+};
+
+let startCalls = 0;
+let clearStopCalls = 0;
+let clearAlarmCalls = 0;
+
+async function getState() {
+  return { ...state };
+}
+
+function getPendingAutoRunTimerPlan() {
+  return state.autoRunTimerPlan;
+}
+
+async function clearAutoRunTimerAlarm() {
+  clearAlarmCalls += 1;
+}
+
+async function broadcastAutoRunStatus() {}
+async function addLog() {}
+async function setAutoRunDelayEnabledState() {}
+function serializeAutoRunRoundSummaries(totalRuns, summaries = []) {
+  return Array.isArray(summaries) ? summaries : [];
+}
+function clearStopRequest() {
+  clearStopCalls += 1;
+}
+function startAutoRunLoop() {
+  startCalls += 1;
+}
+
+${helperBundle}
+
+return {
+  launchAutoRunTimerPlan,
+  snapshot() {
+    return {
+      startCalls,
+      clearStopCalls,
+      clearAlarmCalls,
+      autoRunCurrentRun,
+      autoRunTotalRuns,
+      autoRunAttemptRun,
+    };
+  },
+};
+`)();
+
+  const started = await api.launchAutoRunTimerPlan('alarm');
+  const snapshot = api.snapshot();
+
+  assert.equal(started, false);
+  assert.equal(snapshot.startCalls, 0, 'stale timer plan should not restart auto-run');
+  assert.equal(snapshot.clearStopCalls, 0, 'stale timer plan should not clear the stop flag for a cancelled run');
+  assert.equal(snapshot.clearAlarmCalls, 0, 'stale timer plan should not clear a potentially newer alarm');
+  assert.equal(snapshot.autoRunCurrentRun, 0);
+  assert.equal(snapshot.autoRunTotalRuns, 1);
+  assert.equal(snapshot.autoRunAttemptRun, 0);
+});

+ 41 - 0
tests/background-tab-runtime-module.test.js

@@ -47,6 +47,7 @@ test('tab runtime waitForTabComplete waits until tab status becomes complete', a
     registerTab: async () => {},
     setState: async () => {},
     shouldBypassStep9ForLocalCpa: () => false,
+    throwIfStopped: () => {},
   });
 
   const result = await runtime.waitForTabComplete(9, {
@@ -57,3 +58,43 @@ test('tab runtime waitForTabComplete waits until tab status becomes complete', a
   assert.equal(result?.status, 'complete');
   assert.equal(getCalls, 3);
 });
+
+test('tab runtime waitForTabComplete aborts promptly when stop is requested', async () => {
+  const source = fs.readFileSync('background/tab-runtime.js', 'utf8');
+  const globalScope = {};
+  const api = new Function('self', `${source}; return self.MultiPageBackgroundTabRuntime;`)(globalScope);
+
+  let throwCalls = 0;
+  const runtime = api.createTabRuntime({
+    LOG_PREFIX: '[test]',
+    addLog: async () => {},
+    chrome: {
+      tabs: {
+        get: async () => ({
+          id: 9,
+          url: 'https://example.com',
+          status: 'loading',
+        }),
+        query: async () => [],
+      },
+    },
+    getSourceLabel: (sourceName) => sourceName || 'unknown',
+    getState: async () => ({ tabRegistry: {}, sourceLastUrls: {} }),
+    matchesSourceUrlFamily: () => false,
+    setState: async () => {},
+    throwIfStopped: () => {
+      throwCalls += 1;
+      if (throwCalls >= 2) {
+        throw new Error('Flow stopped.');
+      }
+    },
+  });
+
+  await assert.rejects(
+    runtime.waitForTabComplete(9, {
+      timeoutMs: 2000,
+      retryDelayMs: 1,
+    }),
+    /Flow stopped\./
+  );
+});

+ 4 - 0
tests/step3-direct-complete.test.js

@@ -98,6 +98,10 @@ function fillInput(input, value) {
 
 async function humanPause() {}
 async function sleep() {}
+function throwIfStopped() {}
+function isStopError() {
+  return false;
+}
 
 function log(message, level = 'info') {
   logs.push({ message, level });

+ 5 - 0
tests/step8-stop-cleanup.test.js

@@ -52,6 +52,8 @@ function extractFunction(source, name) {
 }
 
 const helperBundle = [
+  extractFunction(helperSource, 'normalizeAutoRunSessionId'),
+  extractFunction(helperSource, 'clearCurrentAutoRunSessionId'),
   extractFunction(helperSource, 'throwIfStopped'),
   extractFunction(helperSource, 'cleanupStep8NavigationListeners'),
   extractFunction(helperSource, 'rejectPendingStep8'),
@@ -71,6 +73,7 @@ let autoRunActive = true;
 let autoRunCurrentRun = 2;
 let autoRunTotalRuns = 3;
 let autoRunAttemptRun = 4;
+let autoRunSessionId = 99;
 const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start';
 const STEP8_CLICK_RETRY_DELAY_MS = 500;
 const STEP8_MAX_ROUNDS = 5;
@@ -257,6 +260,7 @@ return {
       sentMessages,
       clickCount,
       autoRunActive,
+      autoRunSessionId,
     };
   },
 };
@@ -288,6 +292,7 @@ return {
   assert.strictEqual(state.webNavCommittedListener, null, 'Stop 后 onCommitted 引用应为空');
   assert.strictEqual(state.step8TabUpdatedListener, null, 'Stop 后 tabs.onUpdated 引用应为空');
   assert.strictEqual(state.step8PendingReject, null, 'Stop 后不应保留 Step 8 挂起 reject');
+  assert.strictEqual(state.autoRunSessionId, 0, 'Stop 后自动运行 session 应失效');
 
   console.log('step8 stop cleanup tests passed');
 })().catch((error) => {

+ 13 - 8
项目完整链路说明.md

@@ -121,6 +121,7 @@
 - 当前邮箱 / 密码
 - localhost 回调地址
 - 自动运行轮次信息
+- 当前自动运行 session 标识 `autoRunSessionId`
 - 标签注册表
 - 最近打开的来源地址
 - LuckMail 当前运行时选择
@@ -179,6 +180,7 @@
 
 - 页面切换导致脚本暂时失联时,后台不会立刻误判彻底失败
 - 邮箱页或注册页能在注入恢复后继续执行
+- 用户点击 Stop 后,标签完成等待、注入后的短暂延迟和内容脚本重试等待会尽快中断,不会因为底层轮询还在 sleep 而继续往下恢复
 
 ## 6. 手动步骤完整链路
 
@@ -230,7 +232,8 @@
 4. 让内容脚本填写密码
 5. 内容脚本先上报 Step 3 完成信号
 6. 上报完成后再异步点击提交,避免页面跳转打断响应通道
-7. 后台在真正确认 Step 3 完成前,会额外检查提交后是否切换页面;如果出现认证页 `Try again / 重试` 页面,会先自动点击 `重试` 恢复,再继续后续链路
+7. 延迟提交真正触发前会再次检查 Stop 状态,避免用户已停止时页面仍继续自动提交
+8. 后台在真正确认 Step 3 完成前,会额外检查提交后是否切换页面;如果出现认证页 `Try again / 重试` 页面,会先自动点击 `重试` 恢复,再继续后续链路
 
 ### Step 4 / Step 7
 
@@ -440,20 +443,22 @@
 流程:
 
 1. 读取总轮数与模式
-2. 计算是否从中断点继续
-3. 每轮执行前重置必要运行态
-4. 执行 `runAutoSequenceFromStep`
+2. 为本轮自动流程分配唯一 `autoRunSessionId`
+3. 计算是否从中断点继续
+4. 每轮执行前重置必要运行态
+5. 执行 `runAutoSequenceFromStep`
    - 步骤 7 内部仍保留登录态恢复的有限重试
    - 一旦进入步骤 7~10,遇到普通报错且认证流程未进入 `add-phone`,则自动回到步骤 7 无限重开
    - 如果是手动停止,则立即退出自动流程,不会再触发“回到步骤 7 重开”
-5. 如果失败,根据设置决定:
+6. 如果失败,根据设置决定:
    - 立即停止
    - 当前轮重试
    - 下一轮继续
-6. 当前轮最终失败时,写入邮箱记录,并在自动重试链路中累计重试次数
+7. 当前轮最终失败时,写入邮箱记录,并在自动重试链路中累计重试次数
    - 手动停止与自动停止会写入“停止”状态(若后续同邮箱成功/失败,会被覆盖为最新状态)
-7. 如果配置了线程间隔,则挂计时计划
-8. 所有轮次结束后输出汇总
+8. 如果配置了线程间隔,则挂计时计划;计时计划会带上当前 `autoRunSessionId`
+9. 旧 timer / 旧 alarm / 旧恢复入口只有在 session 仍有效时才允许恢复执行;Stop 会立即使当前 session 失效,防止“停止后旧倒计时又把流程重新拉起”
+10. 所有轮次结束后输出汇总,并清空当前 session 标识
 
 ## 9. 新增功能时最容易漏掉的地方
 

+ 6 - 5
项目文件结构说明.md

@@ -41,14 +41,14 @@
 ## `background/`
 
 - `background/account-run-history.js`:邮箱记录模块,负责以邮箱为主键维护最新记录(同邮箱后续状态覆盖),在步骤 2 设定邮箱后支持先写入“停止/未完成”占位状态,统一归一化成功/失败/停止三态并落地到 `chrome.storage.local`,支持清理记录,并在启用独立本地同步配置后把完整快照同步到本地 helper。
-- `background/auto-run-controller.js`:自动运行主控制器,封装多轮执行、重试、轮次摘要、线程间隔与倒计时恢复逻辑;fresh-attempt reset 时会额外保留 `gmailBaseEmail` 与 `mail2925BaseEmail`,避免自动流程重置后丢失别名基邮箱配置。
+- `background/auto-run-controller.js`:自动运行主控制器,封装多轮执行、重试、轮次摘要、线程间隔与倒计时恢复逻辑;当前自动流程会绑定 `autoRunSessionId`,手动停止后旧的倒计时计划、旧重试链路和旧恢复入口不会再复活已失效的自动运行;fresh-attempt reset 时会额外保留 `gmailBaseEmail` 与 `mail2925BaseEmail`,避免自动流程重置后丢失别名基邮箱配置。
 - `background/generated-email-helpers.js`:生成邮箱辅助层,除了 Duck / Cloudflare / iCloud / Cloudflare Temp Email,也统一承接 Gmail / 2925 的别名邮箱生成入口。
 - `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层。
 - `background/message-router.js`:后台消息路由层,负责处理 `chrome.runtime.onMessage` 进入的所有业务消息。
 - `background/navigation-utils.js`:导航与 URL 判断工具层,负责 callback、入口页、CPA / SUB2API 地址、步骤跳转相关判断。
 - `background/panel-bridge.js`:CPA / SUB2API 面板桥接层,封装 OAuth 地址获取所需的页面打开、脚本注入和通信。
 - `background/signup-flow-helpers.js`:注册页辅助层,负责打开注册入口、等待密码页以及解析当前流程所用邮箱;在 Gmail / 2925 模式下会优先复用已经存在且仍兼容的完整注册邮箱,只有不兼容或为空时才重新生成。
-- `background/tab-runtime.js`:标签页与内容脚本运行时基础设施,封装标签注册、冲突清理、消息超时、注入重试与队列。
+- `background/tab-runtime.js`:标签页与内容脚本运行时基础设施,封装标签注册、冲突清理、消息超时、注入重试与队列;当前等待标签完成、注入后的短暂延迟和内容脚本重试等待都已做 Stop 感知,避免用户停止后后台还继续等待并恢复执行
 - `background/verification-flow.js`:注册/登录验证码共享流程层,封装重发、轮询、提交、失败回退、自定义邮箱跳过、共享验证码自动重发次数配置以及 2925 长轮询参数。
 
 ## `background/steps/`
@@ -76,7 +76,7 @@
 - `content/mail-163.js`:163 / 163 VIP 邮箱轮询脚本,负责网页邮箱验证码读取和邮件清理。
 - `content/mail-2925.js`:2925 邮箱页面脚本,负责 2925 邮箱收件轮询与验证码匹配。
 - `content/qq-mail.js`:QQ 邮箱轮询脚本,负责网页邮箱验证码读取。
-- `content/signup-page.js`:注册、登录、授权主内容脚本,负责 OpenAI / ChatGPT 页面上的步骤执行。
+- `content/signup-page.js`:注册、登录、授权主内容脚本,负责 OpenAI / ChatGPT 页面上的步骤执行;其中 Step 2 / 3 的延迟点击与延迟提交在真正触发前会先检查 Stop 状态,避免停止后页面继续自动点击
 - `content/sub2api-panel.js`:SUB2API 后台内容脚本,负责获取 OAuth 地址和提交 localhost 回调;当前承接步骤 10。
 - `content/utils.js`:内容脚本公共工具层,负责日志、READY / COMPLETE / ERROR 上报、元素等待、输入与点击。
 - `content/vps-panel.js`:CPA 面板内容脚本,负责获取 OAuth 地址、提交回调 URL,并基于精确成功徽标与错误态做步骤 10 判定。
@@ -123,6 +123,7 @@
 - `tests/activation-utils.test.js`:测试内容脚本激活策略与平台回调可恢复错误判断。
 - `tests/auth-page-recovery.test.js`:测试认证页共享恢复层的重试页识别与恢复行为。
 - `tests/auto-run-fresh-attempt-reset.test.js`:测试自动运行在新一轮开始前会重置旧运行时上下文,并补充 `gmailBaseEmail` / `mail2925BaseEmail` 在 fresh reset 后仍被保留的回归验证。
+- `tests/auto-run-timer-session-guard.test.js`:测试自动运行的旧计时计划在 Stop 使 session 失效后,不能再把已停止的自动流程重新拉起。
 - `tests/auto-run-step6-restart.test.js`:测试自动运行在后半段授权链路遇错时会回到步骤 7 重开,并在命中 add-phone 时停止重开。
 - `tests/auto-step-random-delay.test.js`:测试自动运行步间延迟与旧配置键兼容解析。
 - `tests/background-auto-run-module.test.js`:测试自动运行控制器模块已接入且导出工厂。
@@ -145,7 +146,7 @@
 - `tests/background-step6-retry-limit.test.js`:测试步骤 6 的 Cookie 清理,以及步骤 7 的有限重试上限。
 - `tests/background-step7-recovery.test.js`:测试步骤 8 获取登录验证码后直接提交(不再回放步骤 7),并为 2925 关闭重发间隔。
 - `tests/background-step-registry.test.js`:测试后台步骤注册表和共享步骤定义已接入。
-- `tests/background-tab-runtime-module.test.js`:测试标签运行时模块已接入且导出工厂。
+- `tests/background-tab-runtime-module.test.js`:测试标签运行时模块已接入且导出工厂,并覆盖等待标签完成时的 Stop 中断行为
 - `tests/background-verification-flow-module.test.js`:测试验证码流程模块已接入且导出工厂。
 - `tests/cloudflare-temp-email-provider.test.js`:测试 Cloudflare Temp Email provider 的轮询与目标邮箱选择逻辑。
 - `tests/cloudflare-temp-email-utils.test.js`:测试 Cloudflare Temp Email 工具层的 URL、域名、邮件解析逻辑。
@@ -165,7 +166,7 @@
 - `tests/signup-entry-diagnostics.test.js`:测试注册入口诊断快照输出。
 - `tests/signup-page-tab-cleanup.test.js`:测试注册页来源标签的冲突清理逻辑。
 - `tests/step-definitions-module.test.js`:测试共享步骤定义模块及侧边栏脚本加载顺序。
-- `tests/step3-direct-complete.test.js`:测试步骤 3 在提交前先完成上报。
+- `tests/step3-direct-complete.test.js`:测试步骤 3 在提交前先完成上报,并保留延迟提交回调的可执行性验证
 - `tests/step5-direct-complete.test.js`:测试步骤 5 在资料页点击提交后立即完成当前步骤。
 - `tests/step6-login-state.test.js`:测试 OAuth 登录状态识别逻辑,当前对应步骤 7 链路。
 - `tests/step6-timeout-recovery.test.js`:测试 OAuth 登录超时报错页的可恢复结果构造,当前对应步骤 7 链路。